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
119#[derive(Debug, thiserror::Error)]
121pub enum FacilitatorClientError {
122 #[error("URL parse error: {context}: {source}")]
124 UrlParse {
125 context: &'static str,
127 #[source]
129 source: url::ParseError,
130 },
131 #[error("HTTP error: {context}: {source}")]
133 Http {
134 context: &'static str,
136 #[source]
138 source: reqwest::Error,
139 },
140 #[error("Failed to deserialize JSON: {context}: {source}")]
142 JsonDeserialization {
143 context: &'static str,
145 #[source]
147 source: reqwest::Error,
148 },
149 #[error("Unexpected HTTP status {status}: {context}: {body}")]
151 HttpStatus {
152 context: &'static str,
154 status: StatusCode,
156 body: String,
158 },
159 #[error("Failed to read response body as text: {context}: {source}")]
161 ResponseBodyRead {
162 context: &'static str,
164 #[source]
166 source: reqwest::Error,
167 },
168}
169
170impl FacilitatorClient {
171 pub const DEFAULT_SUPPORTED_CACHE_TTL: Duration = Duration::from_mins(10);
173
174 #[must_use]
176 pub const fn base_url(&self) -> &Url {
177 &self.base_url
178 }
179
180 #[must_use]
182 pub const fn verify_url(&self) -> &Url {
183 &self.verify_url
184 }
185
186 #[must_use]
188 pub const fn settle_url(&self) -> &Url {
189 &self.settle_url
190 }
191
192 #[must_use]
194 pub const fn supported_url(&self) -> &Url {
195 &self.supported_url
196 }
197
198 #[must_use]
200 pub const fn headers(&self) -> &HeaderMap {
201 &self.headers
202 }
203
204 #[must_use]
206 pub const fn timeout(&self) -> &Option<Duration> {
207 &self.timeout
208 }
209
210 #[must_use]
212 pub const fn supported_cache(&self) -> &SupportedCache {
213 &self.supported_cache
214 }
215
216 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 #[must_use]
260 pub fn with_headers(mut self, headers: HeaderMap) -> Self {
261 self.headers = headers;
262 self
263 }
264
265 #[must_use]
267 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
268 self.timeout = Some(timeout);
269 self
270 }
271
272 #[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 #[must_use]
283 pub fn without_supported_cache(self) -> Self {
284 self.with_supported_cache_ttl(Duration::ZERO)
285 }
286
287 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 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 #[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 pub async fn supported(&self) -> Result<SupportedResponse, FacilitatorClientError> {
331 if let Some(response) = self.supported_cache.get().await {
333 return Ok(response);
334 }
335
336 #[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 #[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 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 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
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}