Skip to main content

r402_http/
client.rs

1//! Client-side x402 payment handling for reqwest.
2//!
3//! [`X402Client`] is a thin HTTP adapter over [`r402_core::PaymentClient`]:
4//! scheme registration, policies, selection, and lifecycle hooks live in core.
5//! This module maps signed payloads to `Payment-Signature` and dispatches
6//! `on_payment_response` after paid retries (including one corrective recovery).
7
8use http::{Extensions, HeaderMap, StatusCode};
9use r402_core::ClientHooks;
10use r402_core::client::{CreatedPayment, PaymentClient, PaymentResponseContext};
11use r402_core::error::ClientError;
12use r402_core::scheme::{FirstMatch, PaymentPolicy, PaymentSelector, SchemeClient};
13use r402_core::wire;
14use r402_core::wire::Base64Bytes;
15use reqwest::{Client, Request, Response};
16use reqwest_middleware as rqm;
17#[cfg(feature = "telemetry")]
18use tracing::{debug, info, instrument, trace};
19
20/// HTTP middleware over a core [`PaymentClient`].
21///
22/// Automatically handles `402 Payment Required` by signing a payment and
23/// retrying once (or twice if `on_payment_response` signals recovery).
24#[allow(
25    missing_debug_implementations,
26    reason = "PaymentClient contains dyn trait objects"
27)]
28pub struct X402Client<TSelector = FirstMatch> {
29    inner: PaymentClient<TSelector>,
30}
31
32impl X402Client<FirstMatch> {
33    /// Creates a new client with [`FirstMatch`] selection.
34    #[must_use]
35    pub fn new() -> Self {
36        Self::default()
37    }
38}
39
40impl Default for X402Client<FirstMatch> {
41    fn default() -> Self {
42        Self {
43            inner: PaymentClient::new(),
44        }
45    }
46}
47
48impl<TSelector> X402Client<TSelector> {
49    /// Builds from an existing core payment client.
50    #[must_use]
51    pub const fn from_payment_client(inner: PaymentClient<TSelector>) -> Self {
52        Self { inner }
53    }
54
55    /// Returns a shared reference to the core payment client.
56    #[must_use]
57    pub const fn payment_client(&self) -> &PaymentClient<TSelector> {
58        &self.inner
59    }
60
61    /// Registers a scheme client.
62    #[must_use]
63    pub fn register<S>(mut self, scheme: S) -> Self
64    where
65        S: SchemeClient + 'static,
66    {
67        self.inner = self.inner.register(scheme);
68        self
69    }
70
71    /// Sets a custom payment selector.
72    #[must_use]
73    pub fn with_selector<P: PaymentSelector>(self, selector: P) -> X402Client<P> {
74        X402Client {
75            inner: self.inner.with_selector(selector),
76        }
77    }
78
79    /// Adds a payment policy.
80    #[must_use]
81    pub fn with_policy<P: PaymentPolicy + 'static>(mut self, policy: P) -> Self {
82        self.inner = self.inner.with_policy(policy);
83        self
84    }
85
86    /// Adds a client lifecycle hook (including `on_payment_response`).
87    #[must_use]
88    pub fn with_hook(mut self, hook: impl ClientHooks + 'static) -> Self {
89        self.inner = self.inner.with_hook(hook);
90        self
91    }
92}
93
94impl<TSelector> X402Client<TSelector>
95where
96    TSelector: PaymentSelector,
97{
98    /// Creates `Payment-Signature` headers from a 402 response.
99    ///
100    /// # Errors
101    ///
102    /// Parse, selection, signing, or before-hook abort.
103    #[cfg_attr(
104        feature = "telemetry",
105        instrument(name = "x402.reqwest.make_payment_headers", skip_all, err)
106    )]
107    pub async fn make_payment_headers(&self, res: Response) -> Result<HeaderMap, ClientError> {
108        let payment_required = parse_payment_required(res)
109            .await
110            .ok_or_else(|| ClientError::Parse("Invalid 402 response".to_owned()))?;
111        let created = self.inner.create_payment(&payment_required).await?;
112        Ok(payment_signature_headers(&created))
113    }
114
115    /// Creates a payment for an already-parsed challenge.
116    ///
117    /// # Errors
118    ///
119    /// Selection, signing, or before-hook abort.
120    pub async fn create_payment(
121        &self,
122        payment_required: &wire::PaymentRequired,
123    ) -> Result<CreatedPayment, ClientError> {
124        self.inner.create_payment(payment_required).await
125    }
126}
127
128/// Builds the `Payment-Signature` header map from a created payment.
129///
130/// # Panics
131///
132/// Panics if the signed payload is not a valid HTTP header value. Signed
133/// base64 payloads from scheme clients are always ASCII.
134#[must_use]
135pub fn payment_signature_headers(created: &CreatedPayment) -> HeaderMap {
136    let mut headers = HeaderMap::new();
137    #[allow(
138        clippy::expect_used,
139        reason = "base64-encoded payload is always valid ASCII header"
140    )]
141    headers.insert(
142        "Payment-Signature",
143        created
144            .signed_payload
145            .parse()
146            .expect("signed payload is valid header value"),
147    );
148    headers
149}
150
151/// Runs the next middleware or HTTP client with optional telemetry instrumentation.
152#[cfg_attr(
153    feature = "telemetry",
154    instrument(name = "x402.reqwest.next", skip_all)
155)]
156async fn run_next(
157    next: rqm::Next<'_>,
158    req: Request,
159    extensions: &mut Extensions,
160) -> rqm::Result<Response> {
161    next.run(req, extensions).await
162}
163
164#[async_trait::async_trait]
165impl<TSelector> rqm::Middleware for X402Client<TSelector>
166where
167    TSelector: PaymentSelector + Send + Sync + 'static,
168{
169    /// Handles a request, automatically handling 402 responses.
170    ///
171    /// When a 402 is received:
172    /// 1. Extract requirements, create signed payment (hooks)
173    /// 2. Retry with `Payment-Signature`
174    /// 3. Dispatch `on_payment_response` on settle / corrective 402
175    /// 4. If recovered, rebuild payment and retry once more
176    #[cfg_attr(
177        feature = "telemetry",
178        instrument(name = "x402.reqwest.handle", skip_all, err)
179    )]
180    async fn handle(
181        &self,
182        req: Request,
183        extensions: &mut Extensions,
184        next: rqm::Next<'_>,
185    ) -> rqm::Result<Response> {
186        let retry_template = req.try_clone();
187        let res = run_next(next.clone(), req, extensions).await?;
188
189        if res.status() != StatusCode::PAYMENT_REQUIRED {
190            #[cfg(feature = "telemetry")]
191            trace!(status = ?res.status(), "No payment required, returning response");
192            return Ok(res);
193        }
194
195        #[cfg(feature = "telemetry")]
196        info!(url = ?res.url(), "Received 402 Payment Required, processing payment");
197
198        let Some(template) = retry_template else {
199            #[cfg(feature = "telemetry")]
200            tracing::warn!("Cannot auto-retry 402: request body not cloneable, returning raw 402");
201            return Ok(res);
202        };
203
204        let payment_required = parse_payment_required(res).await.ok_or_else(|| {
205            rqm::Error::Middleware(ClientError::Parse("Invalid 402 response".into()).into())
206        })?;
207
208        let created = self
209            .inner
210            .create_payment(&payment_required)
211            .await
212            .map_err(|e| rqm::Error::Middleware(e.into()))?;
213
214        let paid = clone_with_payment(&template, &created)
215            .map_err(|e| rqm::Error::Middleware(e.into()))?;
216
217        #[cfg(feature = "telemetry")]
218        trace!(url = ?paid.url(), "Retrying request with payment headers");
219
220        let response = run_next(next.clone(), paid, extensions).await?;
221
222        let Some(recovered) = self
223            .dispatch_and_maybe_recover(&payment_required, &created, &response)
224            .await
225            .map_err(|e| rqm::Error::Middleware(e.into()))?
226        else {
227            return Ok(response);
228        };
229
230        let second = clone_with_payment(&template, &recovered)
231            .map_err(|e| rqm::Error::Middleware(e.into()))?;
232        let second_response = run_next(next, second, extensions).await?;
233        // Fire hooks on the second response without further recovery.
234        let ctx = build_response_context(&payment_required, &recovered, &second_response);
235        if ctx.settle_response.is_some() || ctx.corrective_payment_required.is_some() {
236            let _ = self.inner.handle_payment_response(&ctx).await;
237        }
238        Ok(second_response)
239    }
240}
241
242impl<TSelector: PaymentSelector> X402Client<TSelector> {
243    /// Returns `Some(new_created)` when hooks signal recovery and a fresh
244    /// payment can be built from the corrective challenge (or original).
245    async fn dispatch_and_maybe_recover(
246        &self,
247        original: &wire::PaymentRequired,
248        created: &CreatedPayment,
249        response: &Response,
250    ) -> Result<Option<CreatedPayment>, ClientError> {
251        let ctx = build_response_context(original, created, response);
252        if ctx.settle_response.is_none() && ctx.corrective_payment_required.is_none() {
253            return Ok(None);
254        }
255
256        let result = self.inner.handle_payment_response(&ctx).await;
257        if !result.recovered {
258            return Ok(None);
259        }
260
261        let challenge = ctx.corrective_payment_required.as_ref().unwrap_or(original);
262        let fresh = self.inner.create_payment(challenge).await?;
263        Ok(Some(fresh))
264    }
265}
266
267fn build_response_context(
268    original: &wire::PaymentRequired,
269    created: &CreatedPayment,
270    response: &Response,
271) -> PaymentResponseContext {
272    let settle_response = response
273        .headers()
274        .get("Payment-Response")
275        .and_then(|h| Base64Bytes::from(h.as_bytes()).decode().ok())
276        .and_then(|b| serde_json::from_slice(&b).ok());
277
278    let corrective_payment_required =
279        if settle_response.is_none() && response.status() == StatusCode::PAYMENT_REQUIRED {
280            response
281                .headers()
282                .get("Payment-Required")
283                .and_then(|h| Base64Bytes::from(h.as_bytes()).decode().ok())
284                .and_then(|b| serde_json::from_slice(&b).ok())
285        } else {
286            None
287        };
288
289    let mut ctx = PaymentResponseContext::new(original.clone(), created.signed_payload.clone());
290    if let Some(settle) = settle_response {
291        ctx = ctx.with_settle_response(settle);
292    }
293    if let Some(required) = corrective_payment_required {
294        ctx = ctx.with_corrective_payment_required(required);
295    }
296    ctx
297}
298
299fn clone_with_payment(
300    template: &Request,
301    created: &CreatedPayment,
302) -> Result<Request, ClientError> {
303    let mut req = template
304        .try_clone()
305        .ok_or(ClientError::RequestNotCloneable)?;
306    req.headers_mut().extend(payment_signature_headers(created));
307    Ok(req)
308}
309
310/// Parses a 402 Payment Required response into a [`wire::PaymentRequired`].
311///
312/// Tries `Payment-Required` header (base64 JSON) first, then response body.
313#[cfg_attr(
314    feature = "telemetry",
315    instrument(name = "x402.reqwest.parse_payment_required", skip(response))
316)]
317pub async fn parse_payment_required(response: Response) -> Option<wire::PaymentRequired> {
318    let v2_from_header = response
319        .headers()
320        .get("Payment-Required")
321        .and_then(|h| Base64Bytes::from(h.as_bytes()).decode().ok())
322        .and_then(|b| serde_json::from_slice::<wire::PaymentRequired>(&b).ok());
323
324    if let Some(v2_payment_required) = v2_from_header {
325        #[cfg(feature = "telemetry")]
326        debug!("Parsed V2 payment required from header");
327        return Some(v2_payment_required);
328    }
329
330    if let Ok(body_bytes) = response.bytes().await
331        && let Ok(v2_from_body) = serde_json::from_slice::<wire::PaymentRequired>(&body_bytes)
332    {
333        #[cfg(feature = "telemetry")]
334        debug!("Parsed V2 payment required from response body");
335        return Some(v2_from_body);
336    }
337
338    #[cfg(feature = "telemetry")]
339    debug!("Could not parse payment required from response");
340
341    None
342}
343
344impl<S> X402Client<S>
345where
346    Self: rqm::Middleware,
347{
348    /// Wraps a reqwest [`Client`] with x402 payment middleware.
349    ///
350    /// Returns a [`rqm::ClientWithMiddleware`] that automatically handles
351    /// 402 Payment Required responses using registered scheme clients.
352    #[must_use]
353    pub fn wrap(self, client: Client) -> rqm::ClientWithMiddleware {
354        rqm::ClientBuilder::new(client).with(self).build()
355    }
356
357    /// Wraps a reqwest [`Client`] and returns the middleware builder
358    /// for further customization (e.g., adding more middleware).
359    #[must_use]
360    pub fn wrap_builder(self, client: Client) -> rqm::ClientBuilder {
361        rqm::ClientBuilder::new(client).with(self)
362    }
363}
364
365/// Extension trait for adding x402 payment handling to a reqwest [`Client`].
366///
367/// ```rust,ignore
368/// use r402_http::client::{WithPayments, X402Client};
369///
370/// let client = reqwest::Client::new().with_payments(x402);
371/// ```
372pub trait WithPayments {
373    /// Adds x402 payment middleware, returning a ready-to-use client.
374    fn with_payments<S>(self, x402: X402Client<S>) -> rqm::ClientWithMiddleware
375    where
376        X402Client<S>: rqm::Middleware;
377}
378
379impl WithPayments for Client {
380    fn with_payments<S>(self, x402: X402Client<S>) -> rqm::ClientWithMiddleware
381    where
382        X402Client<S>: rqm::Middleware,
383    {
384        x402.wrap(self)
385    }
386}