Skip to main content

r402_http/server/
layer.rs

1//! Axum middleware for enforcing [x402](https://www.x402.org) payments on protected routes.
2//!
3//! This middleware validates incoming payment headers using a configured x402 facilitator,
4//! verifies the payment, executes the request, and settles valid payments after successful
5//! execution. If the handler returns an error (4xx/5xx), settlement is skipped.
6//!
7//! Returns a `402 Payment Required` response if the request lacks a valid payment.
8//!
9//! ## Configuration Notes
10//!
11//! - **[`X402Middleware::with_price_tag`]** sets the assets and amounts accepted for payment (static pricing).
12//! - **[`X402Middleware::with_dynamic_price`]** sets a callback for dynamic pricing based on request context.
13//! - **[`X402Middleware::with_base_url`]** sets the base URL for computing full resource URLs.
14//!   If not set, defaults to `http://localhost/` (avoid in production).
15//! - **[`X402LayerBuilder::with_description`]** is optional but helps the payer understand what is being paid for.
16//! - **[`X402LayerBuilder::with_mime_type`]** sets the MIME type of the protected resource (default: `application/json`).
17//! - **[`X402LayerBuilder::with_resource`]** explicitly sets the full URI of the protected resource.
18//!
19
20use std::convert::Infallible;
21use std::future::Future;
22use std::pin::Pin;
23use std::sync::Arc;
24use std::task::{Context, Poll};
25use std::time::Duration;
26
27use axum_core::extract::Request;
28use axum_core::response::Response;
29use http::{HeaderMap, Uri};
30use r402::facilitator::Facilitator;
31use r402::proto::v2;
32use tower::util::BoxCloneSyncService;
33use tower::{Layer, Service};
34use url::Url;
35
36use super::facilitator::FacilitatorClient;
37use super::paygate::{Paygate, ResourceInfoBuilder};
38use super::pricing::{DynamicPriceTags, PriceTagSource, StaticPriceTags};
39
40/// The main X402 middleware instance for enforcing x402 payments on routes.
41///
42/// Create a single instance per application and use it to build payment layers
43/// for protected routes.
44pub struct X402Middleware<F> {
45    facilitator: F,
46    base_url: Option<Url>,
47}
48
49impl<F: Clone> Clone for X402Middleware<F> {
50    fn clone(&self) -> Self {
51        Self {
52            facilitator: self.facilitator.clone(),
53            base_url: self.base_url.clone(),
54        }
55    }
56}
57
58impl<F: std::fmt::Debug> std::fmt::Debug for X402Middleware<F> {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.debug_struct("X402Middleware")
61            .field("facilitator", &self.facilitator)
62            .field("base_url", &self.base_url)
63            .finish()
64    }
65}
66
67impl<F> X402Middleware<F> {
68    /// Returns a reference to the underlying facilitator.
69    pub const fn facilitator(&self) -> &F {
70        &self.facilitator
71    }
72}
73
74impl X402Middleware<Arc<FacilitatorClient>> {
75    /// Creates a new middleware instance with a default facilitator URL.
76    ///
77    /// # Panics
78    ///
79    /// Panics if the facilitator URL is invalid.
80    #[must_use]
81    pub fn new(url: &str) -> Self {
82        let facilitator = FacilitatorClient::try_from(url).expect("Invalid facilitator URL");
83        Self {
84            facilitator: Arc::new(facilitator),
85            base_url: None,
86        }
87    }
88
89    /// Creates a new middleware instance with a facilitator URL.
90    ///
91    /// # Errors
92    ///
93    /// Returns an error if the URL is invalid.
94    pub fn try_new(url: &str) -> Result<Self, Box<dyn std::error::Error>> {
95        let facilitator = FacilitatorClient::try_from(url)?;
96        Ok(Self {
97            facilitator: Arc::new(facilitator),
98            base_url: None,
99        })
100    }
101
102    /// Returns the configured facilitator URL.
103    #[must_use]
104    pub fn facilitator_url(&self) -> &Url {
105        self.facilitator.base_url()
106    }
107
108    /// Sets the TTL for caching the facilitator's supported response.
109    ///
110    /// Default is 10 minutes. Use [`FacilitatorClient::without_supported_cache()`]
111    /// to disable caching entirely.
112    #[must_use]
113    pub fn with_supported_cache_ttl(&self, ttl: Duration) -> Self {
114        let inner = Arc::unwrap_or_clone(Arc::clone(&self.facilitator));
115        let facilitator = Arc::new(inner.with_supported_cache_ttl(ttl));
116        Self {
117            facilitator,
118            base_url: self.base_url.clone(),
119        }
120    }
121
122    /// Sets a per-request timeout for all facilitator HTTP calls (verify, settle, supported).
123    ///
124    /// Without this, the underlying `reqwest::Client` uses no timeout by default,
125    /// which can cause requests to hang indefinitely if the facilitator is slow
126    /// or unreachable, eventually triggering OS-level TCP timeouts (typically 2–5 minutes).
127    ///
128    /// A reasonable production value is 30 seconds.
129    #[must_use]
130    pub fn with_facilitator_timeout(&self, timeout: Duration) -> Self {
131        let inner = Arc::unwrap_or_clone(Arc::clone(&self.facilitator));
132        let facilitator = Arc::new(inner.with_timeout(timeout));
133        Self {
134            facilitator,
135            base_url: self.base_url.clone(),
136        }
137    }
138}
139
140impl TryFrom<&str> for X402Middleware<Arc<FacilitatorClient>> {
141    type Error = Box<dyn std::error::Error>;
142
143    fn try_from(value: &str) -> Result<Self, Self::Error> {
144        Self::try_new(value)
145    }
146}
147
148impl TryFrom<String> for X402Middleware<Arc<FacilitatorClient>> {
149    type Error = Box<dyn std::error::Error>;
150
151    fn try_from(value: String) -> Result<Self, Self::Error> {
152        Self::try_new(&value)
153    }
154}
155
156impl<F> X402Middleware<F>
157where
158    F: Clone,
159{
160    /// Sets the base URL used to construct resource URLs dynamically.
161    ///
162    /// If [`X402LayerBuilder::with_resource`] is not called, this base URL is combined with
163    /// each request's path/query to compute the resource. If not set, defaults to `http://localhost/`.
164    ///
165    /// In production, prefer calling `with_resource` or setting a precise `base_url`.
166    #[must_use]
167    pub fn with_base_url(&self, base_url: Url) -> Self {
168        let mut this = self.clone();
169        this.base_url = Some(base_url);
170        this
171    }
172}
173
174impl<TFacilitator> X402Middleware<TFacilitator>
175where
176    TFacilitator: Clone,
177{
178    /// Sets the price tag for the protected route.
179    ///
180    /// Creates a layer builder that can be further configured with additional
181    /// price tags and resource information.
182    #[must_use]
183    pub fn with_price_tag(
184        &self,
185        price_tag: v2::PriceTag,
186    ) -> X402LayerBuilder<StaticPriceTags, TFacilitator> {
187        X402LayerBuilder {
188            facilitator: self.facilitator.clone(),
189            price_source: StaticPriceTags::new(vec![price_tag]),
190            base_url: self.base_url.clone().map(Arc::new),
191            resource: Arc::new(ResourceInfoBuilder::default()),
192        }
193    }
194
195    /// Sets a dynamic price source for the protected route.
196    ///
197    /// The `callback` receives request headers, URI, and base URL, and returns
198    /// a vector of V2 price tags.
199    #[must_use]
200    pub fn with_dynamic_price<F, Fut>(
201        &self,
202        callback: F,
203    ) -> X402LayerBuilder<DynamicPriceTags, TFacilitator>
204    where
205        F: Fn(&HeaderMap, &Uri, Option<&Url>) -> Fut + Send + Sync + 'static,
206        Fut: Future<Output = Vec<v2::PriceTag>> + Send + 'static,
207    {
208        X402LayerBuilder {
209            facilitator: self.facilitator.clone(),
210            price_source: DynamicPriceTags::new(callback),
211            base_url: self.base_url.clone().map(Arc::new),
212            resource: Arc::new(ResourceInfoBuilder::default()),
213        }
214    }
215}
216
217/// Builder for configuring the X402 middleware layer.
218///
219/// Generic over `TSource` which implements [`PriceTagSource`] to support
220/// both static and dynamic pricing strategies.
221#[derive(Clone)]
222#[allow(missing_debug_implementations)] // generic types may not implement Debug
223pub struct X402LayerBuilder<TSource, TFacilitator> {
224    facilitator: TFacilitator,
225    base_url: Option<Arc<Url>>,
226    price_source: TSource,
227    resource: Arc<ResourceInfoBuilder>,
228}
229
230impl<TFacilitator> X402LayerBuilder<StaticPriceTags, TFacilitator> {
231    /// Adds another payment option.
232    ///
233    /// Allows specifying multiple accepted payment methods (e.g., different networks).
234    ///
235    /// Note: This method is only available for static price tag sources.
236    #[must_use]
237    pub fn with_price_tag(mut self, price_tag: v2::PriceTag) -> Self {
238        self.price_source = self.price_source.with_price_tag(price_tag);
239        self
240    }
241}
242
243#[allow(missing_debug_implementations)] // generic types may not implement Debug
244impl<TSource, TFacilitator> X402LayerBuilder<TSource, TFacilitator> {
245    /// Sets a description of what the payment grants access to.
246    ///
247    /// This is included in 402 responses to inform clients what they're paying for.
248    #[must_use]
249    pub fn with_description(mut self, description: String) -> Self {
250        let mut new_resource = (*self.resource).clone();
251        new_resource.description = description;
252        self.resource = Arc::new(new_resource);
253        self
254    }
255
256    /// Sets the MIME type of the protected resource.
257    ///
258    /// Defaults to `application/json` if not specified.
259    #[must_use]
260    pub fn with_mime_type(mut self, mime: String) -> Self {
261        let mut new_resource = (*self.resource).clone();
262        new_resource.mime_type = mime;
263        self.resource = Arc::new(new_resource);
264        self
265    }
266
267    /// Sets the full URL of the protected resource.
268    ///
269    /// When set, this URL is used directly instead of constructing it from the base URL
270    /// and request URI. This is the preferred approach in production.
271    #[must_use]
272    #[allow(clippy::needless_pass_by_value)] // Url consumed via to_string()
273    pub fn with_resource(mut self, resource: Url) -> Self {
274        let mut new_resource = (*self.resource).clone();
275        new_resource.url = Some(resource.to_string());
276        self.resource = Arc::new(new_resource);
277        self
278    }
279}
280
281impl<S, TSource, TFacilitator> Layer<S> for X402LayerBuilder<TSource, TFacilitator>
282where
283    S: Service<Request, Response = Response, Error = Infallible> + Clone + Send + Sync + 'static,
284    S::Future: Send + 'static,
285    TFacilitator: Facilitator + Clone,
286    TSource: PriceTagSource,
287{
288    type Service = X402MiddlewareService<TSource, TFacilitator>;
289
290    fn layer(&self, inner: S) -> Self::Service {
291        X402MiddlewareService {
292            facilitator: self.facilitator.clone(),
293            base_url: self.base_url.clone(),
294            price_source: self.price_source.clone(),
295            resource: Arc::clone(&self.resource),
296            inner: BoxCloneSyncService::new(inner),
297        }
298    }
299}
300
301/// Axum service that enforces x402 payments on incoming requests.
302///
303/// Generic over `TSource` which implements [`PriceTagSource`] to support
304/// both static and dynamic pricing strategies.
305#[derive(Clone)]
306#[allow(missing_debug_implementations)] // BoxCloneSyncService does not implement Debug
307pub struct X402MiddlewareService<TSource, TFacilitator> {
308    /// Payment facilitator (local or remote)
309    facilitator: TFacilitator,
310    /// Base URL for constructing resource URLs
311    base_url: Option<Arc<Url>>,
312    /// Price tag source - can be static or dynamic
313    price_source: TSource,
314    /// Resource information
315    resource: Arc<ResourceInfoBuilder>,
316    /// The inner Axum service being wrapped
317    inner: BoxCloneSyncService<Request, Response, Infallible>,
318}
319
320impl<TSource, TFacilitator> Service<Request> for X402MiddlewareService<TSource, TFacilitator>
321where
322    TSource: PriceTagSource,
323    TFacilitator: Facilitator + Clone + Send + Sync + 'static,
324{
325    type Response = Response;
326    type Error = Infallible;
327    type Future = Pin<Box<dyn Future<Output = Result<Response, Infallible>> + Send>>;
328
329    /// Delegates readiness polling to the wrapped inner service.
330    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
331        self.inner.poll_ready(cx)
332    }
333
334    /// Intercepts the request, injects payment enforcement logic, and forwards to the wrapped service.
335    fn call(&mut self, req: Request) -> Self::Future {
336        let price_source = self.price_source.clone();
337        let facilitator = self.facilitator.clone();
338        let base_url = self.base_url.clone();
339        let resource_builder = Arc::clone(&self.resource);
340        let mut inner = self.inner.clone();
341
342        Box::pin(async move {
343            // Resolve price tags from the source
344            let accepts = price_source
345                .resolve(req.headers(), req.uri(), base_url.as_deref())
346                .await;
347
348            // If no price tags are configured, bypass payment enforcement
349            if accepts.is_empty() {
350                return inner.call(req).await;
351            }
352
353            let resource = resource_builder.as_resource_info(base_url.as_deref(), &req);
354
355            let gate = {
356                let mut gate = Paygate::builder(facilitator)
357                    .accepts(accepts)
358                    .resource(resource)
359                    .build();
360                gate.enrich_accepts().await;
361                gate
362            };
363            gate.handle_request(inner, req).await
364        })
365    }
366}