Skip to main content

r402_http/server/
facilitator.rs

1//! A [`r402::facilitator::Facilitator`] implementation that interacts with a _remote_ x402 Facilitator over HTTP.
2//!
3//! This [`FacilitatorClient`] handles the `/verify`, `/settle`, and `/supported` endpoints of a remote facilitator,
4//! and implements the [`r402::facilitator::Facilitator`] trait for compatibility
5//! with x402-based middleware and logic.
6//!
7//! ## Features
8//!
9//! - Uses `reqwest` for async HTTP requests
10//! - Supports optional timeout and headers
11//! - Integrates with `tracing` if the `telemetry` feature is enabled
12//!
13//! ## Error Handling
14//!
15//! Custom error types capture detailed failure contexts, including
16//! - URL construction
17//! - HTTP transport failures
18//! - JSON deserialization errors
19//! - Unexpected HTTP status responses
20//!
21
22use std::fmt::Display;
23use std::sync::Arc;
24use std::time::Duration;
25
26use http::{HeaderMap, StatusCode};
27use r402::facilitator::{BoxFuture, Facilitator, FacilitatorError};
28use r402::proto::{
29    SettleRequest, SettleResponse, SupportedResponse, VerifyRequest, VerifyResponse,
30};
31use reqwest::Client;
32use tokio::sync::RwLock;
33#[cfg(feature = "telemetry")]
34use tracing::{Instrument, Span, instrument};
35use url::Url;
36
37/// TTL cache for [`SupportedResponse`].
38#[derive(Clone, Debug)]
39struct SupportedCacheState {
40    /// The cached response
41    response: SupportedResponse,
42    /// When the cache expires
43    expires_at: std::time::Instant,
44}
45
46/// An encapsulated TTL cache for the `/supported` endpoint response.
47///
48/// Clones share the same cache state via `Arc`, so cached responses are
49/// visible across all clones (e.g. when the middleware clones the
50/// facilitator per-request).
51#[derive(Debug, Clone)]
52pub struct SupportedCache {
53    /// TTL for the cache
54    ttl: Duration,
55    /// Shared cache state (`Arc<RwLock>` so clones hit the same cache)
56    state: Arc<RwLock<Option<SupportedCacheState>>>,
57}
58
59impl SupportedCache {
60    /// Creates a new cache with the given TTL.
61    #[must_use]
62    pub fn new(ttl: Duration) -> Self {
63        Self {
64            ttl,
65            state: Arc::new(RwLock::new(None)),
66        }
67    }
68
69    /// Returns the cached response if valid, None otherwise.
70    pub async fn get(&self) -> Option<SupportedResponse> {
71        let guard = self.state.read().await;
72        let cache = guard.as_ref()?;
73        if std::time::Instant::now() < cache.expires_at {
74            Some(cache.response.clone())
75        } else {
76            None
77        }
78    }
79
80    /// Stores a response in the cache with the configured TTL.
81    pub async fn set(&self, response: SupportedResponse) {
82        let mut guard = self.state.write().await;
83        *guard = Some(SupportedCacheState {
84            response,
85            expires_at: std::time::Instant::now() + self.ttl,
86        });
87    }
88
89    /// Clears the cache.
90    pub async fn clear(&self) {
91        let mut guard = self.state.write().await;
92        *guard = None;
93    }
94}
95
96/// A client for communicating with a remote x402 facilitator.
97///
98/// Handles `/verify`, `/settle`, and `/supported` endpoints via JSON HTTP.
99#[derive(Clone, Debug)]
100pub struct FacilitatorClient {
101    /// Base URL of the facilitator (e.g. `https://facilitator.example/`)
102    base_url: Url,
103    /// Full URL to `POST /verify` requests
104    verify_url: Url,
105    /// Full URL to `POST /settle` requests
106    settle_url: Url,
107    /// Full URL to `GET /supported` requests
108    supported_url: Url,
109    /// Shared Reqwest HTTP client
110    client: Client,
111    /// Optional custom headers sent with each request
112    headers: HeaderMap,
113    /// Optional request timeout
114    timeout: Option<Duration>,
115    /// Cache for the supported endpoint response
116    supported_cache: SupportedCache,
117}
118
119/// Errors that can occur while interacting with a remote facilitator.
120#[derive(Debug, thiserror::Error)]
121pub enum FacilitatorClientError {
122    /// URL parse error.
123    #[error("URL parse error: {context}: {source}")]
124    UrlParse {
125        /// Human-readable context.
126        context: &'static str,
127        /// The underlying parse error.
128        #[source]
129        source: url::ParseError,
130    },
131    /// HTTP transport error.
132    #[error("HTTP error: {context}: {source}")]
133    Http {
134        /// Human-readable context.
135        context: &'static str,
136        /// The underlying reqwest error.
137        #[source]
138        source: reqwest::Error,
139    },
140    /// JSON deserialization error.
141    #[error("Failed to deserialize JSON: {context}: {source}")]
142    JsonDeserialization {
143        /// Human-readable context.
144        context: &'static str,
145        /// The underlying reqwest error.
146        #[source]
147        source: reqwest::Error,
148    },
149    /// Unexpected HTTP status code.
150    #[error("Unexpected HTTP status {status}: {context}: {body}")]
151    HttpStatus {
152        /// Human-readable context.
153        context: &'static str,
154        /// The HTTP status code.
155        status: StatusCode,
156        /// The response body.
157        body: String,
158    },
159    /// Failed to read response body.
160    #[error("Failed to read response body as text: {context}: {source}")]
161    ResponseBodyRead {
162        /// Human-readable context.
163        context: &'static str,
164        /// The underlying reqwest error.
165        #[source]
166        source: reqwest::Error,
167    },
168}
169
170impl FacilitatorClient {
171    /// Default TTL for caching the supported endpoint response (10 minutes).
172    pub const DEFAULT_SUPPORTED_CACHE_TTL: Duration = Duration::from_mins(10);
173
174    /// Returns the base URL used by this client.
175    #[must_use]
176    pub const fn base_url(&self) -> &Url {
177        &self.base_url
178    }
179
180    /// Returns the computed `./verify` URL relative to [`FacilitatorClient::base_url`].
181    #[must_use]
182    pub const fn verify_url(&self) -> &Url {
183        &self.verify_url
184    }
185
186    /// Returns the computed `./settle` URL relative to [`FacilitatorClient::base_url`].
187    #[must_use]
188    pub const fn settle_url(&self) -> &Url {
189        &self.settle_url
190    }
191
192    /// Returns the computed `./supported` URL relative to [`FacilitatorClient::base_url`].
193    #[must_use]
194    pub const fn supported_url(&self) -> &Url {
195        &self.supported_url
196    }
197
198    /// Returns any custom headers configured on the client.
199    #[must_use]
200    pub const fn headers(&self) -> &HeaderMap {
201        &self.headers
202    }
203
204    /// Returns the configured timeout, if any.
205    #[must_use]
206    pub const fn timeout(&self) -> &Option<Duration> {
207        &self.timeout
208    }
209
210    /// Returns a reference to the supported cache.
211    #[must_use]
212    pub const fn supported_cache(&self) -> &SupportedCache {
213        &self.supported_cache
214    }
215
216    /// Constructs a new [`FacilitatorClient`] from a base URL.
217    ///
218    /// This sets up `./verify`, `./settle`, and `./supported` endpoint URLs relative to the base.
219    ///
220    /// # Errors
221    ///
222    /// Returns [`FacilitatorClientError`] if URL construction fails.
223    pub fn try_new(base_url: Url) -> Result<Self, FacilitatorClientError> {
224        let client = Client::new();
225        let verify_url =
226            base_url
227                .join("./verify")
228                .map_err(|e| FacilitatorClientError::UrlParse {
229                    context: "Failed to construct ./verify URL",
230                    source: e,
231                })?;
232        let settle_url =
233            base_url
234                .join("./settle")
235                .map_err(|e| FacilitatorClientError::UrlParse {
236                    context: "Failed to construct ./settle URL",
237                    source: e,
238                })?;
239        let supported_url =
240            base_url
241                .join("./supported")
242                .map_err(|e| FacilitatorClientError::UrlParse {
243                    context: "Failed to construct ./supported URL",
244                    source: e,
245                })?;
246        Ok(Self {
247            client,
248            base_url,
249            verify_url,
250            settle_url,
251            supported_url,
252            headers: HeaderMap::new(),
253            timeout: None,
254            supported_cache: SupportedCache::new(Self::DEFAULT_SUPPORTED_CACHE_TTL),
255        })
256    }
257
258    /// Attaches custom headers to all future requests.
259    #[must_use]
260    pub fn with_headers(mut self, headers: HeaderMap) -> Self {
261        self.headers = headers;
262        self
263    }
264
265    /// Sets a timeout for all future requests.
266    #[must_use]
267    pub const fn with_timeout(mut self, timeout: Duration) -> Self {
268        self.timeout = Some(timeout);
269        self
270    }
271
272    /// Sets the TTL for caching the supported endpoint response.
273    ///
274    /// Default is 10 minutes. Use [`Self::without_supported_cache()`] to disable caching.
275    #[must_use]
276    pub fn with_supported_cache_ttl(mut self, ttl: Duration) -> Self {
277        self.supported_cache = SupportedCache::new(ttl);
278        self
279    }
280
281    /// Disables caching for the supported endpoint.
282    #[must_use]
283    pub fn without_supported_cache(self) -> Self {
284        self.with_supported_cache_ttl(Duration::ZERO)
285    }
286
287    /// Sends a `POST /verify` request to the facilitator.
288    ///
289    /// # Errors
290    ///
291    /// Returns [`FacilitatorClientError`] if the HTTP request fails.
292    pub async fn verify(
293        &self,
294        request: &VerifyRequest,
295    ) -> Result<VerifyResponse, FacilitatorClientError> {
296        self.post_json(&self.verify_url, "POST /verify", request)
297            .await
298    }
299
300    /// Sends a `POST /settle` request to the facilitator.
301    ///
302    /// # Errors
303    ///
304    /// Returns [`FacilitatorClientError`] if the HTTP request fails.
305    pub async fn settle(
306        &self,
307        request: &SettleRequest,
308    ) -> Result<SettleResponse, FacilitatorClientError> {
309        self.post_json(&self.settle_url, "POST /settle", request)
310            .await
311    }
312
313    /// Sends a `GET /supported` request to the facilitator.
314    /// This is the inner method that always makes an HTTP request.
315    #[cfg_attr(
316        feature = "telemetry",
317        instrument(name = "x402.facilitator_client.supported", skip_all, err)
318    )]
319    async fn supported_inner(&self) -> Result<SupportedResponse, FacilitatorClientError> {
320        self.get_json(&self.supported_url, "GET /supported").await
321    }
322
323    /// Sends a `GET /supported` request to the facilitator.
324    /// Results are cached with a configurable TTL (default: 10 minutes).
325    /// Use `supported_inner()` to bypass the cache.
326    ///
327    /// # Errors
328    ///
329    /// Returns [`FacilitatorClientError`] if the HTTP request fails.
330    pub async fn supported(&self) -> Result<SupportedResponse, FacilitatorClientError> {
331        // Try to get from cache
332        if let Some(response) = self.supported_cache.get().await {
333            return Ok(response);
334        }
335
336        // Cache miss - fetch and cache
337        #[cfg(feature = "telemetry")]
338        tracing::info!("x402.facilitator_client.supported_cache_miss");
339
340        let response = self.supported_inner().await?;
341        self.supported_cache.set(response.clone()).await;
342
343        Ok(response)
344    }
345
346    /// Generic POST helper that handles JSON serialization, error mapping,
347    /// timeout application, and telemetry integration.
348    ///
349    /// `context` is a human-readable identifier used in tracing and error messages (e.g. `"POST /verify"`).
350    #[allow(clippy::needless_pass_by_value)]
351    async fn post_json<T, R>(
352        &self,
353        url: &Url,
354        context: &'static str,
355        payload: &T,
356    ) -> Result<R, FacilitatorClientError>
357    where
358        T: serde::Serialize + Sync + ?Sized,
359        R: serde::de::DeserializeOwned,
360    {
361        let req = self.client.post(url.clone()).json(payload);
362        self.send_and_parse(req, context).await
363    }
364
365    /// Generic GET helper that handles error mapping, timeout application,
366    /// and telemetry integration.
367    ///
368    /// `context` is a human-readable identifier used in tracing and error messages (e.g. `"GET /supported"`).
369    async fn get_json<R>(
370        &self,
371        url: &Url,
372        context: &'static str,
373    ) -> Result<R, FacilitatorClientError>
374    where
375        R: serde::de::DeserializeOwned,
376    {
377        let req = self.client.get(url.clone());
378        self.send_and_parse(req, context).await
379    }
380
381    /// Applies headers, timeout, sends the request, and parses the JSON response.
382    async fn send_and_parse<R>(
383        &self,
384        mut req: reqwest::RequestBuilder,
385        context: &'static str,
386    ) -> Result<R, FacilitatorClientError>
387    where
388        R: serde::de::DeserializeOwned,
389    {
390        for (key, value) in &self.headers {
391            req = req.header(key, value);
392        }
393        if let Some(timeout) = self.timeout {
394            req = req.timeout(timeout);
395        }
396        let http_response = req
397            .send()
398            .await
399            .map_err(|e| FacilitatorClientError::Http { context, source: e })?;
400
401        let result = if http_response.status() == StatusCode::OK {
402            http_response
403                .json::<R>()
404                .await
405                .map_err(|e| FacilitatorClientError::JsonDeserialization { context, source: e })
406        } else {
407            let status = http_response.status();
408            let body = http_response
409                .text()
410                .await
411                .map_err(|e| FacilitatorClientError::ResponseBodyRead { context, source: e })?;
412            Err(FacilitatorClientError::HttpStatus {
413                context,
414                status,
415                body,
416            })
417        };
418
419        record_result_on_span(&result);
420
421        result
422    }
423}
424
425impl Facilitator for FacilitatorClient {
426    fn verify(
427        &self,
428        request: VerifyRequest,
429    ) -> BoxFuture<'_, Result<VerifyResponse, FacilitatorError>> {
430        Box::pin(async move {
431            #[cfg(feature = "telemetry")]
432            let result = with_span(
433                Self::verify(self, &request),
434                tracing::info_span!("x402.facilitator_client.verify", timeout = ?self.timeout),
435            )
436            .await;
437            #[cfg(not(feature = "telemetry"))]
438            let result = Self::verify(self, &request).await;
439            result.map_err(|e| FacilitatorError::Other(Box::new(e)))
440        })
441    }
442
443    fn settle(
444        &self,
445        request: SettleRequest,
446    ) -> BoxFuture<'_, Result<SettleResponse, FacilitatorError>> {
447        Box::pin(async move {
448            #[cfg(feature = "telemetry")]
449            let result = with_span(
450                Self::settle(self, &request),
451                tracing::info_span!("x402.facilitator_client.settle", timeout = ?self.timeout),
452            )
453            .await;
454            #[cfg(not(feature = "telemetry"))]
455            let result = Self::settle(self, &request).await;
456            result.map_err(|e| FacilitatorError::Other(Box::new(e)))
457        })
458    }
459
460    fn supported(&self) -> BoxFuture<'_, Result<SupportedResponse, FacilitatorError>> {
461        Box::pin(async move {
462            Self::supported(self)
463                .await
464                .map_err(|e| FacilitatorError::Other(Box::new(e)))
465        })
466    }
467}
468
469/// Converts a string URL into a `FacilitatorClient`, parsing the URL and calling `try_new`.
470impl TryFrom<&str> for FacilitatorClient {
471    type Error = FacilitatorClientError;
472
473    fn try_from(value: &str) -> Result<Self, Self::Error> {
474        // Normalize: strip trailing slashes and add a single trailing slash
475        let mut normalized = value.trim_end_matches('/').to_string();
476        normalized.push('/');
477        let url = Url::parse(&normalized).map_err(|e| FacilitatorClientError::UrlParse {
478            context: "Failed to parse base url",
479            source: e,
480        })?;
481        Self::try_new(url)
482    }
483}
484
485/// Converts a String URL into a `FacilitatorClient`.
486impl TryFrom<String> for FacilitatorClient {
487    type Error = FacilitatorClientError;
488
489    fn try_from(value: String) -> Result<Self, Self::Error> {
490        Self::try_from(value.as_str())
491    }
492}
493
494/// Records the outcome of a request on a tracing span, including status and errors.
495#[cfg(feature = "telemetry")]
496fn record_result_on_span<R, E: Display>(result: &Result<R, E>) {
497    let span = Span::current();
498    match result {
499        Ok(_) => {
500            span.record("otel.status_code", "OK");
501        }
502        Err(err) => {
503            span.record("otel.status_code", "ERROR");
504            span.record("error.message", tracing::field::display(err));
505            tracing::event!(tracing::Level::ERROR, error = %err, "Request to facilitator failed");
506        }
507    }
508}
509
510/// Records the outcome of a request on a tracing span, including status and errors.
511/// Noop if telemetry feature is off.
512#[cfg(not(feature = "telemetry"))]
513const fn record_result_on_span<R, E: Display>(_result: &Result<R, E>) {}
514
515/// Instruments a future with a given tracing span.
516#[cfg(feature = "telemetry")]
517fn with_span<F: Future>(fut: F, span: Span) -> impl Future<Output = F::Output> {
518    fut.instrument(span)
519}
520
521#[cfg(test)]
522mod tests {
523    use std::collections::HashMap;
524
525    use r402::proto::SupportedPaymentKind;
526    use wiremock::matchers::{method, path};
527    use wiremock::{Mock, MockServer, ResponseTemplate};
528
529    use super::*;
530
531    fn create_test_supported_response() -> SupportedResponse {
532        SupportedResponse {
533            kinds: vec![SupportedPaymentKind {
534                x402_version: 1,
535                scheme: "eip155-exact".to_string(),
536                network: "1".to_string(),
537                extra: None,
538            }],
539            extensions: vec![],
540            signers: HashMap::new(),
541        }
542    }
543
544    #[tokio::test]
545    async fn test_supported_cache_caches_response() {
546        let mock_server = MockServer::start().await;
547        let test_response = create_test_supported_response();
548
549        // Mock the supported endpoint
550        Mock::given(method("GET"))
551            .and(path("/supported"))
552            .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
553            .mount(&mock_server)
554            .await;
555
556        let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
557
558        // First call should hit the network
559        let result1 = client.supported().await.unwrap();
560        assert_eq!(result1.kinds.len(), 1);
561
562        // Second call should use cache (same mock call count)
563        let result2 = client.supported().await.unwrap();
564        assert_eq!(result2.kinds.len(), 1);
565
566        // Both results should be equal
567        assert_eq!(result1.kinds[0].scheme, result2.kinds[0].scheme);
568    }
569
570    #[tokio::test]
571    async fn test_supported_cache_with_custom_ttl() {
572        let mock_server = MockServer::start().await;
573        let test_response = create_test_supported_response();
574
575        // Mock the supported endpoint
576        Mock::given(method("GET"))
577            .and(path("/supported"))
578            .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
579            .mount(&mock_server)
580            .await;
581
582        // Create client with 1ms TTL (essentially no caching)
583        let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
584            .unwrap()
585            .with_supported_cache_ttl(Duration::from_millis(1));
586
587        // First call
588        let result1 = client.supported().await.unwrap();
589        assert_eq!(result1.kinds.len(), 1);
590
591        // Wait for cache to expire
592        tokio::time::sleep(Duration::from_millis(10)).await;
593
594        // Second call should hit the network again due to expired cache
595        let result2 = client.supported().await.unwrap();
596        assert_eq!(result2.kinds.len(), 1);
597    }
598
599    #[tokio::test]
600    async fn test_supported_cache_disabled() {
601        let mock_server = MockServer::start().await;
602        let test_response = create_test_supported_response();
603
604        // Mock the supported endpoint
605        Mock::given(method("GET"))
606            .and(path("/supported"))
607            .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
608            .mount(&mock_server)
609            .await;
610
611        // Create client with caching disabled
612        let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
613            .unwrap()
614            .without_supported_cache();
615
616        // Each call should hit the network
617        let result1 = client.supported().await.unwrap();
618        let result2 = client.supported().await.unwrap();
619
620        assert_eq!(result1.kinds.len(), 1);
621        assert_eq!(result2.kinds.len(), 1);
622    }
623
624    #[tokio::test]
625    async fn test_supported_cache_shared_across_clones() {
626        let mock_server = MockServer::start().await;
627        let test_response = create_test_supported_response();
628
629        // Mock the supported endpoint — expect exactly 1 request
630        Mock::given(method("GET"))
631            .and(path("/supported"))
632            .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
633            .expect(1)
634            .mount(&mock_server)
635            .await;
636
637        let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
638
639        // Clone the client — clones share the same cache
640        let client2 = client.clone();
641
642        // Populate cache on first client
643        let result1 = client.supported().await.unwrap();
644        assert_eq!(result1.kinds.len(), 1);
645
646        // Clone should hit the shared cache (no extra HTTP request)
647        let result2 = client2.supported().await.unwrap();
648        assert_eq!(result2.kinds.len(), 1);
649        assert_eq!(result1.kinds[0].scheme, result2.kinds[0].scheme);
650    }
651
652    #[tokio::test]
653    async fn test_supported_inner_bypasses_cache() {
654        let mock_server = MockServer::start().await;
655        let test_response = create_test_supported_response();
656
657        // Mock the supported endpoint
658        Mock::given(method("GET"))
659            .and(path("/supported"))
660            .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
661            .mount(&mock_server)
662            .await;
663
664        let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
665
666        // Populate cache
667        let _ = client.supported().await.unwrap();
668
669        // supported_inner() should always make HTTP request, bypassing cache
670        let result = client.supported_inner().await.unwrap();
671        assert_eq!(result.kinds.len(), 1);
672    }
673}