1use 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#[derive(Clone, Debug)]
39struct SupportedCacheState {
40 response: SupportedResponse,
42 expires_at: std::time::Instant,
44}
45
46#[derive(Debug, Clone)]
52pub struct SupportedCache {
53 ttl: Duration,
55 state: Arc<RwLock<Option<SupportedCacheState>>>,
57}
58
59impl SupportedCache {
60 #[must_use]
62 pub fn new(ttl: Duration) -> Self {
63 Self {
64 ttl,
65 state: Arc::new(RwLock::new(None)),
66 }
67 }
68
69 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 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 pub async fn clear(&self) {
91 let mut guard = self.state.write().await;
92 *guard = None;
93 }
94}
95
96#[derive(Clone, Debug)]
100pub struct FacilitatorClient {
101 base_url: Url,
103 verify_url: Url,
105 settle_url: Url,
107 supported_url: Url,
109 client: Client,
111 headers: HeaderMap,
113 timeout: Option<Duration>,
115 supported_cache: SupportedCache,
117}
118
119impl Facilitator for FacilitatorClient {
120 fn verify(
121 &self,
122 request: VerifyRequest,
123 ) -> BoxFuture<'_, Result<VerifyResponse, FacilitatorError>> {
124 Box::pin(async move {
125 #[cfg(feature = "telemetry")]
126 let result = with_span(
127 Self::verify(self, &request),
128 tracing::info_span!("x402.facilitator_client.verify", timeout = ?self.timeout),
129 )
130 .await;
131 #[cfg(not(feature = "telemetry"))]
132 let result = Self::verify(self, &request).await;
133 result.map_err(|e| FacilitatorError::Other(Box::new(e)))
134 })
135 }
136
137 fn settle(
138 &self,
139 request: SettleRequest,
140 ) -> BoxFuture<'_, Result<SettleResponse, FacilitatorError>> {
141 Box::pin(async move {
142 #[cfg(feature = "telemetry")]
143 let result = with_span(
144 Self::settle(self, &request),
145 tracing::info_span!("x402.facilitator_client.settle", timeout = ?self.timeout),
146 )
147 .await;
148 #[cfg(not(feature = "telemetry"))]
149 let result = Self::settle(self, &request).await;
150 result.map_err(|e| FacilitatorError::Other(Box::new(e)))
151 })
152 }
153
154 fn supported(&self) -> BoxFuture<'_, Result<SupportedResponse, FacilitatorError>> {
155 Box::pin(async move {
156 Self::supported(self)
157 .await
158 .map_err(|e| FacilitatorError::Other(Box::new(e)))
159 })
160 }
161}
162
163#[derive(Debug, thiserror::Error)]
165pub enum FacilitatorClientError {
166 #[error("URL parse error: {context}: {source}")]
168 UrlParse {
169 context: &'static str,
171 #[source]
173 source: url::ParseError,
174 },
175 #[error("HTTP error: {context}: {source}")]
177 Http {
178 context: &'static str,
180 #[source]
182 source: reqwest::Error,
183 },
184 #[error("Failed to deserialize JSON: {context}: {source}")]
186 JsonDeserialization {
187 context: &'static str,
189 #[source]
191 source: reqwest::Error,
192 },
193 #[error("Unexpected HTTP status {status}: {context}: {body}")]
195 HttpStatus {
196 context: &'static str,
198 status: StatusCode,
200 body: String,
202 },
203 #[error("Failed to read response body as text: {context}: {source}")]
205 ResponseBodyRead {
206 context: &'static str,
208 #[source]
210 source: reqwest::Error,
211 },
212}
213
214impl FacilitatorClient {
215 pub const DEFAULT_SUPPORTED_CACHE_TTL: Duration = Duration::from_mins(10);
217
218 #[must_use]
220 pub const fn base_url(&self) -> &Url {
221 &self.base_url
222 }
223
224 #[must_use]
226 pub const fn verify_url(&self) -> &Url {
227 &self.verify_url
228 }
229
230 #[must_use]
232 pub const fn settle_url(&self) -> &Url {
233 &self.settle_url
234 }
235
236 #[must_use]
238 pub const fn supported_url(&self) -> &Url {
239 &self.supported_url
240 }
241
242 #[must_use]
244 pub const fn headers(&self) -> &HeaderMap {
245 &self.headers
246 }
247
248 #[must_use]
250 pub const fn timeout(&self) -> &Option<Duration> {
251 &self.timeout
252 }
253
254 #[must_use]
256 pub const fn supported_cache(&self) -> &SupportedCache {
257 &self.supported_cache
258 }
259
260 pub fn try_new(base_url: Url) -> Result<Self, FacilitatorClientError> {
268 let client = Client::new();
269 let verify_url =
270 base_url
271 .join("./verify")
272 .map_err(|e| FacilitatorClientError::UrlParse {
273 context: "Failed to construct ./verify URL",
274 source: e,
275 })?;
276 let settle_url =
277 base_url
278 .join("./settle")
279 .map_err(|e| FacilitatorClientError::UrlParse {
280 context: "Failed to construct ./settle URL",
281 source: e,
282 })?;
283 let supported_url =
284 base_url
285 .join("./supported")
286 .map_err(|e| FacilitatorClientError::UrlParse {
287 context: "Failed to construct ./supported URL",
288 source: e,
289 })?;
290 Ok(Self {
291 client,
292 base_url,
293 verify_url,
294 settle_url,
295 supported_url,
296 headers: HeaderMap::new(),
297 timeout: None,
298 supported_cache: SupportedCache::new(Self::DEFAULT_SUPPORTED_CACHE_TTL),
299 })
300 }
301
302 #[must_use]
304 pub fn with_headers(mut self, headers: HeaderMap) -> Self {
305 self.headers = headers;
306 self
307 }
308
309 #[must_use]
311 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
312 self.timeout = Some(timeout);
313 self
314 }
315
316 #[must_use]
320 pub fn with_supported_cache_ttl(mut self, ttl: Duration) -> Self {
321 self.supported_cache = SupportedCache::new(ttl);
322 self
323 }
324
325 #[must_use]
327 pub fn without_supported_cache(self) -> Self {
328 self.with_supported_cache_ttl(Duration::ZERO)
329 }
330
331 pub async fn verify(
337 &self,
338 request: &VerifyRequest,
339 ) -> Result<VerifyResponse, FacilitatorClientError> {
340 self.post_json(&self.verify_url, "POST /verify", request)
341 .await
342 }
343
344 pub async fn settle(
350 &self,
351 request: &SettleRequest,
352 ) -> Result<SettleResponse, FacilitatorClientError> {
353 self.post_json(&self.settle_url, "POST /settle", request)
354 .await
355 }
356
357 #[cfg_attr(
360 feature = "telemetry",
361 instrument(name = "x402.facilitator_client.supported", skip_all, err)
362 )]
363 async fn supported_inner(&self) -> Result<SupportedResponse, FacilitatorClientError> {
364 self.get_json(&self.supported_url, "GET /supported").await
365 }
366
367 pub async fn supported(&self) -> Result<SupportedResponse, FacilitatorClientError> {
375 if let Some(response) = self.supported_cache.get().await {
377 return Ok(response);
378 }
379
380 #[cfg(feature = "telemetry")]
382 tracing::info!("x402.facilitator_client.supported_cache_miss");
383
384 let response = self.supported_inner().await?;
385 self.supported_cache.set(response.clone()).await;
386
387 Ok(response)
388 }
389
390 #[allow(clippy::needless_pass_by_value)]
395 async fn post_json<T, R>(
396 &self,
397 url: &Url,
398 context: &'static str,
399 payload: &T,
400 ) -> Result<R, FacilitatorClientError>
401 where
402 T: serde::Serialize + Sync + ?Sized,
403 R: serde::de::DeserializeOwned,
404 {
405 let req = self.client.post(url.clone()).json(payload);
406 self.send_and_parse(req, context).await
407 }
408
409 async fn get_json<R>(
414 &self,
415 url: &Url,
416 context: &'static str,
417 ) -> Result<R, FacilitatorClientError>
418 where
419 R: serde::de::DeserializeOwned,
420 {
421 let req = self.client.get(url.clone());
422 self.send_and_parse(req, context).await
423 }
424
425 async fn send_and_parse<R>(
427 &self,
428 mut req: reqwest::RequestBuilder,
429 context: &'static str,
430 ) -> Result<R, FacilitatorClientError>
431 where
432 R: serde::de::DeserializeOwned,
433 {
434 for (key, value) in &self.headers {
435 req = req.header(key, value);
436 }
437 if let Some(timeout) = self.timeout {
438 req = req.timeout(timeout);
439 }
440 let http_response = req
441 .send()
442 .await
443 .map_err(|e| FacilitatorClientError::Http { context, source: e })?;
444
445 let result = if http_response.status() == StatusCode::OK {
446 http_response
447 .json::<R>()
448 .await
449 .map_err(|e| FacilitatorClientError::JsonDeserialization { context, source: e })
450 } else {
451 let status = http_response.status();
452 let body = http_response
453 .text()
454 .await
455 .map_err(|e| FacilitatorClientError::ResponseBodyRead { context, source: e })?;
456 Err(FacilitatorClientError::HttpStatus {
457 context,
458 status,
459 body,
460 })
461 };
462
463 record_result_on_span(&result);
464
465 result
466 }
467}
468
469impl TryFrom<&str> for FacilitatorClient {
471 type Error = FacilitatorClientError;
472
473 fn try_from(value: &str) -> Result<Self, Self::Error> {
474 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
485impl 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#[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#[cfg(not(feature = "telemetry"))]
513const fn record_result_on_span<R, E: Display>(_result: &Result<R, E>) {}
514
515#[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::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 let result1 = client.supported().await.unwrap();
560 assert_eq!(result1.kinds.len(), 1);
561
562 let result2 = client.supported().await.unwrap();
564 assert_eq!(result2.kinds.len(), 1);
565
566 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::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 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
584 .unwrap()
585 .with_supported_cache_ttl(Duration::from_millis(1));
586
587 let result1 = client.supported().await.unwrap();
589 assert_eq!(result1.kinds.len(), 1);
590
591 tokio::time::sleep(Duration::from_millis(10)).await;
593
594 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::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 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
613 .unwrap()
614 .without_supported_cache();
615
616 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::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 let client2 = client.clone();
641
642 let result1 = client.supported().await.unwrap();
644 assert_eq!(result1.kinds.len(), 1);
645
646 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::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 let _ = client.supported().await.unwrap();
668
669 let result = client.supported_inner().await.unwrap();
671 assert_eq!(result.kinds.len(), 1);
672 }
673}