1use std::convert::Infallible;
31use std::future::Future;
32use std::pin::Pin;
33use std::sync::Arc;
34use std::task::{Context, Poll};
35use std::time::Duration;
36
37use axum_core::extract::Request;
38use axum_core::response::Response;
39use http::{HeaderMap, Uri};
40use r402_core::facilitator::Facilitator;
41use r402_core::wire;
42use tower::util::BoxCloneSyncService;
43use tower::{Layer, Service};
44use url::Url;
45
46use super::facilitator::{FacilitatorClient, FacilitatorClientError};
47use super::hooks::{DynPaygateHooks, PaygateHooks, ProtectedRequestOutcome};
48use super::paygate::{Paygate, ResourceTemplate};
49use super::pricing::{DynamicPriceTags, PriceTagSource, StaticPriceTags};
50
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
72pub enum SettlementMode {
73 #[default]
75 Sequential,
76 Concurrent,
78 Background,
80}
81
82pub struct X402Middleware<F> {
87 facilitator: F,
88 base_url: Option<Url>,
89}
90
91impl<F: Clone> Clone for X402Middleware<F> {
92 fn clone(&self) -> Self {
93 Self {
94 facilitator: self.facilitator.clone(),
95 base_url: self.base_url.clone(),
96 }
97 }
98}
99
100impl<F: std::fmt::Debug> std::fmt::Debug for X402Middleware<F> {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 f.debug_struct("X402Middleware")
103 .field("facilitator", &self.facilitator)
104 .field("base_url", &self.base_url)
105 .finish()
106 }
107}
108
109impl<F> X402Middleware<F> {
110 #[must_use]
115 pub const fn from_facilitator(facilitator: F) -> Self {
116 Self {
117 facilitator,
118 base_url: None,
119 }
120 }
121
122 pub const fn facilitator(&self) -> &F {
124 &self.facilitator
125 }
126}
127
128impl X402Middleware<Arc<FacilitatorClient>> {
129 pub fn try_new(url: &str) -> Result<Self, FacilitatorClientError> {
137 let facilitator = FacilitatorClient::try_from(url)?;
138 Ok(Self {
139 facilitator: Arc::new(facilitator),
140 base_url: None,
141 })
142 }
143
144 #[must_use]
146 pub fn facilitator_url(&self) -> &Url {
147 self.facilitator.base_url()
148 }
149
150 #[must_use]
155 pub fn with_supported_cache_ttl(&self, ttl: Duration) -> Self {
156 let inner = Arc::unwrap_or_clone(Arc::clone(&self.facilitator));
157 let facilitator = Arc::new(inner.with_supported_cache_ttl(ttl));
158 Self {
159 facilitator,
160 base_url: self.base_url.clone(),
161 }
162 }
163
164 #[must_use]
172 pub fn with_facilitator_timeout(&self, timeout: Duration) -> Self {
173 let inner = Arc::unwrap_or_clone(Arc::clone(&self.facilitator));
174 let facilitator = Arc::new(inner.with_timeout(timeout));
175 Self {
176 facilitator,
177 base_url: self.base_url.clone(),
178 }
179 }
180}
181
182impl TryFrom<&str> for X402Middleware<Arc<FacilitatorClient>> {
183 type Error = FacilitatorClientError;
184
185 fn try_from(value: &str) -> Result<Self, Self::Error> {
186 Self::try_new(value)
187 }
188}
189
190impl TryFrom<String> for X402Middleware<Arc<FacilitatorClient>> {
191 type Error = FacilitatorClientError;
192
193 fn try_from(value: String) -> Result<Self, Self::Error> {
194 Self::try_new(&value)
195 }
196}
197
198impl<F> X402Middleware<F>
199where
200 F: Clone,
201{
202 #[must_use]
209 pub fn with_base_url(&self, base_url: Url) -> Self {
210 let mut this = self.clone();
211 this.base_url = Some(base_url);
212 this
213 }
214}
215
216impl<TFacilitator> X402Middleware<TFacilitator>
217where
218 TFacilitator: Clone,
219{
220 #[must_use]
225 pub fn with_price_tag(
226 &self,
227 price_tag: wire::PriceTag,
228 ) -> X402Layer<StaticPriceTags, TFacilitator> {
229 X402Layer {
230 facilitator: self.facilitator.clone(),
231 price_source: StaticPriceTags::new(vec![price_tag]),
232 base_url: self.base_url.clone().map(Arc::new),
233 resource: Arc::new(ResourceTemplate::default()),
234 settlement_mode: SettlementMode::default(),
235 hooks: None,
236 }
237 }
238
239 #[must_use]
246 pub fn with_price_tags(
247 &self,
248 price_tags: Vec<wire::PriceTag>,
249 ) -> X402Layer<StaticPriceTags, TFacilitator> {
250 X402Layer {
251 facilitator: self.facilitator.clone(),
252 price_source: StaticPriceTags::new(price_tags),
253 base_url: self.base_url.clone().map(Arc::new),
254 resource: Arc::new(ResourceTemplate::default()),
255 settlement_mode: SettlementMode::default(),
256 hooks: None,
257 }
258 }
259
260 #[must_use]
265 pub fn with_dynamic_price<F, Fut>(
266 &self,
267 callback: F,
268 ) -> X402Layer<DynamicPriceTags, TFacilitator>
269 where
270 F: Fn(&HeaderMap, &Uri, Option<&Url>) -> Fut + Send + Sync + 'static,
271 Fut: Future<Output = Vec<wire::PriceTag>> + Send + 'static,
272 {
273 X402Layer {
274 facilitator: self.facilitator.clone(),
275 price_source: DynamicPriceTags::new(callback),
276 base_url: self.base_url.clone().map(Arc::new),
277 resource: Arc::new(ResourceTemplate::default()),
278 settlement_mode: SettlementMode::default(),
279 hooks: None,
280 }
281 }
282}
283
284#[derive(Clone)]
289#[allow(
290 missing_debug_implementations,
291 reason = "generic types may not impl Debug"
292)]
293pub struct X402Layer<TSource, TFacilitator> {
294 facilitator: TFacilitator,
295 base_url: Option<Arc<Url>>,
296 price_source: TSource,
297 resource: Arc<ResourceTemplate>,
298 settlement_mode: SettlementMode,
299 hooks: Option<Arc<dyn DynPaygateHooks>>,
300}
301
302impl<TFacilitator> X402Layer<StaticPriceTags, TFacilitator> {
303 #[must_use]
309 pub fn with_price_tag(mut self, price_tag: wire::PriceTag) -> Self {
310 self.price_source = self.price_source.with_price_tag(price_tag);
311 self
312 }
313}
314
315#[allow(
316 missing_debug_implementations,
317 reason = "generic types may not impl Debug"
318)]
319impl<TSource, TFacilitator> X402Layer<TSource, TFacilitator> {
320 #[must_use]
324 pub fn with_description(mut self, description: String) -> Self {
325 let mut new_resource = (*self.resource).clone();
326 new_resource.description = description;
327 self.resource = Arc::new(new_resource);
328 self
329 }
330
331 #[must_use]
335 pub fn with_mime_type(mut self, mime: String) -> Self {
336 let mut new_resource = (*self.resource).clone();
337 new_resource.mime_type = mime;
338 self.resource = Arc::new(new_resource);
339 self
340 }
341
342 #[must_use]
347 #[allow(
348 clippy::needless_pass_by_value,
349 reason = "Url consumed via to_string()"
350 )]
351 pub fn with_resource(mut self, resource: Url) -> Self {
352 let mut new_resource = (*self.resource).clone();
353 new_resource.url = Some(resource.to_string());
354 self.resource = Arc::new(new_resource);
355 self
356 }
357
358 #[must_use]
369 pub const fn with_settlement_mode(mut self, mode: SettlementMode) -> Self {
370 self.settlement_mode = mode;
371 self
372 }
373
374 #[must_use]
387 pub fn with_hooks<H>(mut self, hooks: H) -> Self
388 where
389 H: PaygateHooks + 'static,
390 {
391 self.hooks = Some(Arc::new(hooks));
392 self
393 }
394}
395
396impl<S, TSource, TFacilitator> Layer<S> for X402Layer<TSource, TFacilitator>
397where
398 S: Service<Request, Response = Response, Error = Infallible> + Clone + Send + Sync + 'static,
399 S::Future: Send + 'static,
400 TFacilitator: Facilitator + Clone,
401 TSource: PriceTagSource,
402{
403 type Service = X402MiddlewareService<TSource, TFacilitator>;
404
405 fn layer(&self, inner: S) -> Self::Service {
406 X402MiddlewareService {
407 facilitator: self.facilitator.clone(),
408 base_url: self.base_url.clone(),
409 price_source: self.price_source.clone(),
410 resource: Arc::clone(&self.resource),
411 settlement_mode: self.settlement_mode,
412 hooks: self.hooks.clone(),
413 inner: BoxCloneSyncService::new(inner),
414 }
415 }
416}
417
418#[derive(Clone)]
423#[allow(
424 missing_debug_implementations,
425 reason = "BoxCloneSyncService does not impl Debug"
426)]
427pub struct X402MiddlewareService<TSource, TFacilitator> {
428 facilitator: TFacilitator,
430 base_url: Option<Arc<Url>>,
432 price_source: TSource,
434 resource: Arc<ResourceTemplate>,
436 settlement_mode: SettlementMode,
438 hooks: Option<Arc<dyn DynPaygateHooks>>,
440 inner: BoxCloneSyncService<Request, Response, Infallible>,
442}
443
444impl<TSource, TFacilitator> Service<Request> for X402MiddlewareService<TSource, TFacilitator>
445where
446 TSource: PriceTagSource,
447 TFacilitator: Facilitator + Clone + Send + Sync + 'static,
448{
449 type Response = Response;
450 type Error = Infallible;
451 type Future = Pin<Box<dyn Future<Output = Result<Response, Infallible>> + Send>>;
452
453 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
455 self.inner.poll_ready(cx)
456 }
457
458 #[allow(
460 clippy::excessive_nesting,
461 reason = "async move + match inside Box::pin is the idiomatic Service::call shape"
462 )]
463 fn call(&mut self, req: Request) -> Self::Future {
464 let price_source = self.price_source.clone();
465 let facilitator = self.facilitator.clone();
466 let base_url = self.base_url.clone();
467 let resource_builder = Arc::clone(&self.resource);
468 let settlement_mode = self.settlement_mode;
469 let hooks = self.hooks.clone();
470 let mut inner = self.inner.clone();
471
472 Box::pin(async move {
473 let mut req = req;
477 if let Some(h) = hooks.as_ref() {
478 match h.on_protected_request(&req).await {
479 ProtectedRequestOutcome::Continue => {}
480 ProtectedRequestOutcome::GrantAccess => return inner.call(req).await,
481 ProtectedRequestOutcome::Abort { status, body } => {
482 return Ok(build_abort_response(status, body));
483 }
484 }
485 }
486
487 let accepts = price_source
489 .resolve(req.headers(), req.uri(), base_url.as_deref())
490 .await;
491
492 if accepts.is_empty() {
494 return inner.call(req).await;
495 }
496
497 let resource = resource_builder.resolve(base_url.as_deref(), &req);
498
499 let mut gate_builder = Paygate::builder(facilitator)
500 .accepts(accepts)
501 .resource(resource);
502 if let Some(h) = hooks.as_ref() {
503 gate_builder = gate_builder.hooks_dyn(Arc::clone(h));
504 }
505 let mut gate = gate_builder.build();
506 gate.enrich_accepts().await;
507
508 if let Some(h) = hooks.as_ref() {
512 h.on_payment_verified(&mut req).await;
513 }
514
515 let result = match settlement_mode {
516 SettlementMode::Sequential => gate.handle_request(inner, req).await,
517 SettlementMode::Concurrent => gate.handle_request_concurrent(inner, req).await,
518 SettlementMode::Background => gate.handle_request_background(inner, req).await,
519 };
520 Ok(result.unwrap_or_else(|err| gate.error_response(err)))
521 })
522 }
523}
524
525fn build_abort_response(status: http::StatusCode, body: Option<String>) -> Response {
527 let mut response = Response::new(axum_core::body::Body::from(body.unwrap_or_default()));
528 *response.status_mut() = status;
529 if let Ok(ct) = http::HeaderValue::from_str("text/plain; charset=utf-8") {
530 let _ = response
531 .headers_mut()
532 .insert(http::header::CONTENT_TYPE, ct);
533 }
534 super::paygate::ensure_expose_headers(response.headers_mut());
535 response
536}
537
538#[cfg(test)]
539#[allow(
540 clippy::expect_used,
541 clippy::unwrap_used,
542 clippy::panic,
543 reason = "test assertions on known-valid fixtures"
544)]
545mod tests {
546 use super::*;
547
548 #[test]
549 fn try_new_stores_parsed_facilitator_url() {
550 let input = "https://facilitator.example.com";
551 let middleware = X402Middleware::try_new(input).expect("valid facilitator URL");
552 let expected = Url::parse("https://facilitator.example.com/").expect("fixture URL");
553 assert_eq!(
554 middleware.facilitator_url(),
555 &expected,
556 "stored base URL must equal the parsed, slash-normalized input"
557 );
558 assert_eq!(
559 middleware.facilitator_url().as_str(),
560 "https://facilitator.example.com/"
561 );
562
563 let slashed = X402Middleware::try_new("https://facilitator.example.com/")
564 .expect("valid facilitator URL with trailing slash");
565 assert_eq!(slashed.facilitator_url(), middleware.facilitator_url());
566 }
567
568 #[test]
569 fn try_new_rejects_invalid_url() {
570 let err = X402Middleware::try_new("not a url");
571 assert!(
572 err.is_err(),
573 "invalid facilitator URL must return Err, not panic"
574 );
575 match err {
576 Err(FacilitatorClientError::UrlParse { context, .. }) => {
577 assert_eq!(context, "Failed to parse base url");
578 }
579 other => panic!("expected UrlParse, got {other:?}"),
580 }
581 }
582
583 #[test]
584 fn try_from_str_matches_try_new() {
585 let via_try_new =
586 X402Middleware::try_new("https://facilitator.example.com").expect("try_new");
587 let via_try_from =
588 X402Middleware::try_from("https://facilitator.example.com").expect("TryFrom<&str>");
589 assert_eq!(
590 via_try_new.facilitator_url(),
591 via_try_from.facilitator_url()
592 );
593 }
594}