Skip to main content

better_fetch/
hooks.rs

1//! Lifecycle hooks for requests and responses.
2//!
3//! [`Hooks::on_request`] and [`Hooks::on_response`] return [`Result`]. To abort the client
4//! pipeline intentionally, return `Err(Error::hook("reason"))`. Other [`Error`] variants
5//! (`Transport`, `Http`, …) are valid when a hook needs to surface a specific failure.
6//! [`Hooks::on_success`], [`Hooks::on_error`], and [`Hooks::on_retry`] cannot return errors.
7
8use std::future::Future;
9use std::pin::Pin;
10use std::sync::Arc;
11
12use bytes::Bytes;
13use http::{HeaderMap, Method};
14use url::Url;
15
16use crate::error::Error;
17use crate::response::Response;
18use crate::Result;
19
20/// Context for an outgoing request.
21#[derive(Debug, Clone)]
22pub struct RequestContext {
23    /// Final URL after plugins and hook mutations.
24    pub url: Url,
25    /// HTTP method.
26    pub method: Method,
27    /// Request headers.
28    pub headers: HeaderMap,
29    /// Request body when present.
30    pub body: Option<Bytes>,
31    /// Number of times this request has already been retried (`0` on the first HTTP attempt).
32    ///
33    /// Matches JS [`retryAttempt`](https://better-fetch.vercel.app/docs/fetch-options).
34    pub retry_attempt: u32,
35}
36
37/// Context after a response is received.
38#[derive(Debug, Clone)]
39pub struct ResponseContext {
40    /// Original request context.
41    pub request: RequestContext,
42    /// Response from the transport (may be mutated by hooks).
43    pub response: Response,
44}
45
46/// Context after a successful HTTP response (2xx).
47#[derive(Debug, Clone)]
48pub struct SuccessContext {
49    /// Original request context.
50    pub request: RequestContext,
51    /// Successful response.
52    pub response: Response,
53}
54
55/// Context when an error occurs.
56#[derive(Debug, Clone)]
57pub struct ErrorContext {
58    /// Original request context.
59    pub request: RequestContext,
60    /// Response when the error is HTTP-related.
61    pub response: Option<Response>,
62    /// Error that occurred.
63    pub error: Error,
64}
65
66type RequestHookFn = Arc<
67    dyn Fn(RequestContext) -> Pin<Box<dyn Future<Output = Result<RequestContext>> + Send>>
68        + Send
69        + Sync,
70>;
71
72type ResponseHookFn = Arc<
73    dyn Fn(ResponseContext) -> Pin<Box<dyn Future<Output = Result<Response>> + Send>> + Send + Sync,
74>;
75
76type SuccessHookFn =
77    Arc<dyn Fn(SuccessContext) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
78
79type ErrorHookFn =
80    Arc<dyn Fn(ErrorContext) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
81
82type RetryHookFn =
83    Arc<dyn Fn(ResponseContext) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
84
85/// Lifecycle hooks for the HTTP client.
86#[derive(Clone, Default)]
87pub struct Hooks {
88    pub(crate) on_request: Vec<RequestHookFn>,
89    pub(crate) on_response: Vec<ResponseHookFn>,
90    pub(crate) on_success: Vec<SuccessHookFn>,
91    pub(crate) on_error: Vec<ErrorHookFn>,
92    pub(crate) on_retry: Vec<RetryHookFn>,
93}
94
95impl Hooks {
96    /// Creates an empty hook chain.
97    pub fn new() -> Self {
98        Self::default()
99    }
100
101    /// Runs before the transport call. Return `Err(Error::hook("…"))` to cancel the request.
102    ///
103    /// # Examples
104    ///
105    /// ```
106    /// use better_fetch::{ClientBuilder, Error, Hooks, Result};
107    ///
108    /// let hooks = Hooks::new().on_request(|ctx| async move {
109    ///     if ctx.url.path().contains("blocked") {
110    ///         return Err(Error::hook("path not allowed"));
111    ///     }
112    ///     Ok(ctx)
113    /// });
114    ///
115    /// let client = ClientBuilder::new()
116    ///     .base_url("https://api.example.com")?
117    ///     .hooks(hooks)
118    ///     .build()?;
119    /// # Ok::<(), better_fetch::Error>(())
120    /// ```
121    pub fn on_request<F, Fut>(mut self, f: F) -> Self
122    where
123        F: Fn(RequestContext) -> Fut + Send + Sync + 'static,
124        Fut: Future<Output = Result<RequestContext>> + Send + 'static,
125    {
126        self.on_request.push(Arc::new(move |ctx| Box::pin(f(ctx))));
127        self
128    }
129
130    /// Runs after the transport returns. Return `Err(Error::hook("…"))` to fail the request.
131    pub fn on_response<F, Fut>(mut self, f: F) -> Self
132    where
133        F: Fn(ResponseContext) -> Fut + Send + Sync + 'static,
134        Fut: Future<Output = Result<Response>> + Send + 'static,
135    {
136        self.on_response.push(Arc::new(move |ctx| Box::pin(f(ctx))));
137        self
138    }
139
140    /// Runs after a successful (2xx) response; cannot abort the pipeline.
141    pub fn on_success<F, Fut>(mut self, f: F) -> Self
142    where
143        F: Fn(SuccessContext) -> Fut + Send + Sync + 'static,
144        Fut: Future<Output = ()> + Send + 'static,
145    {
146        self.on_success.push(Arc::new(move |ctx| Box::pin(f(ctx))));
147        self
148    }
149
150    /// Runs when an error occurs; cannot abort the pipeline.
151    pub fn on_error<F, Fut>(mut self, f: F) -> Self
152    where
153        F: Fn(ErrorContext) -> Fut + Send + Sync + 'static,
154        Fut: Future<Output = ()> + Send + 'static,
155    {
156        self.on_error.push(Arc::new(move |ctx| Box::pin(f(ctx))));
157        self
158    }
159
160    /// Runs before a transport retry is scheduled.
161    pub fn on_retry<F, Fut>(mut self, f: F) -> Self
162    where
163        F: Fn(ResponseContext) -> Fut + Send + Sync + 'static,
164        Fut: Future<Output = ()> + Send + 'static,
165    {
166        self.on_retry.push(Arc::new(move |ctx| Box::pin(f(ctx))));
167        self
168    }
169
170    pub(crate) fn merge(mut self, other: Hooks) -> Self {
171        self.on_request.extend(other.on_request);
172        self.on_response.extend(other.on_response);
173        self.on_success.extend(other.on_success);
174        self.on_error.extend(other.on_error);
175        self.on_retry.extend(other.on_retry);
176        self
177    }
178
179    pub(crate) async fn run_on_request(&self, mut ctx: RequestContext) -> Result<RequestContext> {
180        for hook in &self.on_request {
181            ctx = hook(ctx).await?;
182        }
183        Ok(ctx)
184    }
185
186    pub(crate) async fn run_on_response(&self, ctx: ResponseContext) -> Result<Response> {
187        let request = ctx.request;
188        let mut response = ctx.response;
189        for hook in &self.on_response {
190            response = hook(ResponseContext {
191                request: request.clone(),
192                response,
193            })
194            .await?;
195        }
196        Ok(response)
197    }
198
199    pub(crate) async fn run_on_success(&self, ctx: SuccessContext) {
200        for hook in &self.on_success {
201            hook(ctx.clone()).await;
202        }
203    }
204
205    pub(crate) async fn run_on_error(&self, ctx: ErrorContext) {
206        for hook in &self.on_error {
207            hook(ctx.clone()).await;
208        }
209    }
210
211    pub(crate) async fn run_on_retry(&self, ctx: ResponseContext) {
212        for hook in &self.on_retry {
213            hook(ctx.clone()).await;
214        }
215    }
216}