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