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 #[allow(
71 clippy::significant_drop_tightening,
72 reason = "read guard scope matches data access"
73 )]
74 pub async fn get(&self) -> Option<SupportedResponse> {
75 let guard = self.state.read().await;
76 let cache = guard.as_ref()?;
77 if std::time::Instant::now() < cache.expires_at {
78 Some(cache.response.clone())
79 } else {
80 None
81 }
82 }
83
84 pub async fn set(&self, response: SupportedResponse) {
86 let mut guard = self.state.write().await;
87 *guard = Some(SupportedCacheState {
88 response,
89 expires_at: std::time::Instant::now() + self.ttl,
90 });
91 }
92
93 pub async fn clear(&self) {
95 let mut guard = self.state.write().await;
96 *guard = None;
97 }
98}
99
100#[derive(Clone, Debug)]
104pub struct FacilitatorClient {
105 base_url: Url,
107 verify_url: Url,
109 settle_url: Url,
111 supported_url: Url,
113 client: Client,
115 headers: HeaderMap,
117 timeout: Option<Duration>,
119 supported_cache: SupportedCache,
121}
122
123#[derive(Debug, thiserror::Error)]
125pub enum FacilitatorClientError {
126 #[error("URL parse error: {context}: {source}")]
128 UrlParse {
129 context: &'static str,
131 #[source]
133 source: url::ParseError,
134 },
135 #[error("HTTP error: {context}: {source}")]
137 Http {
138 context: &'static str,
140 #[source]
142 source: reqwest::Error,
143 },
144 #[error("Failed to deserialize JSON: {context}: {source}")]
146 JsonDeserialization {
147 context: &'static str,
149 #[source]
151 source: reqwest::Error,
152 },
153 #[error("Unexpected HTTP status {status}: {context}: {body}")]
155 HttpStatus {
156 context: &'static str,
158 status: StatusCode,
160 body: String,
162 },
163 #[error("Failed to read response body as text: {context}: {source}")]
165 ResponseBodyRead {
166 context: &'static str,
168 #[source]
170 source: reqwest::Error,
171 },
172}
173
174impl FacilitatorClient {
175 pub const DEFAULT_SUPPORTED_CACHE_TTL: Duration = Duration::from_mins(10);
177
178 #[must_use]
180 pub const fn base_url(&self) -> &Url {
181 &self.base_url
182 }
183
184 #[must_use]
186 pub const fn verify_url(&self) -> &Url {
187 &self.verify_url
188 }
189
190 #[must_use]
192 pub const fn settle_url(&self) -> &Url {
193 &self.settle_url
194 }
195
196 #[must_use]
198 pub const fn supported_url(&self) -> &Url {
199 &self.supported_url
200 }
201
202 #[must_use]
204 pub const fn headers(&self) -> &HeaderMap {
205 &self.headers
206 }
207
208 #[must_use]
210 pub const fn timeout(&self) -> Option<&Duration> {
211 self.timeout.as_ref()
212 }
213
214 #[must_use]
216 pub const fn supported_cache(&self) -> &SupportedCache {
217 &self.supported_cache
218 }
219
220 pub fn try_new(base_url: Url) -> Result<Self, FacilitatorClientError> {
228 let client = Client::new();
229 let verify_url =
230 base_url
231 .join("./verify")
232 .map_err(|e| FacilitatorClientError::UrlParse {
233 context: "Failed to construct ./verify URL",
234 source: e,
235 })?;
236 let settle_url =
237 base_url
238 .join("./settle")
239 .map_err(|e| FacilitatorClientError::UrlParse {
240 context: "Failed to construct ./settle URL",
241 source: e,
242 })?;
243 let supported_url =
244 base_url
245 .join("./supported")
246 .map_err(|e| FacilitatorClientError::UrlParse {
247 context: "Failed to construct ./supported URL",
248 source: e,
249 })?;
250 Ok(Self {
251 client,
252 base_url,
253 verify_url,
254 settle_url,
255 supported_url,
256 headers: HeaderMap::new(),
257 timeout: None,
258 supported_cache: SupportedCache::new(Self::DEFAULT_SUPPORTED_CACHE_TTL),
259 })
260 }
261
262 #[must_use]
264 pub fn with_headers(mut self, headers: HeaderMap) -> Self {
265 self.headers = headers;
266 self
267 }
268
269 #[must_use]
271 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
272 self.timeout = Some(timeout);
273 self
274 }
275
276 #[must_use]
280 pub fn with_supported_cache_ttl(mut self, ttl: Duration) -> Self {
281 self.supported_cache = SupportedCache::new(ttl);
282 self
283 }
284
285 #[must_use]
287 pub fn without_supported_cache(self) -> Self {
288 self.with_supported_cache_ttl(Duration::ZERO)
289 }
290
291 pub async fn verify(
297 &self,
298 request: &VerifyRequest,
299 ) -> Result<VerifyResponse, FacilitatorClientError> {
300 self.post_json(&self.verify_url, "POST /verify", request)
301 .await
302 }
303
304 pub async fn settle(
310 &self,
311 request: &SettleRequest,
312 ) -> Result<SettleResponse, FacilitatorClientError> {
313 self.post_json(&self.settle_url, "POST /settle", request)
314 .await
315 }
316
317 #[cfg_attr(
320 feature = "telemetry",
321 instrument(name = "x402.facilitator_client.supported", skip_all, err)
322 )]
323 async fn supported_inner(&self) -> Result<SupportedResponse, FacilitatorClientError> {
324 self.get_json(&self.supported_url, "GET /supported").await
325 }
326
327 pub async fn supported(&self) -> Result<SupportedResponse, FacilitatorClientError> {
335 if let Some(response) = self.supported_cache.get().await {
337 return Ok(response);
338 }
339
340 #[cfg(feature = "telemetry")]
342 tracing::info!("x402.facilitator_client.supported_cache_miss");
343
344 let response = self.supported_inner().await?;
345 self.supported_cache.set(response.clone()).await;
346
347 Ok(response)
348 }
349
350 #[allow(
355 clippy::needless_pass_by_value,
356 reason = "context is a static str, clone cost is zero"
357 )]
358 async fn post_json<T, R>(
359 &self,
360 url: &Url,
361 context: &'static str,
362 payload: &T,
363 ) -> Result<R, FacilitatorClientError>
364 where
365 T: serde::Serialize + Sync + ?Sized,
366 R: serde::de::DeserializeOwned,
367 {
368 let req = self.client.post(url.clone()).json(payload);
369 self.send_and_parse(req, context).await
370 }
371
372 async fn get_json<R>(
377 &self,
378 url: &Url,
379 context: &'static str,
380 ) -> Result<R, FacilitatorClientError>
381 where
382 R: serde::de::DeserializeOwned,
383 {
384 let req = self.client.get(url.clone());
385 self.send_and_parse(req, context).await
386 }
387
388 async fn send_and_parse<R>(
390 &self,
391 mut req: reqwest::RequestBuilder,
392 context: &'static str,
393 ) -> Result<R, FacilitatorClientError>
394 where
395 R: serde::de::DeserializeOwned,
396 {
397 for (key, value) in &self.headers {
398 req = req.header(key, value);
399 }
400 if let Some(timeout) = self.timeout {
401 req = req.timeout(timeout);
402 }
403 let http_response = req
404 .send()
405 .await
406 .map_err(|e| FacilitatorClientError::Http { context, source: e })?;
407
408 let result = if http_response.status() == StatusCode::OK {
409 http_response
410 .json::<R>()
411 .await
412 .map_err(|e| FacilitatorClientError::JsonDeserialization { context, source: e })
413 } else {
414 let status = http_response.status();
415 let body = http_response
416 .text()
417 .await
418 .map_err(|e| FacilitatorClientError::ResponseBodyRead { context, source: e })?;
419 Err(FacilitatorClientError::HttpStatus {
420 context,
421 status,
422 body,
423 })
424 };
425
426 record_result_on_span(&result);
427
428 result
429 }
430}
431
432impl Facilitator for FacilitatorClient {
433 fn verify(
434 &self,
435 request: VerifyRequest,
436 ) -> BoxFuture<'_, Result<VerifyResponse, FacilitatorError>> {
437 Box::pin(async move {
438 #[cfg(feature = "telemetry")]
439 let result = with_span(
440 Self::verify(self, &request),
441 tracing::info_span!("x402.facilitator_client.verify", timeout = ?self.timeout),
442 )
443 .await;
444 #[cfg(not(feature = "telemetry"))]
445 let result = Self::verify(self, &request).await;
446 result.map_err(|e| FacilitatorError::Other(Box::new(e)))
447 })
448 }
449
450 fn settle(
451 &self,
452 request: SettleRequest,
453 ) -> BoxFuture<'_, Result<SettleResponse, FacilitatorError>> {
454 Box::pin(async move {
455 #[cfg(feature = "telemetry")]
456 let result = with_span(
457 Self::settle(self, &request),
458 tracing::info_span!("x402.facilitator_client.settle", timeout = ?self.timeout),
459 )
460 .await;
461 #[cfg(not(feature = "telemetry"))]
462 let result = Self::settle(self, &request).await;
463 result.map_err(|e| FacilitatorError::Other(Box::new(e)))
464 })
465 }
466
467 fn supported(&self) -> BoxFuture<'_, Result<SupportedResponse, FacilitatorError>> {
468 Box::pin(async move {
469 Self::supported(self)
470 .await
471 .map_err(|e| FacilitatorError::Other(Box::new(e)))
472 })
473 }
474}
475
476impl TryFrom<&str> for FacilitatorClient {
478 type Error = FacilitatorClientError;
479
480 fn try_from(value: &str) -> Result<Self, Self::Error> {
481 let mut normalized = value.trim_end_matches('/').to_owned();
483 normalized.push('/');
484 let url = Url::parse(&normalized).map_err(|e| FacilitatorClientError::UrlParse {
485 context: "Failed to parse base url",
486 source: e,
487 })?;
488 Self::try_new(url)
489 }
490}
491
492impl TryFrom<String> for FacilitatorClient {
494 type Error = FacilitatorClientError;
495
496 fn try_from(value: String) -> Result<Self, Self::Error> {
497 Self::try_from(value.as_str())
498 }
499}
500
501#[cfg(feature = "telemetry")]
503fn record_result_on_span<R, E: Display>(result: &Result<R, E>) {
504 let span = Span::current();
505 match result {
506 Ok(_) => {
507 span.record("otel.status_code", "OK");
508 }
509 Err(err) => {
510 span.record("otel.status_code", "ERROR");
511 span.record("error.message", tracing::field::display(err));
512 tracing::event!(tracing::Level::ERROR, error = %err, "Request to facilitator failed");
513 }
514 }
515}
516
517#[cfg(not(feature = "telemetry"))]
520const fn record_result_on_span<R, E: Display>(_result: &Result<R, E>) {}
521
522#[cfg(feature = "telemetry")]
524fn with_span<F: Future>(fut: F, span: Span) -> impl Future<Output = F::Output> {
525 fut.instrument(span)
526}
527
528#[cfg(test)]
529#[allow(
530 clippy::indexing_slicing,
531 reason = "test assertions with known-length slices"
532)]
533mod tests {
534 use std::collections::HashMap;
535
536 use r402::proto::SupportedPaymentKind;
537 use wiremock::matchers::{method, path};
538 use wiremock::{Mock, MockServer, ResponseTemplate};
539
540 use super::*;
541
542 fn create_test_supported_response() -> SupportedResponse {
543 SupportedResponse {
544 kinds: vec![SupportedPaymentKind {
545 x402_version: 1,
546 scheme: "eip155-exact".to_owned(),
547 network: "1".to_owned(),
548 extra: None,
549 }],
550 extensions: vec![],
551 signers: HashMap::new(),
552 }
553 }
554
555 #[tokio::test]
556 async fn test_supported_cache_caches_response() {
557 let mock_server = MockServer::start().await;
558 let test_response = create_test_supported_response();
559
560 Mock::given(method("GET"))
562 .and(path("/supported"))
563 .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
564 .mount(&mock_server)
565 .await;
566
567 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
568
569 let result1 = client.supported().await.unwrap();
571 assert_eq!(result1.kinds.len(), 1);
572
573 let result2 = client.supported().await.unwrap();
575 assert_eq!(result2.kinds.len(), 1);
576
577 assert_eq!(result1.kinds[0].scheme, result2.kinds[0].scheme);
579 }
580
581 #[tokio::test]
582 async fn test_supported_cache_with_custom_ttl() {
583 let mock_server = MockServer::start().await;
584 let test_response = create_test_supported_response();
585
586 Mock::given(method("GET"))
588 .and(path("/supported"))
589 .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
590 .mount(&mock_server)
591 .await;
592
593 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
595 .unwrap()
596 .with_supported_cache_ttl(Duration::from_millis(1));
597
598 let result1 = client.supported().await.unwrap();
600 assert_eq!(result1.kinds.len(), 1);
601
602 tokio::time::sleep(Duration::from_millis(10)).await;
604
605 let result2 = client.supported().await.unwrap();
607 assert_eq!(result2.kinds.len(), 1);
608 }
609
610 #[tokio::test]
611 async fn test_supported_cache_disabled() {
612 let mock_server = MockServer::start().await;
613 let test_response = create_test_supported_response();
614
615 Mock::given(method("GET"))
617 .and(path("/supported"))
618 .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
619 .mount(&mock_server)
620 .await;
621
622 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap())
624 .unwrap()
625 .without_supported_cache();
626
627 let result1 = client.supported().await.unwrap();
629 let result2 = client.supported().await.unwrap();
630
631 assert_eq!(result1.kinds.len(), 1);
632 assert_eq!(result2.kinds.len(), 1);
633 }
634
635 #[tokio::test]
636 async fn test_supported_cache_shared_across_clones() {
637 let mock_server = MockServer::start().await;
638 let test_response = create_test_supported_response();
639
640 Mock::given(method("GET"))
642 .and(path("/supported"))
643 .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
644 .expect(1)
645 .mount(&mock_server)
646 .await;
647
648 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
649
650 let client2 = client.clone();
652
653 let result1 = client.supported().await.unwrap();
655 assert_eq!(result1.kinds.len(), 1);
656
657 let result2 = client2.supported().await.unwrap();
659 assert_eq!(result2.kinds.len(), 1);
660 assert_eq!(result1.kinds[0].scheme, result2.kinds[0].scheme);
661 }
662
663 #[tokio::test]
664 async fn test_supported_inner_bypasses_cache() {
665 let mock_server = MockServer::start().await;
666 let test_response = create_test_supported_response();
667
668 Mock::given(method("GET"))
670 .and(path("/supported"))
671 .respond_with(ResponseTemplate::new(200).set_body_json(&test_response))
672 .mount(&mock_server)
673 .await;
674
675 let client = FacilitatorClient::try_new(mock_server.uri().parse::<Url>().unwrap()).unwrap();
676
677 let _ = client.supported().await.unwrap();
679
680 let result = client.supported_inner().await.unwrap();
682 assert_eq!(result.kinds.len(), 1);
683 }
684}