1use std::fmt::Display;
23use std::future::Future;
24use std::pin::Pin;
25use std::time::Duration;
26
27use http::{HeaderMap, StatusCode};
28use r402::facilitator::{Facilitator, FacilitatorError};
29use r402::proto::{
30 SettleRequest, SettleResponse, SupportedResponse, VerifyRequest, VerifyResponse,
31};
32use reqwest::Client;
33use tokio::sync::RwLock;
34#[cfg(feature = "telemetry")]
35use tracing::{Instrument, Span, instrument};
36use url::Url;
37
38#[derive(Clone, Debug)]
40struct SupportedCacheState {
41 response: SupportedResponse,
43 expires_at: std::time::Instant,
45}
46
47#[derive(Debug)]
51pub struct SupportedCache {
52 ttl: Duration,
54 state: RwLock<Option<SupportedCacheState>>,
56}
57
58impl SupportedCache {
59 #[must_use]
61 pub fn new(ttl: Duration) -> Self {
62 Self {
63 ttl,
64 state: RwLock::new(None),
65 }
66 }
67
68 pub async fn get(&self) -> Option<SupportedResponse> {
70 let guard = self.state.read().await;
71 let cache = guard.as_ref()?;
72 if std::time::Instant::now() < cache.expires_at {
73 Some(cache.response.clone())
74 } else {
75 None
76 }
77 }
78
79 pub async fn set(&self, response: SupportedResponse) {
81 let mut guard = self.state.write().await;
82 *guard = Some(SupportedCacheState {
83 response,
84 expires_at: std::time::Instant::now() + self.ttl,
85 });
86 }
87
88 pub async fn clear(&self) {
90 let mut guard = self.state.write().await;
91 *guard = None;
92 }
93}
94
95impl Clone for SupportedCache {
96 fn clone(&self) -> Self {
97 Self::new(self.ttl)
98 }
99}
100
101#[derive(Clone, Debug)]
105pub struct FacilitatorClient {
106 base_url: Url,
108 verify_url: Url,
110 settle_url: Url,
112 supported_url: Url,
114 client: Client,
116 headers: HeaderMap,
118 timeout: Option<Duration>,
120 supported_cache: SupportedCache,
122}
123
124impl Facilitator for FacilitatorClient {
125 fn verify(
126 &self,
127 request: VerifyRequest,
128 ) -> Pin<Box<dyn Future<Output = Result<VerifyResponse, FacilitatorError>> + Send + '_>> {
129 Box::pin(async move {
130 #[cfg(feature = "telemetry")]
131 let result = with_span(
132 Self::verify(self, &request),
133 tracing::info_span!("x402.facilitator_client.verify", timeout = ?self.timeout),
134 )
135 .await;
136 #[cfg(not(feature = "telemetry"))]
137 let result = Self::verify(self, &request).await;
138 result.map_err(|e| FacilitatorError::Other(Box::new(e)))
139 })
140 }
141
142 fn settle(
143 &self,
144 request: SettleRequest,
145 ) -> Pin<Box<dyn Future<Output = Result<SettleResponse, FacilitatorError>> + Send + '_>> {
146 Box::pin(async move {
147 #[cfg(feature = "telemetry")]
148 let result = with_span(
149 Self::settle(self, &request),
150 tracing::info_span!("x402.facilitator_client.settle", timeout = ?self.timeout),
151 )
152 .await;
153 #[cfg(not(feature = "telemetry"))]
154 let result = Self::settle(self, &request).await;
155 result.map_err(|e| FacilitatorError::Other(Box::new(e)))
156 })
157 }
158
159 fn supported(
160 &self,
161 ) -> Pin<Box<dyn Future<Output = Result<SupportedResponse, FacilitatorError>> + Send + '_>>
162 {
163 Box::pin(async move {
164 Self::supported(self)
165 .await
166 .map_err(|e| FacilitatorError::Other(Box::new(e)))
167 })
168 }
169}
170
171#[derive(Debug, thiserror::Error)]
173pub enum FacilitatorClientError {
174 #[error("URL parse error: {context}: {source}")]
176 UrlParse {
177 context: &'static str,
179 #[source]
181 source: url::ParseError,
182 },
183 #[error("HTTP error: {context}: {source}")]
185 Http {
186 context: &'static str,
188 #[source]
190 source: reqwest::Error,
191 },
192 #[error("Failed to deserialize JSON: {context}: {source}")]
194 JsonDeserialization {
195 context: &'static str,
197 #[source]
199 source: reqwest::Error,
200 },
201 #[error("Unexpected HTTP status {status}: {context}: {body}")]
203 HttpStatus {
204 context: &'static str,
206 status: StatusCode,
208 body: String,
210 },
211 #[error("Failed to read response body as text: {context}: {source}")]
213 ResponseBodyRead {
214 context: &'static str,
216 #[source]
218 source: reqwest::Error,
219 },
220}
221
222impl FacilitatorClient {
223 pub const DEFAULT_SUPPORTED_CACHE_TTL: Duration = Duration::from_mins(10);
225
226 pub const fn base_url(&self) -> &Url {
228 &self.base_url
229 }
230
231 pub const fn verify_url(&self) -> &Url {
233 &self.verify_url
234 }
235
236 pub const fn settle_url(&self) -> &Url {
238 &self.settle_url
239 }
240
241 pub const fn supported_url(&self) -> &Url {
243 &self.supported_url
244 }
245
246 pub const fn headers(&self) -> &HeaderMap {
248 &self.headers
249 }
250
251 pub const fn timeout(&self) -> &Option<Duration> {
253 &self.timeout
254 }
255
256 pub const fn supported_cache(&self) -> &SupportedCache {
258 &self.supported_cache
259 }
260
261 pub fn try_new(base_url: Url) -> Result<Self, FacilitatorClientError> {
269 let client = Client::new();
270 let verify_url =
271 base_url
272 .join("./verify")
273 .map_err(|e| FacilitatorClientError::UrlParse {
274 context: "Failed to construct ./verify URL",
275 source: e,
276 })?;
277 let settle_url =
278 base_url
279 .join("./settle")
280 .map_err(|e| FacilitatorClientError::UrlParse {
281 context: "Failed to construct ./settle URL",
282 source: e,
283 })?;
284 let supported_url =
285 base_url
286 .join("./supported")
287 .map_err(|e| FacilitatorClientError::UrlParse {
288 context: "Failed to construct ./supported URL",
289 source: e,
290 })?;
291 Ok(Self {
292 client,
293 base_url,
294 verify_url,
295 settle_url,
296 supported_url,
297 headers: HeaderMap::new(),
298 timeout: None,
299 supported_cache: SupportedCache::new(Self::DEFAULT_SUPPORTED_CACHE_TTL),
300 })
301 }
302
303 #[must_use]
305 pub fn with_headers(mut self, headers: HeaderMap) -> Self {
306 self.headers = headers;
307 self
308 }
309
310 #[must_use]
312 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
313 self.timeout = Some(timeout);
314 self
315 }
316
317 #[must_use]
321 pub fn with_supported_cache_ttl(mut self, ttl: Duration) -> Self {
322 self.supported_cache = SupportedCache::new(ttl);
323 self
324 }
325
326 #[must_use]
328 pub fn without_supported_cache(self) -> Self {
329 self.with_supported_cache_ttl(Duration::ZERO)
330 }
331
332 pub async fn verify(
338 &self,
339 request: &VerifyRequest,
340 ) -> Result<VerifyResponse, FacilitatorClientError> {
341 self.post_json(&self.verify_url, "POST /verify", request)
342 .await
343 }
344
345 pub async fn settle(
351 &self,
352 request: &SettleRequest,
353 ) -> Result<SettleResponse, FacilitatorClientError> {
354 self.post_json(&self.settle_url, "POST /settle", request)
355 .await
356 }
357
358 #[cfg_attr(
361 feature = "telemetry",
362 instrument(name = "x402.facilitator_client.supported", skip_all, err)
363 )]
364 async fn supported_inner(&self) -> Result<SupportedResponse, FacilitatorClientError> {
365 self.get_json(&self.supported_url, "GET /supported").await
366 }
367
368 pub async fn supported(&self) -> Result<SupportedResponse, FacilitatorClientError> {
376 if let Some(response) = self.supported_cache.get().await {
378 return Ok(response);
379 }
380
381 #[cfg(feature = "telemetry")]
383 tracing::info!("x402.facilitator_client.supported_cache_miss");
384
385 let response = self.supported_inner().await?;
386 self.supported_cache.set(response.clone()).await;
387
388 Ok(response)
389 }
390
391 #[allow(clippy::needless_pass_by_value)]
396 async fn post_json<T, R>(
397 &self,
398 url: &Url,
399 context: &'static str,
400 payload: &T,
401 ) -> Result<R, FacilitatorClientError>
402 where
403 T: serde::Serialize + Sync + ?Sized,
404 R: serde::de::DeserializeOwned,
405 {
406 let req = self.client.post(url.clone()).json(payload);
407 self.send_and_parse(req, context).await
408 }
409
410 async fn get_json<R>(
415 &self,
416 url: &Url,
417 context: &'static str,
418 ) -> Result<R, FacilitatorClientError>
419 where
420 R: serde::de::DeserializeOwned,
421 {
422 let req = self.client.get(url.clone());
423 self.send_and_parse(req, context).await
424 }
425
426 async fn send_and_parse<R>(
428 &self,
429 mut req: reqwest::RequestBuilder,
430 context: &'static str,
431 ) -> Result<R, FacilitatorClientError>
432 where
433 R: serde::de::DeserializeOwned,
434 {
435 for (key, value) in &self.headers {
436 req = req.header(key, value);
437 }
438 if let Some(timeout) = self.timeout {
439 req = req.timeout(timeout);
440 }
441 let http_response = req
442 .send()
443 .await
444 .map_err(|e| FacilitatorClientError::Http { context, source: e })?;
445
446 let result = if http_response.status() == StatusCode::OK {
447 http_response
448 .json::<R>()
449 .await
450 .map_err(|e| FacilitatorClientError::JsonDeserialization { context, source: e })
451 } else {
452 let status = http_response.status();
453 let body = http_response
454 .text()
455 .await
456 .map_err(|e| FacilitatorClientError::ResponseBodyRead { context, source: e })?;
457 Err(FacilitatorClientError::HttpStatus {
458 context,
459 status,
460 body,
461 })
462 };
463
464 record_result_on_span(&result);
465
466 result
467 }
468}
469
470impl TryFrom<&str> for FacilitatorClient {
472 type Error = FacilitatorClientError;
473
474 fn try_from(value: &str) -> Result<Self, Self::Error> {
475 let mut normalized = value.trim_end_matches('/').to_string();
477 normalized.push('/');
478 let url = Url::parse(&normalized).map_err(|e| FacilitatorClientError::UrlParse {
479 context: "Failed to parse base url",
480 source: e,
481 })?;
482 Self::try_new(url)
483 }
484}
485
486impl TryFrom<String> for FacilitatorClient {
488 type Error = FacilitatorClientError;
489
490 fn try_from(value: String) -> Result<Self, Self::Error> {
491 Self::try_from(value.as_str())
492 }
493}
494
495#[cfg(feature = "telemetry")]
497fn record_result_on_span<R, E: Display>(result: &Result<R, E>) {
498 let span = Span::current();
499 match result {
500 Ok(_) => {
501 span.record("otel.status_code", "OK");
502 }
503 Err(err) => {
504 span.record("otel.status_code", "ERROR");
505 span.record("error.message", tracing::field::display(err));
506 tracing::event!(tracing::Level::ERROR, error = %err, "Request to facilitator failed");
507 }
508 }
509}
510
511#[cfg(not(feature = "telemetry"))]
514fn record_result_on_span<R, E: Display>(_result: &Result<R, E>) {}
515
516#[cfg(feature = "telemetry")]
518fn with_span<F: Future>(fut: F, span: Span) -> impl Future<Output = F::Output> {
519 fut.instrument(span)
520}
521
522#[cfg(test)]
523mod tests {
524 use std::collections::HashMap;
525
526 use r402::proto::SupportedPaymentKind;
527 use wiremock::matchers::{method, path};
528 use wiremock::{Mock, MockServer, ResponseTemplate};
529
530 use super::*;
531
532 fn create_test_supported_response() -> SupportedResponse {
533 SupportedResponse {
534 kinds: vec![SupportedPaymentKind {
535 x402_version: 1,
536 scheme: "eip155-exact".to_string(),
537 network: "1".to_string(),
538 extra: None,
539 }],
540 extensions: vec![],
541 signers: HashMap::new(),
542 }
543 }
544
545 #[tokio::test]
546 async fn test_supported_cache_caches_response() {
547 let mock_server = MockServer::start().await;
548 let test_response = create_test_supported_response();
549
550 Mock::given(method("GET"))
552 .and(path("/supported"))
553 .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
554 .mount(&mock_server)
555 .await;
556
557 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
558
559 let result1 = client.supported().await.unwrap();
561 assert_eq!(result1.kinds.len(), 1);
562
563 let result2 = client.supported().await.unwrap();
565 assert_eq!(result2.kinds.len(), 1);
566
567 assert_eq!(result1.kinds[0].scheme, result2.kinds[0].scheme);
569 }
570
571 #[tokio::test]
572 async fn test_supported_cache_with_custom_ttl() {
573 let mock_server = MockServer::start().await;
574 let test_response = create_test_supported_response();
575
576 Mock::given(method("GET"))
578 .and(path("/supported"))
579 .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
580 .mount(&mock_server)
581 .await;
582
583 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
585 .unwrap()
586 .with_supported_cache_ttl(Duration::from_millis(1));
587
588 let result1 = client.supported().await.unwrap();
590 assert_eq!(result1.kinds.len(), 1);
591
592 tokio::time::sleep(Duration::from_millis(10)).await;
594
595 let result2 = client.supported().await.unwrap();
597 assert_eq!(result2.kinds.len(), 1);
598 }
599
600 #[tokio::test]
601 async fn test_supported_cache_disabled() {
602 let mock_server = MockServer::start().await;
603 let test_response = create_test_supported_response();
604
605 Mock::given(method("GET"))
607 .and(path("/supported"))
608 .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
609 .mount(&mock_server)
610 .await;
611
612 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
614 .unwrap()
615 .without_supported_cache();
616
617 let result1 = client.supported().await.unwrap();
619 let result2 = client.supported().await.unwrap();
620
621 assert_eq!(result1.kinds.len(), 1);
622 assert_eq!(result2.kinds.len(), 1);
623 }
624
625 #[tokio::test]
626 async fn test_supported_cache_clones_independently() {
627 let mock_server = MockServer::start().await;
628 let test_response = create_test_supported_response();
629
630 Mock::given(method("GET"))
632 .and(path("/supported"))
633 .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
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 _ = client.supported().await.unwrap();
644
645 let _ = client2.supported().await.unwrap();
648 }
649
650 #[tokio::test]
651 async fn test_supported_inner_bypasses_cache() {
652 let mock_server = MockServer::start().await;
653 let test_response = create_test_supported_response();
654
655 Mock::given(method("GET"))
657 .and(path("/supported"))
658 .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
659 .mount(&mock_server)
660 .await;
661
662 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
663
664 let _ = client.supported().await.unwrap();
666
667 let result = client.supported_inner().await.unwrap();
669 assert_eq!(result.kinds.len(), 1);
670 }
671}