| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570 |
- //! Generic handlers for custom payment methods
- //!
- //! These handlers work for ANY custom payment method without requiring
- //! method-specific validation or request parsing.
- //!
- //! Special handling for bolt11 and bolt12:
- //! When the method parameter is "bolt11" or "bolt12", these handlers use the
- //! specific Bolt11/Bolt12 request/response types instead of the generic custom types.
- use axum::extract::{FromRequestParts, Json, Path, State};
- use axum::http::request::Parts;
- use axum::http::StatusCode;
- use axum::response::{IntoResponse, Response};
- use cdk::mint::QuoteId;
- #[cfg(feature = "auth")]
- use cdk::nuts::nut21::{Method, ProtectedEndpoint, RoutePath};
- use cdk::nuts::{
- MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltQuoteBolt12Request,
- MeltQuoteCustomRequest, MintQuoteBolt11Request, MintQuoteBolt11Response,
- MintQuoteBolt12Request, MintQuoteBolt12Response, MintQuoteCustomRequest, MintRequest,
- MintResponse,
- };
- use serde_json::Value;
- use tracing::instrument;
- #[cfg(feature = "auth")]
- use crate::auth::AuthHeader;
- use crate::router_handlers::into_response;
- use crate::MintState;
- const PREFER_HEADER_KEY: &str = "Prefer";
- /// Header extractor for the Prefer header
- ///
- /// This extractor checks for the `Prefer: respond-async` header
- /// to determine if the client wants asynchronous processing
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
- pub struct PreferHeader {
- pub respond_async: bool,
- }
- impl<S> FromRequestParts<S> for PreferHeader
- where
- S: Send + Sync,
- {
- type Rejection = (StatusCode, String);
- async fn from_request_parts(
- parts: &mut Parts,
- _state: &S,
- ) -> anyhow::Result<Self, Self::Rejection> {
- // Check for Prefer header
- if let Some(prefer_value) = parts.headers.get(PREFER_HEADER_KEY) {
- let value = prefer_value.to_str().map_err(|_| {
- (
- StatusCode::BAD_REQUEST,
- "Invalid Prefer header value".to_string(),
- )
- })?;
- // Check if it contains "respond-async"
- let respond_async = value.to_lowercase().contains("respond-async");
- return Ok(PreferHeader { respond_async });
- }
- // No Prefer header found - default to synchronous processing
- Ok(PreferHeader {
- respond_async: false,
- })
- }
- }
- /// Generic handler for custom payment method mint quotes
- ///
- /// This handler works for ANY custom payment method (e.g., paypal, venmo, cashapp, bolt11, bolt12).
- /// For bolt11/bolt12, it handles the specific request/response types.
- /// For other methods, it passes the request data directly to the payment processor.
- #[instrument(skip_all, fields(method = ?method))]
- pub async fn post_mint_custom_quote(
- #[cfg(feature = "auth")] auth: AuthHeader,
- State(state): State<MintState>,
- Path(method): Path<String>,
- Json(payload): Json<Value>,
- ) -> Result<Response, Response> {
- #[cfg(feature = "auth")]
- {
- state
- .mint
- .verify_auth(
- auth.into(),
- &ProtectedEndpoint::new(Method::Post, RoutePath::MintQuote(method.clone())),
- )
- .await
- .map_err(into_response)?;
- }
- match method.as_str() {
- "bolt11" => {
- let bolt11_request: MintQuoteBolt11Request =
- serde_json::from_value(payload).map_err(|e| {
- tracing::error!("Failed to parse bolt11 request: {}", e);
- into_response(cdk::Error::InvalidPaymentMethod)
- })?;
- let quote = state
- .mint
- .get_mint_quote(bolt11_request.into())
- .await
- .map_err(into_response)?;
- let response: MintQuoteBolt11Response<QuoteId> =
- quote.try_into().map_err(into_response)?;
- Ok(Json(response).into_response())
- }
- "bolt12" => {
- let bolt12_request: MintQuoteBolt12Request =
- serde_json::from_value(payload).map_err(|e| {
- tracing::error!("Failed to parse bolt12 request: {}", e);
- into_response(cdk::Error::InvalidPaymentMethod)
- })?;
- let quote = state
- .mint
- .get_mint_quote(bolt12_request.into())
- .await
- .map_err(into_response)?;
- let response: MintQuoteBolt12Response<QuoteId> =
- quote.try_into().map_err(into_response)?;
- Ok(Json(response).into_response())
- }
- _ => {
- let custom_request: MintQuoteCustomRequest =
- serde_json::from_value(payload).map_err(|e| {
- tracing::error!("Failed to parse custom request: {}", e);
- into_response(cdk::Error::InvalidPaymentMethod)
- })?;
- let quote_request = cdk::mint::MintQuoteRequest::Custom {
- method,
- request: custom_request,
- };
- let response = state
- .mint
- .get_mint_quote(quote_request)
- .await
- .map_err(into_response)?;
- match response {
- cdk::mint::MintQuoteResponse::Custom { response, .. } => {
- Ok(Json(response).into_response())
- }
- _ => Err(into_response(cdk::Error::InvalidPaymentMethod)),
- }
- }
- }
- }
- /// Get custom payment method mint quote status
- #[instrument(skip_all, fields(method = ?method, quote_id = ?quote_id))]
- pub async fn get_check_mint_custom_quote(
- #[cfg(feature = "auth")] auth: AuthHeader,
- State(state): State<MintState>,
- Path((method, quote_id)): Path<(String, QuoteId)>,
- ) -> Result<Response, Response> {
- #[cfg(feature = "auth")]
- {
- state
- .mint
- .verify_auth(
- auth.into(),
- &ProtectedEndpoint::new(Method::Get, RoutePath::MintQuote(method.clone())),
- )
- .await
- .map_err(into_response)?;
- }
- let quote_response = state
- .mint
- .check_mint_quote("e_id)
- .await
- .map_err(into_response)?;
- match method.as_str() {
- "bolt11" => {
- let response: MintQuoteBolt11Response<QuoteId> =
- quote_response.try_into().map_err(into_response)?;
- Ok(Json(response).into_response())
- }
- "bolt12" => {
- let response: MintQuoteBolt12Response<QuoteId> =
- quote_response.try_into().map_err(into_response)?;
- Ok(Json(response).into_response())
- }
- _ => {
- // Extract and verify it's a Custom payment method
- match quote_response {
- cdk::mint::MintQuoteResponse::Custom {
- method: quote_method,
- response,
- } => {
- if quote_method != method {
- return Err(into_response(cdk::Error::InvalidPaymentMethod));
- }
- Ok(Json(response).into_response())
- }
- _ => Err(into_response(cdk::Error::InvalidPaymentMethod)),
- }
- }
- }
- }
- /// Mint tokens with custom payment method
- #[instrument(skip_all, fields(method = ?method, quote_id = ?payload.quote))]
- pub async fn post_mint_custom(
- #[cfg(feature = "auth")] auth: AuthHeader,
- State(state): State<MintState>,
- Path(method): Path<String>,
- Json(payload): Json<MintRequest<QuoteId>>,
- ) -> Result<Json<MintResponse>, Response> {
- #[cfg(feature = "auth")]
- {
- state
- .mint
- .verify_auth(
- auth.into(),
- &ProtectedEndpoint::new(Method::Post, RoutePath::Mint(method.clone())),
- )
- .await
- .map_err(into_response)?;
- }
- // Note: process_mint_request will validate the quote internally
- // including checking if it's paid and matches the expected payment method
- let res = state
- .mint
- .process_mint_request(payload)
- .await
- .map_err(into_response)?;
- Ok(Json(res))
- }
- /// Request a melt quote for custom payment method
- #[instrument(skip_all, fields(method = ?method))]
- pub async fn post_melt_custom_quote(
- #[cfg(feature = "auth")] auth: AuthHeader,
- State(state): State<MintState>,
- Path(method): Path<String>,
- Json(payload): Json<Value>,
- ) -> Result<Json<MeltQuoteBolt11Response<QuoteId>>, Response> {
- #[cfg(feature = "auth")]
- {
- state
- .mint
- .verify_auth(
- auth.into(),
- &ProtectedEndpoint::new(Method::Post, RoutePath::MeltQuote(method.clone())),
- )
- .await
- .map_err(into_response)?;
- }
- let response = match method.as_str() {
- "bolt11" => {
- let bolt11_request: MeltQuoteBolt11Request =
- serde_json::from_value(payload).map_err(|e| {
- tracing::error!("Failed to parse bolt11 melt request: {}", e);
- into_response(cdk::Error::InvalidPaymentMethod)
- })?;
- state
- .mint
- .get_melt_quote(bolt11_request.into())
- .await
- .map_err(into_response)?
- }
- "bolt12" => {
- let bolt12_request: MeltQuoteBolt12Request =
- serde_json::from_value(payload).map_err(|e| {
- tracing::error!("Failed to parse bolt12 melt request: {}", e);
- into_response(cdk::Error::InvalidPaymentMethod)
- })?;
- state
- .mint
- .get_melt_quote(bolt12_request.into())
- .await
- .map_err(into_response)?
- }
- _ => {
- let custom_request: MeltQuoteCustomRequest =
- serde_json::from_value(payload).map_err(|e| {
- tracing::error!("Failed to parse custom melt request: {}", e);
- into_response(cdk::Error::InvalidPaymentMethod)
- })?;
- state
- .mint
- .get_melt_quote(custom_request.into())
- .await
- .map_err(into_response)?
- }
- };
- Ok(Json(response))
- }
- /// Get custom payment method melt quote status
- #[instrument(skip_all, fields(method = ?method, quote_id = ?quote_id))]
- pub async fn get_check_melt_custom_quote(
- #[cfg(feature = "auth")] auth: AuthHeader,
- State(state): State<MintState>,
- Path((method, quote_id)): Path<(String, QuoteId)>,
- ) -> Result<Json<MeltQuoteBolt11Response<QuoteId>>, Response> {
- #[cfg(feature = "auth")]
- {
- state
- .mint
- .verify_auth(
- auth.into(),
- &ProtectedEndpoint::new(Method::Get, RoutePath::MeltQuote(method.clone())),
- )
- .await
- .map_err(into_response)?;
- }
- // Note: check_melt_quote returns the response directly
- // The payment method validation is done when the quote was created
- let quote = state
- .mint
- .check_melt_quote("e_id)
- .await
- .map_err(into_response)?;
- Ok(Json(quote))
- }
- /// Melt tokens with custom payment method
- #[instrument(skip_all, fields(method = ?method))]
- pub async fn post_melt_custom(
- #[cfg(feature = "auth")] auth: AuthHeader,
- prefer: PreferHeader,
- State(state): State<MintState>,
- Path(method): Path<String>,
- Json(payload): Json<cdk::nuts::MeltRequest<QuoteId>>,
- ) -> Result<Json<MeltQuoteBolt11Response<QuoteId>>, Response> {
- #[cfg(feature = "auth")]
- {
- state
- .mint
- .verify_auth(
- auth.into(),
- &ProtectedEndpoint::new(Method::Post, RoutePath::Melt(method.clone())),
- )
- .await
- .map_err(into_response)?;
- }
- let res = if prefer.respond_async {
- // Asynchronous processing - return immediately after setup
- state
- .mint
- .melt_async(&payload)
- .await
- .map_err(into_response)?
- } else {
- // Synchronous processing - wait for completion
- state.mint.melt(&payload).await.map_err(into_response)?
- };
- Ok(Json(res))
- }
- // ============================================================================
- // CACHED HANDLERS FOR NUT-19 SUPPORT
- // ============================================================================
- /// Cached version of post_mint_custom for NUT-19 caching support
- #[instrument(skip_all, fields(method = ?method, quote_id = ?payload.quote))]
- pub async fn cache_post_mint_custom(
- #[cfg(feature = "auth")] auth: AuthHeader,
- state: State<MintState>,
- method: Path<String>,
- payload: Json<MintRequest<QuoteId>>,
- ) -> Result<Json<MintResponse>, Response> {
- use std::ops::Deref;
- let State(mint_state) = state.clone();
- let json_extracted_payload = payload.deref();
- let cache_key = match mint_state.cache.calculate_key(json_extracted_payload) {
- Some(key) => key,
- None => {
- // Could not calculate key, just return the handler result
- #[cfg(feature = "auth")]
- return post_mint_custom(auth, state, method, payload).await;
- #[cfg(not(feature = "auth"))]
- return post_mint_custom(state, method, payload).await;
- }
- };
- if let Some(cached_response) = mint_state.cache.get::<MintResponse>(&cache_key).await {
- return Ok(Json(cached_response));
- }
- #[cfg(feature = "auth")]
- let result = post_mint_custom(auth, state, method, payload).await?;
- #[cfg(not(feature = "auth"))]
- let result = post_mint_custom(state, method, payload).await?;
- // Cache the response
- mint_state.cache.set(cache_key, result.deref()).await;
- Ok(result)
- }
- #[cfg(test)]
- mod tests {
- use axum::http::{HeaderValue, Request, StatusCode};
- use super::*;
- fn create_test_request(prefer_header: Option<&str>) -> Request<()> {
- let mut req = Request::builder()
- .method("POST")
- .uri("/test")
- .body(())
- .unwrap();
- if let Some(header_value) = prefer_header {
- req.headers_mut().insert(
- PREFER_HEADER_KEY,
- HeaderValue::from_str(header_value).unwrap(),
- );
- }
- req
- }
- fn create_test_request_with_bytes(bytes: &[u8]) -> Request<()> {
- let mut req = Request::builder()
- .method("POST")
- .uri("/test")
- .body(())
- .unwrap();
- req.headers_mut()
- .insert(PREFER_HEADER_KEY, HeaderValue::from_bytes(bytes).unwrap());
- req
- }
- #[tokio::test]
- async fn test_prefer_header_respond_async() {
- let req = create_test_request(Some("respond-async"));
- let (mut parts, _) = req.into_parts();
- let result = PreferHeader::from_request_parts(&mut parts, &()).await;
- assert!(result.is_ok());
- assert!(result.unwrap().respond_async);
- }
- #[tokio::test]
- async fn test_prefer_header_respond_async_with_other_values() {
- let req = create_test_request(Some("respond-async; wait=10"));
- let (mut parts, _) = req.into_parts();
- let result = PreferHeader::from_request_parts(&mut parts, &()).await;
- assert!(result.is_ok());
- assert!(result.unwrap().respond_async);
- }
- #[tokio::test]
- async fn test_prefer_header_case_insensitive() {
- let req = create_test_request(Some("RESPOND-ASYNC"));
- let (mut parts, _) = req.into_parts();
- let result = PreferHeader::from_request_parts(&mut parts, &()).await;
- assert!(result.is_ok());
- assert!(result.unwrap().respond_async);
- }
- #[tokio::test]
- async fn test_prefer_header_no_respond_async() {
- let req = create_test_request(Some("wait=10"));
- let (mut parts, _) = req.into_parts();
- let result = PreferHeader::from_request_parts(&mut parts, &()).await;
- assert!(result.is_ok());
- assert!(!result.unwrap().respond_async);
- }
- #[tokio::test]
- async fn test_prefer_header_missing() {
- let req = create_test_request(None);
- let (mut parts, _) = req.into_parts();
- let result = PreferHeader::from_request_parts(&mut parts, &()).await;
- assert!(result.is_ok());
- assert!(!result.unwrap().respond_async);
- }
- #[tokio::test]
- async fn test_prefer_header_invalid_value() {
- let req = create_test_request_with_bytes(&[0xFF, 0xFE]);
- let (mut parts, _) = req.into_parts();
- let result = PreferHeader::from_request_parts(&mut parts, &()).await;
- assert!(result.is_err());
- let (status, message) = result.unwrap_err();
- assert_eq!(status, StatusCode::BAD_REQUEST);
- assert_eq!(message, "Invalid Prefer header value");
- }
- #[tokio::test]
- async fn test_prefer_header_empty_value() {
- let req = create_test_request(Some(""));
- let (mut parts, _) = req.into_parts();
- let result = PreferHeader::from_request_parts(&mut parts, &()).await;
- assert!(result.is_ok());
- assert!(!result.unwrap().respond_async);
- }
- }
- /// Cached version of post_melt_custom for NUT-19 caching support
- #[instrument(skip_all, fields(method = ?method))]
- pub async fn cache_post_melt_custom(
- #[cfg(feature = "auth")] auth: AuthHeader,
- prefer: PreferHeader,
- state: State<MintState>,
- method: Path<String>,
- payload: Json<cdk::nuts::MeltRequest<QuoteId>>,
- ) -> Result<Json<MeltQuoteBolt11Response<QuoteId>>, Response> {
- use std::ops::Deref;
- let State(mint_state) = state.clone();
- let json_extracted_payload = payload.deref();
- let cache_key = match mint_state.cache.calculate_key(json_extracted_payload) {
- Some(key) => key,
- None => {
- // Could not calculate key, just return the handler result
- #[cfg(feature = "auth")]
- return post_melt_custom(auth, prefer, state, method, payload).await;
- #[cfg(not(feature = "auth"))]
- return post_melt_custom(prefer, state, method, payload).await;
- }
- };
- if let Some(cached_response) = mint_state
- .cache
- .get::<MeltQuoteBolt11Response<QuoteId>>(&cache_key)
- .await
- {
- return Ok(Json(cached_response));
- }
- #[cfg(feature = "auth")]
- let result = post_melt_custom(auth, prefer, state, method, payload).await?;
- #[cfg(not(feature = "auth"))]
- let result = post_melt_custom(prefer, state, method, payload).await?;
- // Cache the response
- mint_state.cache.set(cache_key, result.deref()).await;
- Ok(result)
- }
|