Skip to main content

r402_http/server/
builder.rs

1//! [`X402Middleware`] construction. `with_price_tag` requires `base_url`.
2
3use std::future::Future;
4use std::sync::Arc;
5
6use compact_str::CompactString;
7use http::{HeaderMap, Uri};
8use r402_facilitator::{Facilitator, FacilitatorClient, FacilitatorClientError};
9use r402_protocol::extension::Extension;
10use r402_protocol::network::{ChainId, ChainIdPattern};
11use r402_protocol::payment::PriceTag;
12use r402_server::{
13    PaymentFlowError, PaymentFlowName, ResourceServer, SchemeNetworkServer, schedule,
14};
15use url::Url;
16
17use super::SettlementMode;
18use super::layer::{ResourceTemplate, X402Layer};
19use super::pricing::{DynamicPriceTags, StaticPriceTags};
20
21/// Construction failure for static HTTP layers.
22#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
23pub enum BuildError {
24    /// `with_price_tag` / `with_price_tags` / `with_dynamic_price` without [`X402Middleware::with_base_url`].
25    ///
26    /// `with_resource` does not waive this requirement.
27    #[error("base_url is required; call with_base_url before with_price_tag")]
28    MissingBaseUrl,
29    /// No scheme adapter is registered for this accept.
30    #[error("missing scheme {scheme} on {network}")]
31    MissingScheme {
32        /// Wire scheme name.
33        scheme: CompactString,
34        /// Accept network.
35        network: ChainId,
36    },
37    /// ATM / payment-flow resolution failed for a static tag.
38    #[error(transparent)]
39    PaymentFlow(#[from] PaymentFlowError),
40    /// Concurrent or Background used with a flow that settles before the handler.
41    #[error(transparent)]
42    Mode(#[from] r402_server::IncompatibleSettlementMode),
43    /// Empty static tags without SIWX `auth_only` already set.
44    #[error(
45        "with_price_tags([]) requires with_auth_only first; empty static tags are not a layer bypass"
46    )]
47    EmptyPriceTags,
48    /// Escrow accept whose scheme does not implement cancel settle.
49    #[error("escrow scheme {scheme} is missing settle_on_cancel")]
50    MissingSettleOnCancel {
51        /// Wire scheme name.
52        scheme: CompactString,
53    },
54}
55
56/// Rejects empty static tags (unless `auth_only`) and illegal scheme/flow/mode.
57pub(crate) fn validate_static_layer(
58    server: &ResourceServer,
59    tags: &[PriceTag],
60    mode: SettlementMode,
61    auth_only: bool,
62) -> Result<(), BuildError> {
63    if tags.is_empty() {
64        return if auth_only {
65            Ok(())
66        } else {
67            Err(BuildError::EmptyPriceTags)
68        };
69    }
70
71    for tag in tags {
72        let requirements = &tag.requirements;
73        let Some(scheme) =
74            server.registered_scheme(requirements.scheme.as_str(), &requirements.network)
75        else {
76            return Err(BuildError::MissingScheme {
77                scheme: requirements.scheme.clone(),
78                network: requirements.network.clone(),
79            });
80        };
81
82        let flow = match server.get_payment_flow(requirements) {
83            Ok(flow) => flow,
84            Err(PaymentFlowError::UnregisteredScheme { .. }) => {
85                return Err(BuildError::MissingScheme {
86                    scheme: requirements.scheme.clone(),
87                    network: requirements.network.clone(),
88                });
89            }
90            Err(err) => return Err(BuildError::PaymentFlow(err)),
91        };
92
93        if flow == PaymentFlowName::Escrow && !scheme.settles_on_cancel() {
94            return Err(BuildError::MissingSettleOnCancel {
95                scheme: requirements.scheme.clone(),
96            });
97        }
98
99        schedule(flow.phases(), mode)?;
100    }
101    Ok(())
102}
103
104/// Application-level x402 middleware. Owns a [`ResourceServer`].
105///
106/// Not a [`tower::Layer`]. Paid routes are built with [`Self::with_price_tag`].
107#[derive(Clone, Debug)]
108pub struct X402Middleware {
109    server: ResourceServer,
110    base_url: Option<Url>,
111    #[cfg(feature = "siwx")]
112    siwx: Option<Arc<super::SiwxGate>>,
113}
114
115impl X402Middleware {
116    /// Wraps `fac` in a new [`ResourceServer`].
117    #[must_use]
118    pub fn from_facilitator(fac: impl Facilitator + 'static) -> Self {
119        Self::from_resource_server(ResourceServer::new(Arc::new(fac)))
120    }
121
122    /// Creates middleware that owns an existing [`ResourceServer`].
123    #[must_use]
124    pub const fn from_resource_server(server: ResourceServer) -> Self {
125        Self {
126            server,
127            base_url: None,
128            #[cfg(feature = "siwx")]
129            siwx: None,
130        }
131    }
132
133    /// Remote facilitator from a base URL.
134    ///
135    /// # Errors
136    ///
137    /// [`FacilitatorClientError`] when the URL cannot be parsed or endpoints built.
138    pub fn try_new(url: &str) -> Result<Self, FacilitatorClientError> {
139        Ok(Self::from_facilitator(FacilitatorClient::try_from(url)?))
140    }
141
142    /// Registers a scheme/network adapter.
143    #[must_use]
144    pub fn with_scheme(
145        mut self,
146        network: ChainIdPattern,
147        scheme: impl SchemeNetworkServer + 'static,
148    ) -> Self {
149        self.server.register_scheme(network, scheme);
150        self
151    }
152
153    /// Registers a protocol extension advertised on 402 responses.
154    #[must_use]
155    pub fn with_extension(mut self, extension: impl Extension + 'static) -> Self {
156        self.server = self.server.with_extension(extension);
157        self
158    }
159
160    /// Public origin used to build `ResourceInfo.url`. Required before `with_price_tag`.
161    ///
162    /// Never derived from `Host` and never defaulted to `http://localhost`.
163    #[must_use]
164    pub fn with_base_url(&self, base_url: Url) -> Self {
165        let mut this = self.clone();
166        this.base_url = Some(base_url);
167        this
168    }
169
170    /// Owned resource server.
171    #[must_use]
172    pub const fn resource_server(&self) -> &ResourceServer {
173        &self.server
174    }
175
176    /// Enables SIWX on layers built from this middleware.
177    ///
178    /// Empty static tags still require [`Self::with_auth_only`] before
179    /// [`Self::with_price_tags`].
180    #[cfg(feature = "siwx")]
181    #[must_use]
182    pub fn with_siwx(&self, gate: super::SiwxGate) -> Self {
183        let mut this = self.clone();
184        this.siwx = Some(Arc::new(gate));
185        this
186    }
187
188    /// Enables SIWX and treats empty price tags as auth-only.
189    ///
190    /// Call before [`Self::with_price_tags`] with an empty list. Grants access
191    /// on valid signature even without a paid-address hit.
192    #[cfg(feature = "siwx")]
193    #[must_use]
194    pub fn with_auth_only(&self, gate: super::SiwxGate) -> Self {
195        self.with_siwx(gate.with_auth_only())
196    }
197
198    fn auth_only(&self) -> bool {
199        #[cfg(feature = "siwx")]
200        {
201            self.siwx.as_ref().is_some_and(|g| g.is_auth_only())
202        }
203        #[cfg(not(feature = "siwx"))]
204        {
205            let _ = self;
206            false
207        }
208    }
209
210    /// Static single-tag layer. Fails when [`Self::with_base_url`] was not called.
211    ///
212    /// # Errors
213    ///
214    /// [`BuildError::MissingBaseUrl`], then other [`BuildError`] variants from
215    /// static tag validation. `with_resource` does not waive `base_url`.
216    pub fn with_price_tag(
217        &self,
218        price_tag: PriceTag,
219    ) -> Result<X402Layer<StaticPriceTags>, BuildError> {
220        self.with_price_tags(vec![price_tag])
221    }
222
223    /// Static multi-tag layer.
224    ///
225    /// Empty list is [`BuildError::EmptyPriceTags`] unless [`Self::with_auth_only`]
226    /// was already called.
227    ///
228    /// # Errors
229    ///
230    /// [`BuildError::MissingBaseUrl`] when `with_base_url` was not called, or
231    /// other [`BuildError`] variants when static tags are invalid.
232    /// `with_resource` does not waive `base_url`.
233    pub fn with_price_tags(
234        &self,
235        price_tags: Vec<PriceTag>,
236    ) -> Result<X402Layer<StaticPriceTags>, BuildError> {
237        let base_url = Arc::new(self.base_url.clone().ok_or(BuildError::MissingBaseUrl)?);
238        validate_static_layer(
239            &self.server,
240            &price_tags,
241            SettlementMode::default(),
242            self.auth_only(),
243        )?;
244        Ok(X402Layer {
245            server: self.server.clone(),
246            price_source: StaticPriceTags::new(price_tags),
247            base_url,
248            resource: Arc::new(ResourceTemplate::default()),
249            settlement_mode: SettlementMode::default(),
250            settlement_tracker: None,
251            hooks: None,
252            #[cfg(feature = "siwx")]
253            siwx: self.siwx.clone(),
254        })
255    }
256
257    /// Dynamic price source.
258    ///
259    /// # Errors
260    ///
261    /// [`BuildError::MissingBaseUrl`].
262    pub fn with_dynamic_price<F, Fut>(
263        &self,
264        callback: F,
265    ) -> Result<X402Layer<DynamicPriceTags>, BuildError>
266    where
267        F: Fn(&HeaderMap, &Uri, &Url) -> Fut + Send + Sync + 'static,
268        Fut: Future<Output = Vec<PriceTag>> + Send + 'static,
269    {
270        Ok(X402Layer {
271            server: self.server.clone(),
272            price_source: DynamicPriceTags::new(callback),
273            base_url: Arc::new(self.base_url.clone().ok_or(BuildError::MissingBaseUrl)?),
274            resource: Arc::new(ResourceTemplate::default()),
275            settlement_mode: SettlementMode::default(),
276            settlement_tracker: None,
277            hooks: None,
278            #[cfg(feature = "siwx")]
279            siwx: self.siwx.clone(),
280        })
281    }
282}
283
284impl TryFrom<&str> for X402Middleware {
285    type Error = FacilitatorClientError;
286
287    fn try_from(value: &str) -> Result<Self, Self::Error> {
288        Self::try_new(value)
289    }
290}
291
292impl TryFrom<String> for X402Middleware {
293    type Error = FacilitatorClientError;
294
295    fn try_from(value: String) -> Result<Self, Self::Error> {
296        Self::try_new(&value)
297    }
298}