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