Skip to main content

r402_client/
extension.rs

1//! Dyn-safe client extensions (payload enrich + HTTP 402 header hook).
2
3use std::fmt::{self, Debug, Formatter};
4use std::future::Future;
5
6use http::HeaderMap;
7use r402_protocol::{ClientError, PaymentRequired};
8
9use crate::hooks::BoxFuture;
10use crate::register::PaymentClient;
11use crate::select::PaymentSelector;
12
13/// Client-side extension. Copy of the [`crate::ClientHooks`] / [`DynClientExtension`] split:
14/// RPITIT is not object-safe, so stored values are [`DynClientExtension`].
15///
16/// SIWX implements [`Self::on_payment_required`] only (HTTP `SIGN-IN-WITH-X`).
17/// Builder-code implements [`Self::enrich_payment_payload`]. Do not fold SIWX into enrich.
18pub trait ClientExtension: Send + Sync {
19    /// Wire key (`"sign-in-with-x"`, `"builder-code"`, …).
20    fn key(&self) -> &'static str;
21
22    /// Called after scheme signing. Default is a no-op (returns `payload_b64`).
23    ///
24    /// Extensions that require a server declaration must no-op internally when
25    /// `payment_required.extensions` does not advertise `key`.
26    fn enrich_payment_payload<'a>(
27        &'a self,
28        payload_b64: &'a str,
29        _payment_required: &'a PaymentRequired,
30    ) -> impl Future<Output = Result<String, ClientError>> + Send + 'a {
31        std::future::ready(Ok(payload_b64.to_owned()))
32    }
33
34    /// Official `transportHooks.http.onPaymentRequired`. Default empty.
35    ///
36    /// `request_url` is the 402 response URL after redirects. SIWX returns
37    /// `SIGN-IN-WITH-X` here and binds challenge origin to that URL.
38    fn on_payment_required<'a>(
39        &'a self,
40        _payment_required: &'a PaymentRequired,
41        _request_url: &'a str,
42    ) -> impl Future<Output = HeaderMap> + Send + 'a {
43        std::future::ready(HeaderMap::new())
44    }
45}
46
47/// Object-safe erasure of [`ClientExtension`].
48pub trait DynClientExtension: Send + Sync {
49    /// See [`ClientExtension::key`].
50    fn key(&self) -> &'static str;
51
52    /// See [`ClientExtension::enrich_payment_payload`].
53    fn enrich_payment_payload<'a>(
54        &'a self,
55        payload_b64: &'a str,
56        payment_required: &'a PaymentRequired,
57    ) -> BoxFuture<'a, Result<String, ClientError>>;
58
59    /// See [`ClientExtension::on_payment_required`].
60    fn on_payment_required<'a>(
61        &'a self,
62        payment_required: &'a PaymentRequired,
63        request_url: &'a str,
64    ) -> BoxFuture<'a, HeaderMap>;
65}
66
67impl<T: ClientExtension + ?Sized> DynClientExtension for T {
68    fn key(&self) -> &'static str {
69        <Self as ClientExtension>::key(self)
70    }
71
72    fn enrich_payment_payload<'a>(
73        &'a self,
74        payload_b64: &'a str,
75        payment_required: &'a PaymentRequired,
76    ) -> BoxFuture<'a, Result<String, ClientError>> {
77        Box::pin(<Self as ClientExtension>::enrich_payment_payload(
78            self,
79            payload_b64,
80            payment_required,
81        ))
82    }
83
84    fn on_payment_required<'a>(
85        &'a self,
86        payment_required: &'a PaymentRequired,
87        request_url: &'a str,
88    ) -> BoxFuture<'a, HeaderMap> {
89        Box::pin(<Self as ClientExtension>::on_payment_required(
90            self,
91            payment_required,
92            request_url,
93        ))
94    }
95}
96
97impl Debug for dyn DynClientExtension {
98    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
99        f.write_str("DynClientExtension")
100    }
101}
102
103impl<S: PaymentSelector> PaymentClient<S> {
104    /// Runs [`ClientExtension::enrich_payment_payload`] for every registered extension.
105    pub(crate) async fn enrich_signed_payload(
106        &self,
107        payload_b64: &str,
108        payment_required: &PaymentRequired,
109    ) -> Result<String, ClientError> {
110        let mut payload = payload_b64.to_owned();
111        for extension in &self.extensions {
112            payload = extension
113                .enrich_payment_payload(&payload, payment_required)
114                .await?;
115        }
116        Ok(payload)
117    }
118
119    /// Merges [`ClientExtension::on_payment_required`] headers for advertised keys.
120    ///
121    /// `request_url` is the 402 response URL after redirects. Unadvertised keys
122    /// are skipped (official HTTP client only invokes a transport hook when
123    /// `extension.key` is in `paymentRequired.extensions`).
124    pub async fn extension_headers(
125        &self,
126        payment_required: &PaymentRequired,
127        request_url: &str,
128    ) -> HeaderMap {
129        let mut headers = HeaderMap::new();
130        for extension in &self.extensions {
131            if payment_required.extensions.get(extension.key()).is_none() {
132                continue;
133            }
134            headers.extend(
135                extension
136                    .on_payment_required(payment_required, request_url)
137                    .await,
138            );
139        }
140        headers
141    }
142}