r402_http/types.rs
1//! HTTP-specific types for the x402 payment protocol server middleware.
2//!
3//! Provides route configuration, payment options, request context, and
4//! processing result types used by [`super::server::PaymentGate`].
5//!
6//! Corresponds to Python SDK's `http/types.py`.
7
8use r402::proto::{PaymentPayload, PaymentRequirements};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12/// A payment option accepted by a protected route.
13///
14/// Defines a (scheme, network) pair along with price and recipient for a
15/// single payment method accepted at an endpoint.
16///
17/// Corresponds to Python SDK's `PaymentOption`.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct PaymentOption {
21 /// Payment scheme identifier (e.g., `"exact"`).
22 pub scheme: String,
23
24 /// Recipient address (e.g., `"0x..."`).
25 pub pay_to: String,
26
27 /// Price — a money string (e.g., `"1.50"`) or structured amount.
28 pub price: Value,
29
30 /// CAIP-2 network identifier (e.g., `"eip155:8453"`).
31 pub network: String,
32
33 /// Maximum payment validity in seconds (defaults to 300).
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub max_timeout_seconds: Option<u64>,
36
37 /// Scheme-specific extra data.
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub extra: Option<Value>,
40}
41
42/// Configuration for a payment-protected route.
43///
44/// Specifies which payment options a route accepts, along with optional
45/// metadata for resource description and paywall customisation.
46///
47/// Corresponds to Python SDK's `RouteConfig`.
48#[derive(Debug, Clone)]
49pub struct RouteConfig {
50 /// Accepted payment options for this route.
51 pub accepts: Vec<PaymentOption>,
52
53 /// Override resource URL (defaults to request URL).
54 pub resource: Option<String>,
55
56 /// Human-readable description of the resource.
57 pub description: Option<String>,
58
59 /// MIME type of the resource.
60 pub mime_type: Option<String>,
61}
62
63impl RouteConfig {
64 /// Creates a new route config with a single payment option.
65 #[must_use]
66 pub fn single(option: PaymentOption) -> Self {
67 Self {
68 accepts: vec![option],
69 resource: None,
70 description: None,
71 mime_type: None,
72 }
73 }
74
75 /// Creates a new route config with multiple payment options.
76 #[must_use]
77 pub fn multi(options: Vec<PaymentOption>) -> Self {
78 Self {
79 accepts: options,
80 resource: None,
81 description: None,
82 mime_type: None,
83 }
84 }
85
86 /// Sets the resource URL override.
87 #[must_use]
88 pub fn with_resource(mut self, resource: impl Into<String>) -> Self {
89 self.resource = Some(resource.into());
90 self
91 }
92
93 /// Sets the resource description.
94 #[must_use]
95 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
96 self.description = Some(desc.into());
97 self
98 }
99
100 /// Sets the MIME type.
101 #[must_use]
102 pub fn with_mime_type(mut self, mime: impl Into<String>) -> Self {
103 self.mime_type = Some(mime.into());
104 self
105 }
106}
107
108/// Result of processing an HTTP request through the payment gate.
109///
110/// Corresponds to Python SDK's `HTTPProcessResult`.
111#[derive(Debug)]
112pub enum ProcessResult {
113 /// Route does not require payment — pass through to inner service.
114 NoPaymentRequired,
115
116 /// Payment verified successfully.
117 PaymentVerified {
118 /// The verified payment payload.
119 payload: PaymentPayload,
120 /// The matching payment requirements.
121 requirements: PaymentRequirements,
122 },
123
124 /// Payment error — return 402 or error response.
125 PaymentError {
126 /// HTTP status code (typically 402 or 500).
127 status: u16,
128 /// Response headers to include.
129 headers: Vec<(String, String)>,
130 /// JSON response body.
131 body: Value,
132 },
133}
134
135/// Result of settlement processing after a successful response.
136///
137/// Corresponds to Python SDK's `ProcessSettleResult`.
138#[derive(Debug)]
139pub struct SettleResult {
140 /// Whether settlement succeeded.
141 pub success: bool,
142 /// Error reason if settlement failed.
143 pub error_reason: Option<String>,
144 /// Headers to add to the response (e.g., `PAYMENT-RESPONSE`).
145 pub headers: Vec<(String, String)>,
146 /// Transaction hash/ID.
147 pub transaction: Option<String>,
148 /// Network identifier.
149 pub network: Option<String>,
150 /// Payer address.
151 pub payer: Option<String>,
152}
153
154/// Paywall UI configuration for browser-based 402 responses.
155///
156/// Corresponds to Python SDK's `PaywallConfig` in `http/types.py`.
157#[derive(Debug, Clone, Default, Serialize, Deserialize)]
158#[serde(rename_all = "camelCase")]
159pub struct PaywallConfig {
160 /// Application name to display.
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub app_name: Option<String>,
163
164 /// URL to application logo.
165 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub app_logo: Option<String>,
167
168 /// Whether this is a testnet deployment.
169 #[serde(default)]
170 pub testnet: bool,
171}
172
173/// A validation error for a route configuration.
174///
175/// Returned by [`super::server::PaymentGateLayer::validate_routes`] when a
176/// payment option references an unregistered scheme or unsupported
177/// facilitator combination.
178///
179/// Corresponds to Python SDK's `RouteValidationError` in `http/types.py`.
180#[derive(Debug, Clone)]
181pub struct RouteValidationError {
182 /// The route pattern (e.g., `"GET /weather"`).
183 pub route_pattern: String,
184 /// Scheme identifier (e.g., `"exact"`).
185 pub scheme: String,
186 /// CAIP-2 network identifier.
187 pub network: String,
188 /// Reason code (`"missing_scheme"` or `"missing_facilitator"`).
189 pub reason: String,
190 /// Human-readable error message.
191 pub message: String,
192}
193
194impl std::fmt::Display for RouteValidationError {
195 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196 write!(f, "{}", self.message)
197 }
198}
199
200/// A compiled route entry mapping a method + path pattern to its config.
201#[derive(Debug, Clone)]
202pub(crate) struct CompiledRoute {
203 /// HTTP method (uppercase) or `"*"` for any method.
204 pub method: String,
205 /// Path pattern (e.g., `/weather`, `/api/*`).
206 pub path_pattern: String,
207 /// Payment configuration for this route.
208 pub config: RouteConfig,
209}
210
211impl CompiledRoute {
212 /// Checks whether this route matches the given method and path.
213 pub fn matches(&self, method: &str, path: &str) -> bool {
214 // Method match
215 if self.method != "*" && !self.method.eq_ignore_ascii_case(method) {
216 return false;
217 }
218
219 // Path match with simple glob support
220 match_path_pattern(&self.path_pattern, path)
221 }
222}
223
224/// Simple glob-style path matching.
225///
226/// Supports:
227/// - Exact match: `/weather` matches `/weather`
228/// - Trailing wildcard: `/api/*` matches `/api/foo` and `/api/foo/bar`
229/// - Full wildcard: `*` matches everything
230fn match_path_pattern(pattern: &str, path: &str) -> bool {
231 if pattern == "*" {
232 return true;
233 }
234
235 let normalized_path = path.split('?').next().unwrap_or(path);
236 let normalized_path = normalized_path.trim_end_matches('/');
237 let normalized_pattern = pattern.trim_end_matches('/');
238
239 if normalized_pattern.ends_with("/*") {
240 let prefix = &normalized_pattern[..normalized_pattern.len() - 2];
241 normalized_path == prefix || normalized_path.starts_with(&format!("{prefix}/"))
242 } else {
243 normalized_path.eq_ignore_ascii_case(normalized_pattern)
244 }
245}
246
247/// Parses a route pattern string into method + path.
248///
249/// Supports formats:
250/// - `"GET /weather"` → method=`GET`, path=`/weather`
251/// - `"/weather"` → method=`*`, path=`/weather`
252/// - `"*"` → method=`*`, path=`*`
253pub(crate) fn parse_route_pattern(pattern: &str) -> (String, String) {
254 let trimmed = pattern.trim();
255 if let Some((method, path)) = trimmed.split_once(char::is_whitespace) {
256 (method.to_uppercase(), path.trim().to_owned())
257 } else {
258 ("*".to_owned(), trimmed.to_owned())
259 }
260}