Skip to main content

r402_http/server/
layer.rs

1//! [`X402Layer`]: Tower layer after a successful `with_price_tag`.
2
3use std::convert::Infallible;
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::Arc;
7use std::task::{Context, Poll};
8
9use axum_core::extract::Request;
10use axum_core::response::Response;
11use r402_protocol::network::ChainIdPattern;
12use r402_protocol::payment::{PriceTag, ResourceInfo};
13use r402_server::{BackgroundSettlementTracker, ResourceServer, SchemeNetworkServer};
14use tower::util::BoxCloneSyncService;
15use tower::{Layer, Service};
16use url::Url;
17
18use super::SettlementMode;
19use super::builder::{BuildError, validate_static_layer};
20use super::fail::abort_response;
21use super::gate::Gate;
22use super::hooks::{DynGateHooks, GateHooks, ProtectedRequestOutcome};
23use super::pricing::{DynamicPriceTags, PriceTagSource, StaticPriceTags};
24
25/// Route-level resource metadata. `url` pins `ResourceInfo.url`; it does not waive `base_url`.
26#[derive(Debug, Clone)]
27pub(crate) struct ResourceTemplate {
28    pub(crate) description: String,
29    pub(crate) mime_type: String,
30    pub(crate) url: Option<Url>,
31}
32
33impl Default for ResourceTemplate {
34    fn default() -> Self {
35        Self {
36            description: String::new(),
37            mime_type: "application/json".to_owned(),
38            url: None,
39        }
40    }
41}
42
43impl ResourceTemplate {
44    /// Builds `ResourceInfo` from the pinned URL or `base_url` + request path/query.
45    ///
46    /// Never reads `Host` and never falls back to `http://localhost`.
47    pub(crate) fn resolve(&self, base_url: &Url, req: &Request) -> ResourceInfo {
48        let url = self.url.as_ref().map_or_else(
49            || {
50                let mut url = base_url.clone();
51                url.set_path(req.uri().path());
52                url.set_query(req.uri().query());
53                url.to_string()
54            },
55            ToString::to_string,
56        );
57        let mut info = ResourceInfo::new(url);
58        if !self.description.is_empty() {
59            info = info.with_description(self.description.clone());
60        }
61        if !self.mime_type.is_empty() {
62            info = info.with_mime_type(self.mime_type.clone());
63        }
64        info
65    }
66}
67
68/// Tower layer produced by [`super::X402Middleware::with_price_tag`].
69#[derive(Clone)]
70pub struct X402Layer<TSource> {
71    pub(crate) server: ResourceServer,
72    pub(crate) base_url: Arc<Url>,
73    pub(crate) price_source: TSource,
74    pub(crate) resource: Arc<ResourceTemplate>,
75    pub(crate) settlement_mode: SettlementMode,
76    pub(crate) settlement_tracker: Option<BackgroundSettlementTracker>,
77    pub(crate) hooks: Option<Arc<dyn DynGateHooks>>,
78    #[cfg(feature = "siwx")]
79    pub(crate) siwx: Option<Arc<super::SiwxGate>>,
80}
81
82impl X402Layer<StaticPriceTags> {
83    /// Adds another static payment option.
84    ///
85    /// # Errors
86    ///
87    /// [`BuildError`] when the appended tag (or the existing list with the
88    /// current settlement mode) is invalid.
89    pub fn with_price_tag(mut self, price_tag: PriceTag) -> Result<Self, BuildError> {
90        self.price_source = self.price_source.with_price_tag(price_tag);
91        self.validate_static()?;
92        Ok(self)
93    }
94
95    /// After-handler settle scheduler for the `authorization` payment flow.
96    ///
97    /// Not a `paymentFlow`. Sequential (default) is spec `authorization`:
98    /// settle after the handler, then attach `Payment-Response`. Concurrent
99    /// overlaps that settle with the handler and still waits. Background does
100    /// not wait and does not attach a receipt. Concurrent and Background are
101    /// illegal with `upfront` / `escrow`.
102    ///
103    /// # Errors
104    ///
105    /// [`BuildError::Mode`] when `mode` is incompatible with a static tag's flow.
106    pub fn with_settlement_mode(mut self, mode: SettlementMode) -> Result<Self, BuildError> {
107        self.settlement_mode = mode;
108        self.validate_static()?;
109        Ok(self)
110    }
111
112    /// Enables SIWX access-grant and 402 challenges on this route.
113    ///
114    /// # Errors
115    ///
116    /// [`BuildError::EmptyPriceTags`] when this replacement is not auth-only
117    /// and the static tag list is empty.
118    #[cfg(feature = "siwx")]
119    pub fn with_siwx(mut self, gate: super::SiwxGate) -> Result<Self, BuildError> {
120        self.siwx = Some(Arc::new(gate));
121        self.validate_static()?;
122        Ok(self)
123    }
124
125    /// Enables SIWX with auth-only access-grant on this route.
126    ///
127    /// # Errors
128    ///
129    /// [`BuildError`] from static re-validation after the replacement.
130    #[cfg(feature = "siwx")]
131    pub fn with_auth_only(self, gate: super::SiwxGate) -> Result<Self, BuildError> {
132        self.with_siwx(gate.with_auth_only())
133    }
134
135    fn validate_static(&self) -> Result<(), BuildError> {
136        validate_static_layer(
137            &self.server,
138            self.price_source.tags(),
139            self.settlement_mode,
140            self.static_auth_only(),
141        )
142    }
143
144    fn static_auth_only(&self) -> bool {
145        #[cfg(feature = "siwx")]
146        {
147            self.siwx.as_ref().is_some_and(|g| g.is_auth_only())
148        }
149        #[cfg(not(feature = "siwx"))]
150        {
151            let _ = self;
152            false
153        }
154    }
155}
156
157impl X402Layer<DynamicPriceTags> {
158    /// After-handler settle scheduler for the `authorization` payment flow.
159    ///
160    /// Same semantics as [`X402Layer<StaticPriceTags>::with_settlement_mode`].
161    /// Dynamic tags are resolved per request; incompatible `upfront` /
162    /// `escrow` combinations fail at request time, not here.
163    ///
164    /// # Errors
165    ///
166    /// Always `Ok` after storing `mode`.
167    #[allow(
168        clippy::unnecessary_wraps,
169        clippy::missing_const_for_fn,
170        reason = "same Result signature as Static"
171    )]
172    pub fn with_settlement_mode(mut self, mode: SettlementMode) -> Result<Self, BuildError> {
173        self.settlement_mode = mode;
174        Ok(self)
175    }
176
177    /// Enables SIWX access-grant and 402 challenges on this route.
178    #[cfg(feature = "siwx")]
179    #[must_use]
180    pub fn with_siwx(mut self, gate: super::SiwxGate) -> Self {
181        self.siwx = Some(Arc::new(gate));
182        self
183    }
184
185    /// Enables SIWX with auth-only access-grant on this route.
186    #[cfg(feature = "siwx")]
187    #[must_use]
188    pub fn with_auth_only(self, gate: super::SiwxGate) -> Self {
189        self.with_siwx(gate.with_auth_only())
190    }
191}
192
193impl<TSource: std::fmt::Debug> std::fmt::Debug for X402Layer<TSource> {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        f.debug_struct("X402Layer")
196            .field("base_url", &self.base_url)
197            .field("price_source", &self.price_source)
198            .field("settlement_mode", &self.settlement_mode)
199            .finish_non_exhaustive()
200    }
201}
202
203impl<TSource> X402Layer<TSource> {
204    /// Registers a scheme/network adapter on the owned resource server.
205    #[must_use]
206    pub fn with_scheme(
207        mut self,
208        network: ChainIdPattern,
209        scheme: impl SchemeNetworkServer + 'static,
210    ) -> Self {
211        self.server.register_scheme(network, scheme);
212        self
213    }
214
215    /// Description copied onto 402 `resource.description`.
216    #[must_use]
217    pub fn with_description(mut self, description: String) -> Self {
218        let mut new_resource = (*self.resource).clone();
219        new_resource.description = description;
220        self.resource = Arc::new(new_resource);
221        self
222    }
223
224    /// MIME type copied onto 402 `resource.mimeType`.
225    #[must_use]
226    pub fn with_mime_type(mut self, mime: String) -> Self {
227        let mut new_resource = (*self.resource).clone();
228        new_resource.mime_type = mime;
229        self.resource = Arc::new(new_resource);
230        self
231    }
232
233    /// Pins `ResourceInfo.url` for this route. Does **not** waive `base_url`.
234    #[must_use]
235    pub fn with_resource(mut self, resource: Url) -> Self {
236        let mut new_resource = (*self.resource).clone();
237        new_resource.url = Some(resource);
238        self.resource = Arc::new(new_resource);
239        self
240    }
241
242    /// In-flight counter for [`SettlementMode::Background`] settle tasks.
243    #[must_use]
244    pub fn with_settlement_tracker(mut self, tracker: BackgroundSettlementTracker) -> Self {
245        self.settlement_tracker = Some(tracker);
246        self
247    }
248
249    /// HTTP-layer hooks (`GrantAccess` slot).
250    #[must_use]
251    pub fn with_hooks<H>(mut self, hooks: H) -> Self
252    where
253        H: GateHooks + 'static,
254    {
255        self.hooks = Some(Arc::new(hooks));
256        self
257    }
258}
259
260impl<S, TSource> Layer<S> for X402Layer<TSource>
261where
262    S: Service<Request, Response = Response, Error = Infallible> + Clone + Send + Sync + 'static,
263    S::Future: Send + 'static,
264    TSource: PriceTagSource,
265{
266    type Service = X402MiddlewareService<TSource>;
267
268    fn layer(&self, inner: S) -> Self::Service {
269        X402MiddlewareService {
270            server: self.server.clone(),
271            base_url: Arc::clone(&self.base_url),
272            price_source: self.price_source.clone(),
273            resource: Arc::clone(&self.resource),
274            settlement_mode: self.settlement_mode,
275            settlement_tracker: self.settlement_tracker.clone(),
276            hooks: self.hooks.clone(),
277            #[cfg(feature = "siwx")]
278            siwx: self.siwx.clone(),
279            inner: BoxCloneSyncService::new(inner),
280        }
281    }
282}
283
284/// Service produced by [`X402Layer`].
285#[derive(Clone)]
286#[allow(
287    missing_debug_implementations,
288    reason = "BoxCloneSyncService does not impl Debug"
289)]
290pub struct X402MiddlewareService<TSource> {
291    server: ResourceServer,
292    base_url: Arc<Url>,
293    price_source: TSource,
294    resource: Arc<ResourceTemplate>,
295    settlement_mode: SettlementMode,
296    settlement_tracker: Option<BackgroundSettlementTracker>,
297    hooks: Option<Arc<dyn DynGateHooks>>,
298    #[cfg(feature = "siwx")]
299    siwx: Option<Arc<super::SiwxGate>>,
300    inner: BoxCloneSyncService<Request, Response, Infallible>,
301}
302
303impl<TSource> Service<Request> for X402MiddlewareService<TSource>
304where
305    TSource: PriceTagSource,
306{
307    type Response = Response;
308    type Error = Infallible;
309    type Future = Pin<Box<dyn Future<Output = Result<Response, Infallible>> + Send>>;
310
311    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
312        self.inner.poll_ready(cx)
313    }
314
315    fn call(&mut self, req: Request) -> Self::Future {
316        Box::pin(enforce(
317            self.price_source.clone(),
318            self.server.clone(),
319            Arc::clone(&self.base_url),
320            Arc::clone(&self.resource),
321            self.settlement_mode,
322            self.settlement_tracker.clone(),
323            self.hooks.clone(),
324            #[cfg(feature = "siwx")]
325            self.siwx.clone(),
326            self.inner.clone(),
327            req,
328        ))
329    }
330}
331
332#[allow(
333    clippy::too_many_arguments,
334    clippy::excessive_nesting,
335    reason = "cloned service fields; hook match must borrow Request only for the await"
336)]
337async fn enforce<TSource: PriceTagSource>(
338    price_source: TSource,
339    server: ResourceServer,
340    base_url: Arc<Url>,
341    resource_builder: Arc<ResourceTemplate>,
342    settlement_mode: SettlementMode,
343    settlement_tracker: Option<BackgroundSettlementTracker>,
344    hooks: Option<Arc<dyn DynGateHooks>>,
345    #[cfg(feature = "siwx")] siwx: Option<Arc<super::SiwxGate>>,
346    mut inner: BoxCloneSyncService<Request, Response, Infallible>,
347    req: Request,
348) -> Result<Response, Infallible> {
349    #[cfg(feature = "siwx")]
350    if let Some(siwx_gate) = siwx.as_ref() {
351        let header = req
352            .headers()
353            .get(crate::headers::SIGN_IN_WITH_X)
354            .and_then(|v| v.to_str().ok())
355            .map(str::to_owned);
356        let path = req.uri().path().to_owned();
357        if let Some(header) = header
358            && siwx_gate.try_grant(&header, &path).await
359        {
360            return inner.call(req).await;
361        }
362    }
363
364    if let Some(h) = hooks.as_ref() {
365        match h.on_protected_request(&req).await {
366            ProtectedRequestOutcome::Continue => {}
367            ProtectedRequestOutcome::GrantAccess => return inner.call(req).await,
368            ProtectedRequestOutcome::Abort { status, body } => {
369                return Ok(abort_response(status, body));
370            }
371        }
372    }
373
374    let accepts = price_source
375        .resolve(req.headers(), req.uri(), base_url.as_ref())
376        .await;
377    #[cfg(feature = "siwx")]
378    let auth_only = siwx.as_ref().is_some_and(|g| g.is_auth_only());
379    #[cfg(not(feature = "siwx"))]
380    let auth_only = false;
381    if accepts.is_empty() && !auth_only {
382        return inner.call(req).await;
383    }
384
385    let resource = resource_builder.resolve(base_url.as_ref(), &req);
386    let mut gate = Gate::from_parts(
387        server,
388        accepts,
389        resource,
390        settlement_mode,
391        settlement_tracker,
392        hooks,
393        #[cfg(feature = "siwx")]
394        siwx.clone(),
395    );
396    if let Err(err) = gate.require_schemes().await {
397        return Ok(gate.error_response(err));
398    }
399    if let Err(err) = gate.assert_mode_compatible(settlement_mode) {
400        return Ok(gate.error_response(err));
401    }
402    if let Err(err) = gate.build_payment_required().await {
403        return Ok(gate.error_response(err));
404    }
405    #[cfg(feature = "siwx")]
406    if let Some(siwx) = siwx.as_ref()
407        && let Err(err) = gate.attach_siwx_challenge(siwx, req.uri().path())
408    {
409        return Ok(gate.error_response(err));
410    }
411
412    let result = match settlement_mode {
413        SettlementMode::Sequential => gate.handle_request(inner, req).await,
414        SettlementMode::Concurrent => {
415            // Join future holds handler + settle state (~17 KiB).
416            Box::pin(gate.handle_request_concurrent(inner, req)).await
417        }
418        SettlementMode::Background => gate.handle_request_background(inner, req).await,
419    };
420    Ok(result.unwrap_or_else(|err| gate.error_response(err)))
421}