use crate::error::MppError;
use crate::protocol::core::{
with_private_cache_control, PaymentChallenge, PaymentCredential, Receipt,
};
pub struct ChallengeContext<'a, I> {
pub challenge: &'a PaymentChallenge,
pub input: &'a I,
pub error: Option<&'a str>,
}
pub struct ReceiptContext<'a, R> {
pub challenge_id: &'a str,
pub receipt: &'a Receipt,
pub response: R,
}
pub trait Transport: Send + Sync {
type Input;
type ChallengeOutput;
type ReceiptOutput;
fn name(&self) -> &str;
fn get_credential(&self, input: &Self::Input) -> Result<Option<PaymentCredential>, MppError>;
fn respond_challenge(&self, ctx: ChallengeContext<'_, Self::Input>) -> Self::ChallengeOutput;
fn respond_receipt(&self, ctx: ReceiptContext<'_, Self::ReceiptOutput>) -> Self::ReceiptOutput;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct HttpTransport {
requires_auth: bool,
}
pub fn http() -> HttpTransport {
HttpTransport {
requires_auth: false,
}
}
impl HttpTransport {
pub fn requires_auth(mut self, enabled: bool) -> Self {
self.requires_auth = enabled;
self
}
fn credential_header(&self) -> &'static str {
if self.requires_auth {
crate::protocol::core::PAYMENT_AUTHORIZATION_HEADER
} else {
http_types::header::AUTHORIZATION.as_str()
}
}
}
impl Transport for HttpTransport {
type Input = http_types::Request<()>;
type ChallengeOutput = http_types::Response<String>;
type ReceiptOutput = http_types::Response<String>;
fn name(&self) -> &str {
"http"
}
fn get_credential(&self, input: &Self::Input) -> Result<Option<PaymentCredential>, MppError> {
let Some(header) = input.headers().get(self.credential_header()) else {
return Ok(None);
};
let header_str = header
.to_str()
.map_err(|e| MppError::MalformedCredential(Some(format!("invalid header: {e}"))))?;
let Some(payment) = crate::protocol::core::extract_payment_scheme(header_str) else {
return Ok(None);
};
let credential = crate::protocol::core::parse_authorization(payment)?;
Ok(Some(credential))
}
fn respond_challenge(&self, ctx: ChallengeContext<'_, Self::Input>) -> Self::ChallengeOutput {
let www_auth = crate::protocol::core::format_www_authenticate(ctx.challenge)
.ok()
.and_then(|value| http_types::HeaderValue::from_str(&value).ok());
let (status, error) = match (&www_auth, ctx.error) {
(None, _) => (
http_types::StatusCode::INTERNAL_SERVER_ERROR,
"Failed to format challenge",
),
(Some(_), Some(msg)) => (http_types::StatusCode::PAYMENT_REQUIRED, msg),
(Some(_), None) => (http_types::StatusCode::PAYMENT_REQUIRED, "Payment Required"),
};
let mut resp = http_types::Response::new(serde_json::json!({ "error": error }).to_string());
*resp.status_mut() = status;
if let Some(www_auth) = www_auth {
resp.headers_mut()
.insert(http_types::header::WWW_AUTHENTICATE, www_auth);
}
resp.headers_mut().insert(
http_types::header::CONTENT_TYPE,
http_types::HeaderValue::from_static("application/json"),
);
resp.headers_mut().insert(
http_types::header::CACHE_CONTROL,
http_types::HeaderValue::from_static("no-store"),
);
resp
}
fn respond_receipt(&self, ctx: ReceiptContext<'_, Self::ReceiptOutput>) -> Self::ReceiptOutput {
let receipt_header =
crate::protocol::core::format_receipt(ctx.receipt).unwrap_or_else(|_| String::new());
let mut resp = ctx.response;
let existing_cc = resp
.headers()
.get_all(http_types::header::CACHE_CONTROL)
.iter()
.filter_map(|v| v.to_str().ok())
.collect::<Vec<_>>()
.join(", ");
let cache_control = with_private_cache_control(Some(existing_cc.as_str()));
if let Ok(value) = http_types::HeaderValue::from_str(&cache_control) {
resp.headers_mut()
.insert(http_types::header::CACHE_CONTROL, value);
}
if let Ok(value) = http_types::HeaderValue::from_str(&receipt_header) {
resp.headers_mut()
.insert(crate::protocol::core::PAYMENT_RECEIPT_HEADER, value);
}
resp
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_http_transport_name() {
let transport = http();
assert_eq!(transport.name(), "http");
}
#[test]
fn test_http_get_credential_none() {
let transport = http();
let req = http_types::Request::builder()
.uri("/test")
.body(())
.unwrap();
let result = transport.get_credential(&req).unwrap();
assert!(result.is_none());
}
#[test]
fn test_http_get_credential_non_payment_auth() {
let transport = http();
let req = http_types::Request::builder()
.uri("/test")
.header("Authorization", "Bearer some-token")
.body(())
.unwrap();
let result = transport.get_credential(&req).unwrap();
assert!(result.is_none());
}
#[test]
fn test_http_get_credential_valid_payment() {
let transport = http();
let challenge = PaymentChallenge::new(
"test-id",
"test.example.com",
"tempo",
"charge",
crate::protocol::core::Base64UrlJson::from_value(
&serde_json::json!({"amount": "1000"}),
)
.unwrap(),
);
let credential = crate::protocol::core::PaymentCredential::new(
challenge.to_echo(),
crate::protocol::core::PaymentPayload::hash("0xdeadbeef"),
);
let auth_header = crate::protocol::core::format_authorization(&credential).unwrap();
let req = http_types::Request::builder()
.uri("/test")
.header("Authorization", &auth_header)
.body(())
.unwrap();
let result = transport.get_credential(&req).unwrap();
assert!(result.is_some(), "should parse valid Payment credential");
let parsed = result.unwrap();
assert_eq!(parsed.challenge.id, "test-id");
}
#[test]
fn test_http_get_credential_from_payment_authorization() {
let transport = http().requires_auth(true);
let mut challenge = PaymentChallenge::new(
"test-id",
"test.example.com",
"tempo",
"charge",
crate::protocol::core::Base64UrlJson::from_value(
&serde_json::json!({"amount": "1000"}),
)
.unwrap(),
);
challenge.header = Some("Payment-Authorization".to_string());
let credential = crate::protocol::core::PaymentCredential::new(
challenge.to_echo(),
crate::protocol::core::PaymentPayload::hash("0xdeadbeef"),
);
let auth_header = crate::protocol::core::format_authorization(&credential).unwrap();
let req = http_types::Request::builder()
.uri("/test")
.header("Authorization", "Bearer ordinary-authentication")
.header("Payment-Authorization", &auth_header)
.body(())
.unwrap();
let result = transport.get_credential(&req).unwrap();
assert!(
result.is_some(),
"should parse Payment-Authorization credential"
);
let parsed = result.unwrap();
assert_eq!(parsed.challenge.id, "test-id");
}
#[test]
fn test_http_respond_challenge() {
let transport = http();
let challenge = PaymentChallenge::new(
"test-id",
"test.example.com",
"tempo",
"charge",
crate::protocol::core::Base64UrlJson::from_value(
&serde_json::json!({"amount": "1000"}),
)
.unwrap(),
);
let req = http_types::Request::builder()
.uri("/test")
.body(())
.unwrap();
let resp = transport.respond_challenge(ChallengeContext {
challenge: &challenge,
input: &req,
error: None,
});
assert_eq!(resp.status(), http_types::StatusCode::PAYMENT_REQUIRED);
assert!(resp
.headers()
.get(http_types::header::WWW_AUTHENTICATE)
.is_some());
assert!(resp.body().contains("Payment Required"));
}
#[test]
fn test_http_respond_challenge_with_control_character() {
let transport = http();
let challenge = PaymentChallenge::new(
"test-id",
"test.example.com",
"tempo",
"charge",
crate::protocol::core::Base64UrlJson::from_value(
&serde_json::json!({"amount": "1000"}),
)
.unwrap(),
)
.with_description("bell\u{7}");
let req = http_types::Request::builder()
.uri("/test")
.body(())
.unwrap();
let resp = transport.respond_challenge(ChallengeContext {
challenge: &challenge,
input: &req,
error: None,
});
assert_eq!(resp.status(), http_types::StatusCode::PAYMENT_REQUIRED);
let www_auth = resp.headers()[http_types::header::WWW_AUTHENTICATE]
.to_str()
.unwrap();
let parsed = crate::protocol::core::parse_www_authenticate(www_auth).unwrap();
assert_eq!(parsed.description, challenge.description);
}
#[test]
fn test_http_respond_challenge_unformattable_challenge() {
let transport = http();
let challenge = PaymentChallenge::new(
"",
"test.example.com",
"tempo",
"charge",
crate::protocol::core::Base64UrlJson::from_value(
&serde_json::json!({"amount": "1000"}),
)
.unwrap(),
);
let req = http_types::Request::builder()
.uri("/test")
.body(())
.unwrap();
let resp = transport.respond_challenge(ChallengeContext {
challenge: &challenge,
input: &req,
error: None,
});
assert_eq!(resp.status(), http_types::StatusCode::INTERNAL_SERVER_ERROR);
assert!(!resp
.headers()
.contains_key(http_types::header::WWW_AUTHENTICATE));
}
#[test]
fn test_http_respond_challenge_with_error() {
let transport = http();
let challenge = PaymentChallenge::new(
"test-id",
"test.example.com",
"tempo",
"charge",
crate::protocol::core::Base64UrlJson::from_value(
&serde_json::json!({"amount": "1000"}),
)
.unwrap(),
);
let req = http_types::Request::builder()
.uri("/test")
.body(())
.unwrap();
let resp = transport.respond_challenge(ChallengeContext {
challenge: &challenge,
input: &req,
error: Some("Verification failed"),
});
assert_eq!(resp.status(), http_types::StatusCode::PAYMENT_REQUIRED);
assert!(resp.body().contains("Verification failed"));
}
#[test]
fn test_http_respond_receipt() {
let transport = http();
let receipt = Receipt::success("tempo", "0xabc123");
let resp = http_types::Response::builder()
.status(http_types::StatusCode::OK)
.body("ok".to_string())
.unwrap();
let resp = transport.respond_receipt(ReceiptContext {
challenge_id: "ch-1",
receipt: &receipt,
response: resp,
});
assert_eq!(resp.status(), http_types::StatusCode::OK);
assert!(resp
.headers()
.get(crate::protocol::core::PAYMENT_RECEIPT_HEADER)
.is_some());
assert_eq!(
resp.headers()
.get(http_types::header::CACHE_CONTROL)
.unwrap(),
"private"
);
}
#[test]
fn test_http_respond_receipt_merges_existing_cache_control() {
let transport = http();
let receipt = Receipt::success("tempo", "0xabc123");
let resp = http_types::Response::builder()
.status(http_types::StatusCode::OK)
.header(http_types::header::CACHE_CONTROL, "no-store")
.body("ok".to_string())
.unwrap();
let resp = transport.respond_receipt(ReceiptContext {
challenge_id: "ch-1",
receipt: &receipt,
response: resp,
});
assert_eq!(
resp.headers()
.get(http_types::header::CACHE_CONTROL)
.unwrap(),
"no-store, private"
);
}
#[test]
fn test_http_respond_receipt_merges_multiple_cache_control_values() {
let transport = http();
let receipt = Receipt::success("tempo", "0xabc123");
let resp = http_types::Response::builder()
.status(http_types::StatusCode::OK)
.header(http_types::header::CACHE_CONTROL, "no-cache")
.header(http_types::header::CACHE_CONTROL, "max-age=60")
.body("ok".to_string())
.unwrap();
let resp = transport.respond_receipt(ReceiptContext {
challenge_id: "ch-1",
receipt: &receipt,
response: resp,
});
let values: Vec<_> = resp
.headers()
.get_all(http_types::header::CACHE_CONTROL)
.iter()
.map(|v| v.to_str().unwrap())
.collect();
assert_eq!(values, vec!["no-cache, max-age=60, private"]);
}
}