Skip to main content

r402_http/
client.rs

1//! HTTP client middleware for automatic x402 payment handling.
2//!
3//! Provides [`X402HttpClient`] which implements [`reqwest_middleware::Middleware`]
4//! to automatically intercept 402 responses, create payment payloads via an
5//! [`r402::client::X402Client`], and retry with the `PAYMENT-SIGNATURE` header.
6//!
7//! Corresponds to Python SDK's `http/x402_http_client.py` +
8//! `http/x402_http_client_base.py`.
9
10use std::future::Future;
11use std::sync::Arc;
12
13use r402::client::X402Client;
14use r402::proto::{PaymentPayload, PaymentPayloadV1, PaymentRequired, PaymentRequiredV1};
15use reqwest::{Request, Response};
16use reqwest_middleware::{Middleware, Next};
17
18use crate::constants::{PAYMENT_REQUIRED_HEADER, PAYMENT_SIGNATURE_HEADER, X_PAYMENT_HEADER};
19use crate::error::HttpError;
20use crate::headers::{decode_payment_required, encode_payment_signature, encode_x_payment};
21
22/// reqwest-middleware that automatically handles HTTP 402 responses.
23///
24/// When a response with status 402 is received, the middleware:
25/// 1. Decodes the `PAYMENT-REQUIRED` header (or V1 body)
26/// 2. Delegates to [`X402Client::create_payment_payload`] to build a signed payload
27/// 3. Retries the request with the `PAYMENT-SIGNATURE` header attached
28///
29/// # Example
30///
31/// ```no_run
32/// use std::sync::Arc;
33/// use r402::client::X402Client;
34/// use r402_http::client::X402HttpClient;
35/// use reqwest_middleware::ClientBuilder;
36///
37/// let x402_client = Arc::new(X402Client::new());
38/// // Register scheme clients on x402_client...
39///
40/// let http_client = ClientBuilder::new(reqwest::Client::new())
41///     .with(X402HttpClient::new(x402_client))
42///     .build();
43/// ```
44///
45/// Corresponds to Python SDK's `x402HTTPClient` + `PaymentRoundTripper`.
46#[derive(Debug, Clone)]
47pub struct X402HttpClient {
48    client: Arc<X402Client>,
49}
50
51impl X402HttpClient {
52    /// Creates a new middleware wrapping the given x402 client.
53    #[must_use]
54    pub fn new(client: Arc<X402Client>) -> Self {
55        Self { client }
56    }
57
58    /// Creates a new middleware from an owned [`X402Client`].
59    ///
60    /// Convenience wrapper that wraps the client in an [`Arc`] internally.
61    #[must_use]
62    pub fn from_client(client: X402Client) -> Self {
63        Self {
64            client: Arc::new(client),
65        }
66    }
67
68    /// Builds a [`reqwest_middleware::ClientWithMiddleware`] with x402 payment
69    /// handling from an owned [`X402Client`].
70    ///
71    /// This is the simplest way to get a payment-capable HTTP client:
72    ///
73    /// ```ignore
74    /// use r402::client::X402Client;
75    /// use r402_http::client::X402HttpClient;
76    ///
77    /// let http_client = X402HttpClient::build_reqwest(
78    ///     X402Client::builder()
79    ///         .register("eip155:*".into(), Box::new(evm_scheme))
80    ///         .build()
81    /// );
82    /// ```
83    #[must_use]
84    pub fn build_reqwest(client: X402Client) -> reqwest_middleware::ClientWithMiddleware {
85        reqwest_middleware::ClientBuilder::new(reqwest::Client::new())
86            .with(Self::from_client(client))
87            .build()
88    }
89
90    /// Extracts payment-required info from a 402 response.
91    ///
92    /// Checks V2 header first, then falls back to V1 body.
93    async fn extract_payment_required(response: &Response) -> Option<PaymentRequiredVersion> {
94        // V2: PAYMENT-REQUIRED header
95        if let Some(header_value) = response.headers().get(PAYMENT_REQUIRED_HEADER) {
96            if let Ok(s) = header_value.to_str() {
97                if let Ok(parsed) = decode_payment_required(s) {
98                    return match parsed {
99                        r402::proto::helpers::PaymentRequiredEnum::V2(pr) => {
100                            Some(PaymentRequiredVersion::V2(*pr))
101                        }
102                        r402::proto::helpers::PaymentRequiredEnum::V1(pr) => {
103                            Some(PaymentRequiredVersion::V1(*pr))
104                        }
105                    };
106                }
107            }
108        }
109
110        None
111    }
112
113    /// Encodes a payment payload into the appropriate HTTP header.
114    fn encode_payment_header(
115        payload: &PaymentPayloadVersion,
116    ) -> Result<(String, String), HttpError> {
117        match payload {
118            PaymentPayloadVersion::V2(p) => {
119                let encoded = encode_payment_signature(p)?;
120                Ok((PAYMENT_SIGNATURE_HEADER.to_owned(), encoded))
121            }
122            PaymentPayloadVersion::V1(p) => {
123                let encoded = encode_x_payment(p)?;
124                Ok((X_PAYMENT_HEADER.to_owned(), encoded))
125            }
126        }
127    }
128}
129
130/// Internal version-tagged payment required.
131enum PaymentRequiredVersion {
132    V2(PaymentRequired),
133    V1(PaymentRequiredV1),
134}
135
136/// Internal version-tagged payment payload.
137enum PaymentPayloadVersion {
138    V2(PaymentPayload),
139    V1(PaymentPayloadV1),
140}
141
142impl Middleware for X402HttpClient {
143    fn handle<'life0, 'life1, 'life2, 'async_trait>(
144        &'life0 self,
145        req: Request,
146        extensions: &'life1 mut http::Extensions,
147        next: Next<'life2>,
148    ) -> core::pin::Pin<
149        Box<dyn Future<Output = Result<Response, reqwest_middleware::Error>> + Send + 'async_trait>,
150    >
151    where
152        'life0: 'async_trait,
153        'life1: 'async_trait,
154        'life2: 'async_trait,
155        Self: 'async_trait,
156    {
157        Box::pin(async move {
158            // Clone request info for potential retry
159            let method = req.method().clone();
160            let url = req.url().clone();
161            let original_headers = req.headers().clone();
162
163            // Send original request
164            let response = next.clone().run(req, extensions).await?;
165
166            // Not a 402 — pass through
167            if response.status().as_u16() != 402 {
168                return Ok(response);
169            }
170
171            // Extract payment requirements from the 402 response
172            let payment_required = match Self::extract_payment_required(&response).await {
173                Some(pr) => pr,
174                None => return Ok(response),
175            };
176
177            // Create payment payload via x402 client
178            let payment_payload = match &payment_required {
179                PaymentRequiredVersion::V2(pr) => {
180                    match self.client.create_payment_payload(pr).await {
181                        Ok(p) => PaymentPayloadVersion::V2(p),
182                        Err(_) => return Ok(response),
183                    }
184                }
185                PaymentRequiredVersion::V1(pr) => {
186                    match self.client.create_payment_payload_v1(pr).await {
187                        Ok(p) => PaymentPayloadVersion::V1(p),
188                        Err(_) => return Ok(response),
189                    }
190                }
191            };
192
193            // Encode payment into header
194            let (header_name, header_value) = match Self::encode_payment_header(&payment_payload) {
195                Ok(h) => h,
196                Err(_) => return Ok(response),
197            };
198
199            // Build retry request with payment header
200            let mut retry_req = Request::new(method, url);
201            *retry_req.headers_mut() = original_headers;
202            retry_req.headers_mut().insert(
203                reqwest::header::HeaderName::from_bytes(header_name.as_bytes())
204                    .expect("valid header name"),
205                reqwest::header::HeaderValue::from_str(&header_value).expect("valid header value"),
206            );
207
208            // Send retry
209            next.run(retry_req, extensions).await
210        })
211    }
212}