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