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(&self) -> BoxFuture<'_, Result<SupportedResponse, FacilitatorError>> {
158 Box::pin(async move {
159 Self::supported(self)
160 .await
161 .map_err(|e| FacilitatorError::Other(Box::new(e)))
162 })
163 }
164}
165
166#[derive(Debug, thiserror::Error)]
168pub enum FacilitatorClientError {
169 #[error("URL parse error: {context}: {source}")]
171 UrlParse {
172 context: &'static str,
174 #[source]
176 source: url::ParseError,
177 },
178 #[error("HTTP error: {context}: {source}")]
180 Http {
181 context: &'static str,
183 #[source]
185 source: reqwest::Error,
186 },
187 #[error("Failed to deserialize JSON: {context}: {source}")]
189 JsonDeserialization {
190 context: &'static str,
192 #[source]
194 source: reqwest::Error,
195 },
196 #[error("Unexpected HTTP status {status}: {context}: {body}")]
198 HttpStatus {
199 context: &'static str,
201 status: StatusCode,
203 body: String,
205 },
206 #[error("Failed to read response body as text: {context}: {source}")]
208 ResponseBodyRead {
209 context: &'static str,
211 #[source]
213 source: reqwest::Error,
214 },
215}
216
217impl FacilitatorClient {
218 pub const DEFAULT_SUPPORTED_CACHE_TTL: Duration = Duration::from_mins(10);
220
221 pub const fn base_url(&self) -> &Url {
223 &self.base_url
224 }
225
226 pub const fn verify_url(&self) -> &Url {
228 &self.verify_url
229 }
230
231 pub const fn settle_url(&self) -> &Url {
233 &self.settle_url
234 }
235
236 pub const fn supported_url(&self) -> &Url {
238 &self.supported_url
239 }
240
241 pub const fn headers(&self) -> &HeaderMap {
243 &self.headers
244 }
245
246 pub const fn timeout(&self) -> &Option<Duration> {
248 &self.timeout
249 }
250
251 pub const fn supported_cache(&self) -> &SupportedCache {
253 &self.supported_cache
254 }
255
256 pub fn try_new(base_url: Url) -> Result<Self, FacilitatorClientError> {
264 let client = Client::new();
265 let verify_url =
266 base_url
267 .join("./verify")
268 .map_err(|e| FacilitatorClientError::UrlParse {
269 context: "Failed to construct ./verify URL",
270 source: e,
271 })?;
272 let settle_url =
273 base_url
274 .join("./settle")
275 .map_err(|e| FacilitatorClientError::UrlParse {
276 context: "Failed to construct ./settle URL",
277 source: e,
278 })?;
279 let supported_url =
280 base_url
281 .join("./supported")
282 .map_err(|e| FacilitatorClientError::UrlParse {
283 context: "Failed to construct ./supported URL",
284 source: e,
285 })?;
286 Ok(Self {
287 client,
288 base_url,
289 verify_url,
290 settle_url,
291 supported_url,
292 headers: HeaderMap::new(),
293 timeout: None,
294 supported_cache: SupportedCache::new(Self::DEFAULT_SUPPORTED_CACHE_TTL),
295 })
296 }
297
298 #[must_use]
300 pub fn with_headers(mut self, headers: HeaderMap) -> Self {
301 self.headers = headers;
302 self
303 }
304
305 #[must_use]
307 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
308 self.timeout = Some(timeout);
309 self
310 }
311
312 #[must_use]
316 pub fn with_supported_cache_ttl(mut self, ttl: Duration) -> Self {
317 self.supported_cache = SupportedCache::new(ttl);
318 self
319 }
320
321 #[must_use]
323 pub fn without_supported_cache(self) -> Self {
324 self.with_supported_cache_ttl(Duration::ZERO)
325 }
326
327 pub async fn verify(
333 &self,
334 request: &VerifyRequest,
335 ) -> Result<VerifyResponse, FacilitatorClientError> {
336 self.post_json(&self.verify_url, "POST /verify", request)
337 .await
338 }
339
340 pub async fn settle(
346 &self,
347 request: &SettleRequest,
348 ) -> Result<SettleResponse, FacilitatorClientError> {
349 self.post_json(&self.settle_url, "POST /settle", request)
350 .await
351 }
352
353 #[cfg_attr(
356 feature = "telemetry",
357 instrument(name = "x402.facilitator_client.supported", skip_all, err)
358 )]
359 async fn supported_inner(&self) -> Result<SupportedResponse, FacilitatorClientError> {
360 self.get_json(&self.supported_url, "GET /supported").await
361 }
362
363 pub async fn supported(&self) -> Result<SupportedResponse, FacilitatorClientError> {
371 if let Some(response) = self.supported_cache.get().await {
373 return Ok(response);
374 }
375
376 #[cfg(feature = "telemetry")]
378 tracing::info!("x402.facilitator_client.supported_cache_miss");
379
380 let response = self.supported_inner().await?;
381 self.supported_cache.set(response.clone()).await;
382
383 Ok(response)
384 }
385
386 #[allow(clippy::needless_pass_by_value)]
391 async fn post_json<T, R>(
392 &self,
393 url: &Url,
394 context: &'static str,
395 payload: &T,
396 ) -> Result<R, FacilitatorClientError>
397 where
398 T: serde::Serialize + Sync + ?Sized,
399 R: serde::de::DeserializeOwned,
400 {
401 let req = self.client.post(url.clone()).json(payload);
402 self.send_and_parse(req, context).await
403 }
404
405 async fn get_json<R>(
410 &self,
411 url: &Url,
412 context: &'static str,
413 ) -> Result<R, FacilitatorClientError>
414 where
415 R: serde::de::DeserializeOwned,
416 {
417 let req = self.client.get(url.clone());
418 self.send_and_parse(req, context).await
419 }
420
421 async fn send_and_parse<R>(
423 &self,
424 mut req: reqwest::RequestBuilder,
425 context: &'static str,
426 ) -> Result<R, FacilitatorClientError>
427 where
428 R: serde::de::DeserializeOwned,
429 {
430 for (key, value) in &self.headers {
431 req = req.header(key, value);
432 }
433 if let Some(timeout) = self.timeout {
434 req = req.timeout(timeout);
435 }
436 let http_response = req
437 .send()
438 .await
439 .map_err(|e| FacilitatorClientError::Http { context, source: e })?;
440
441 let result = if http_response.status() == StatusCode::OK {
442 http_response
443 .json::<R>()
444 .await
445 .map_err(|e| FacilitatorClientError::JsonDeserialization { context, source: e })
446 } else {
447 let status = http_response.status();
448 let body = http_response
449 .text()
450 .await
451 .map_err(|e| FacilitatorClientError::ResponseBodyRead { context, source: e })?;
452 Err(FacilitatorClientError::HttpStatus {
453 context,
454 status,
455 body,
456 })
457 };
458
459 record_result_on_span(&result);
460
461 result
462 }
463}
464
465impl TryFrom<&str> for FacilitatorClient {
467 type Error = FacilitatorClientError;
468
469 fn try_from(value: &str) -> Result<Self, Self::Error> {
470 let mut normalized = value.trim_end_matches('/').to_string();
472 normalized.push('/');
473 let url = Url::parse(&normalized).map_err(|e| FacilitatorClientError::UrlParse {
474 context: "Failed to parse base url",
475 source: e,
476 })?;
477 Self::try_new(url)
478 }
479}
480
481impl TryFrom<String> for FacilitatorClient {
483 type Error = FacilitatorClientError;
484
485 fn try_from(value: String) -> Result<Self, Self::Error> {
486 Self::try_from(value.as_str())
487 }
488}
489
490#[cfg(feature = "telemetry")]
492fn record_result_on_span<R, E: Display>(result: &Result<R, E>) {
493 let span = Span::current();
494 match result {
495 Ok(_) => {
496 span.record("otel.status_code", "OK");
497 }
498 Err(err) => {
499 span.record("otel.status_code", "ERROR");
500 span.record("error.message", tracing::field::display(err));
501 tracing::event!(tracing::Level::ERROR, error = %err, "Request to facilitator failed");
502 }
503 }
504}
505
506#[cfg(not(feature = "telemetry"))]
509const fn record_result_on_span<R, E: Display>(_result: &Result<R, E>) {}
510
511#[cfg(feature = "telemetry")]
513fn with_span<F: Future>(fut: F, span: Span) -> impl Future<Output = F::Output> {
514 fut.instrument(span)
515}
516
517#[cfg(test)]
518mod tests {
519 use std::collections::HashMap;
520
521 use r402::proto::SupportedPaymentKind;
522 use wiremock::matchers::{method, path};
523 use wiremock::{Mock, MockServer, ResponseTemplate};
524
525 use super::*;
526
527 fn create_test_supported_response() -> SupportedResponse {
528 SupportedResponse {
529 kinds: vec![SupportedPaymentKind {
530 x402_version: 1,
531 scheme: "eip155-exact".to_string(),
532 network: "1".to_string(),
533 extra: None,
534 }],
535 extensions: vec![],
536 signers: HashMap::new(),
537 }
538 }
539
540 #[tokio::test]
541 async fn test_supported_cache_caches_response() {
542 let mock_server = MockServer::start().await;
543 let test_response = create_test_supported_response();
544
545 Mock::given(method("GET"))
547 .and(path("/supported"))
548 .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
549 .mount(&mock_server)
550 .await;
551
552 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
553
554 let result1 = client.supported().await.unwrap();
556 assert_eq!(result1.kinds.len(), 1);
557
558 let result2 = client.supported().await.unwrap();
560 assert_eq!(result2.kinds.len(), 1);
561
562 assert_eq!(result1.kinds[0].scheme, result2.kinds[0].scheme);
564 }
565
566 #[tokio::test]
567 async fn test_supported_cache_with_custom_ttl() {
568 let mock_server = MockServer::start().await;
569 let test_response = create_test_supported_response();
570
571 Mock::given(method("GET"))
573 .and(path("/supported"))
574 .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
575 .mount(&mock_server)
576 .await;
577
578 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
580 .unwrap()
581 .with_supported_cache_ttl(Duration::from_millis(1));
582
583 let result1 = client.supported().await.unwrap();
585 assert_eq!(result1.kinds.len(), 1);
586
587 tokio::time::sleep(Duration::from_millis(10)).await;
589
590 let result2 = client.supported().await.unwrap();
592 assert_eq!(result2.kinds.len(), 1);
593 }
594
595 #[tokio::test]
596 async fn test_supported_cache_disabled() {
597 let mock_server = MockServer::start().await;
598 let test_response = create_test_supported_response();
599
600 Mock::given(method("GET"))
602 .and(path("/supported"))
603 .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
604 .mount(&mock_server)
605 .await;
606
607 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
609 .unwrap()
610 .without_supported_cache();
611
612 let result1 = client.supported().await.unwrap();
614 let result2 = client.supported().await.unwrap();
615
616 assert_eq!(result1.kinds.len(), 1);
617 assert_eq!(result2.kinds.len(), 1);
618 }
619
620 #[tokio::test]
621 async fn test_supported_cache_clones_independently() {
622 let mock_server = MockServer::start().await;
623 let test_response = create_test_supported_response();
624
625 Mock::given(method("GET"))
627 .and(path("/supported"))
628 .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
629 .mount(&mock_server)
630 .await;
631
632 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
633
634 let client2 = client.clone();
636
637 let _ = client.supported().await.unwrap();
639
640 let _ = client2.supported().await.unwrap();
643 }
644
645 #[tokio::test]
646 async fn test_supported_inner_bypasses_cache() {
647 let mock_server = MockServer::start().await;
648 let test_response = create_test_supported_response();
649
650 Mock::given(method("GET"))
652 .and(path("/supported"))
653 .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
654 .mount(&mock_server)
655 .await;
656
657 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
658
659 let _ = client.supported().await.unwrap();
661
662 let result = client.supported_inner().await.unwrap();
664 assert_eq!(result.kinds.len(), 1);
665 }
666}