Skip to main content

r402_client/
hooks.rs

1//! Buyer lifecycle hooks around payment creation and paid responses.
2
3use std::fmt::{self, Debug, Formatter};
4use std::future::Future;
5use std::pin::Pin;
6
7use r402_protocol::{PaymentRequired, SettleResponse};
8
9use crate::register::PaymentClient;
10use crate::select::PaymentSelector;
11
12/// Boxed future used by [`DynClientHooks`].
13pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
14
15/// Decision returned by "before" hooks to control whether the operation proceeds.
16#[derive(Debug, Clone)]
17#[non_exhaustive]
18pub enum HookDecision {
19    /// Continue execution normally.
20    Continue,
21    /// Abort with a structured reason + message.
22    Abort {
23        /// Machine-readable reason for aborting.
24        reason: String,
25        /// Human-readable description.
26        message: String,
27    },
28}
29
30/// Outcome returned by "on failure" hooks.
31#[derive(Debug)]
32#[non_exhaustive]
33pub enum FailureRecovery<T> {
34    /// No recovery — propagate the original error.
35    Propagate,
36    /// The hook produced a substitute success result.
37    Recovered(T),
38}
39
40/// Context for payment-creation hooks.
41#[derive(Debug, Clone)]
42#[non_exhaustive]
43pub struct PaymentCreationContext {
44    /// Parsed payment requirements from the 402 challenge.
45    pub payment_required: PaymentRequired,
46}
47
48impl PaymentCreationContext {
49    /// Constructs a creation context from a 402 challenge.
50    #[must_use]
51    pub const fn new(payment_required: PaymentRequired) -> Self {
52        Self { payment_required }
53    }
54}
55
56/// Result of successful payment creation (transport-agnostic).
57#[derive(Debug, Clone)]
58#[non_exhaustive]
59pub struct CreatedPayment {
60    /// Base64-encoded payment payload for `Payment-Signature` / MCP meta.
61    pub signed_payload: String,
62    /// Challenge that was paid.
63    pub payment_required: PaymentRequired,
64}
65
66impl CreatedPayment {
67    /// Constructs a created-payment record.
68    #[must_use]
69    pub fn new(signed_payload: impl Into<String>, payment_required: PaymentRequired) -> Self {
70        Self {
71            signed_payload: signed_payload.into(),
72            payment_required,
73        }
74    }
75}
76
77/// Context delivered after a paid request completes.
78///
79/// Typically one of `settle_response` or `corrective_payment_required` is set:
80/// settle for a paid success with `Payment-Response`; corrective 402 when the
81/// server rejected with a new `Payment-Required`.
82#[derive(Debug, Clone)]
83#[non_exhaustive]
84pub struct PaymentResponseContext {
85    /// Original 402 challenge used to build the payment.
86    pub payment_required: PaymentRequired,
87    /// Signed payload that was submitted.
88    pub signed_payload: String,
89    /// Parsed settle outcome when present.
90    pub settle_response: Option<SettleResponse>,
91    /// Corrective `Payment-Required` when the paid retry returned 402.
92    pub corrective_payment_required: Option<PaymentRequired>,
93}
94
95impl PaymentResponseContext {
96    /// Constructs a payment-response context.
97    #[must_use]
98    pub fn new(payment_required: PaymentRequired, signed_payload: impl Into<String>) -> Self {
99        Self {
100            payment_required,
101            signed_payload: signed_payload.into(),
102            settle_response: None,
103            corrective_payment_required: None,
104        }
105    }
106
107    /// Attaches a settle response.
108    #[must_use]
109    pub fn with_settle_response(mut self, settle: SettleResponse) -> Self {
110        self.settle_response = Some(settle);
111        self
112    }
113
114    /// Attaches a corrective payment-required challenge.
115    #[must_use]
116    pub fn with_corrective_payment_required(mut self, required: PaymentRequired) -> Self {
117        self.corrective_payment_required = Some(required);
118        self
119    }
120}
121
122/// Result of [`ClientHooks::on_payment_response`].
123#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
124#[non_exhaustive]
125pub struct PaymentResponseResult {
126    /// When `true`, the transport should retry once with a freshly built payload.
127    pub recovered: bool,
128}
129
130impl PaymentResponseResult {
131    /// No recovery — continue with the response as-is.
132    #[must_use]
133    pub const fn continue_() -> Self {
134        Self { recovered: false }
135    }
136
137    /// Signal one corrective retry.
138    #[must_use]
139    pub const fn recovered() -> Self {
140        Self { recovered: true }
141    }
142}
143
144/// Lifecycle hooks for the payment client.
145///
146/// All methods default to no-ops. Override only what you need.
147pub trait ClientHooks: Send + Sync {
148    /// Runs before payment payload creation. Abort skips signing.
149    fn before_payment_creation<'a>(
150        &'a self,
151        _ctx: &'a PaymentCreationContext,
152    ) -> impl Future<Output = HookDecision> + Send + 'a {
153        async { HookDecision::Continue }
154    }
155
156    /// Runs after a payment payload is successfully created.
157    fn after_payment_creation<'a>(
158        &'a self,
159        _ctx: &'a PaymentCreationContext,
160        _created: &'a CreatedPayment,
161    ) -> impl Future<Output = ()> + Send + 'a {
162        async {}
163    }
164
165    /// Runs when payment creation fails. May recover with a substitute payload.
166    fn on_payment_creation_failure<'a>(
167        &'a self,
168        _ctx: &'a PaymentCreationContext,
169        _error: &'a str,
170    ) -> impl Future<Output = FailureRecovery<CreatedPayment>> + Send + 'a {
171        async { FailureRecovery::Propagate }
172    }
173
174    /// Runs after each paid response (settle success or corrective 402).
175    ///
176    /// Returning [`PaymentResponseResult::recovered`] asks the transport to
177    /// retry once with a freshly built payment payload.
178    fn on_payment_response<'a>(
179        &'a self,
180        _ctx: &'a PaymentResponseContext,
181    ) -> impl Future<Output = PaymentResponseResult> + Send + 'a {
182        async { PaymentResponseResult::continue_() }
183    }
184}
185
186/// Object-safe erasure of [`ClientHooks`].
187pub trait DynClientHooks: Send + Sync {
188    /// See [`ClientHooks::before_payment_creation`].
189    fn before_payment_creation<'a>(
190        &'a self,
191        ctx: &'a PaymentCreationContext,
192    ) -> Pin<Box<dyn Future<Output = HookDecision> + Send + 'a>>;
193
194    /// See [`ClientHooks::after_payment_creation`].
195    fn after_payment_creation<'a>(
196        &'a self,
197        ctx: &'a PaymentCreationContext,
198        created: &'a CreatedPayment,
199    ) -> BoxFuture<'a, ()>;
200
201    /// See [`ClientHooks::on_payment_creation_failure`].
202    fn on_payment_creation_failure<'a>(
203        &'a self,
204        ctx: &'a PaymentCreationContext,
205        error: &'a str,
206    ) -> BoxFuture<'a, FailureRecovery<CreatedPayment>>;
207
208    /// See [`ClientHooks::on_payment_response`].
209    fn on_payment_response<'a>(
210        &'a self,
211        ctx: &'a PaymentResponseContext,
212    ) -> Pin<Box<dyn Future<Output = PaymentResponseResult> + Send + 'a>>;
213}
214
215impl<T: ClientHooks + ?Sized> DynClientHooks for T {
216    fn before_payment_creation<'a>(
217        &'a self,
218        ctx: &'a PaymentCreationContext,
219    ) -> Pin<Box<dyn Future<Output = HookDecision> + Send + 'a>> {
220        Box::pin(<Self as ClientHooks>::before_payment_creation(self, ctx))
221    }
222
223    fn after_payment_creation<'a>(
224        &'a self,
225        ctx: &'a PaymentCreationContext,
226        created: &'a CreatedPayment,
227    ) -> BoxFuture<'a, ()> {
228        Box::pin(<Self as ClientHooks>::after_payment_creation(
229            self, ctx, created,
230        ))
231    }
232
233    fn on_payment_creation_failure<'a>(
234        &'a self,
235        ctx: &'a PaymentCreationContext,
236        error: &'a str,
237    ) -> BoxFuture<'a, FailureRecovery<CreatedPayment>> {
238        Box::pin(<Self as ClientHooks>::on_payment_creation_failure(
239            self, ctx, error,
240        ))
241    }
242
243    fn on_payment_response<'a>(
244        &'a self,
245        ctx: &'a PaymentResponseContext,
246    ) -> Pin<Box<dyn Future<Output = PaymentResponseResult> + Send + 'a>> {
247        Box::pin(<Self as ClientHooks>::on_payment_response(self, ctx))
248    }
249}
250
251impl Debug for dyn DynClientHooks {
252    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
253        f.write_str("DynClientHooks")
254    }
255}
256
257impl<S: PaymentSelector> PaymentClient<S> {
258    /// Dispatches [`ClientHooks::on_payment_response`] for every hook.
259    ///
260    /// First `recovered: true` wins; remaining hooks still run.
261    pub async fn handle_payment_response(
262        &self,
263        ctx: &PaymentResponseContext,
264    ) -> PaymentResponseResult {
265        let mut recovered = false;
266        for hook in &self.hooks {
267            let result = hook.on_payment_response(ctx).await;
268            if result.recovered {
269                recovered = true;
270            }
271        }
272        if recovered {
273            PaymentResponseResult::recovered()
274        } else {
275            PaymentResponseResult::continue_()
276        }
277    }
278}