Skip to main content

better_fetch/
client_builder.rs

1//! [`ClientBuilder`] — configure and build a [`Client`](crate::Client).
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use reqwest::Client as ReqwestClient;
7use tokio::sync::Semaphore;
8use url::Url;
9
10use crate::auth::Auth;
11use crate::backend::{HttpBackend, ReqwestBackend};
12use crate::client::{Client, ClientConfig};
13use crate::error::Error;
14use crate::hooks::Hooks;
15use crate::plugin::PluginRegistry;
16use crate::request::parse_request_header;
17use crate::retry::RetryPolicy;
18use crate::streaming::RETRY_BODY_PEEK_DEFAULT;
19use crate::Result;
20
21#[cfg(feature = "json")]
22use crate::json_parser::JsonParserFn;
23
24#[cfg(feature = "schema")]
25use crate::schema::SchemaRegistry;
26
27/// Builder for [`Client`].
28#[must_use = "call `.build()` to create a `Client`"]
29pub struct ClientBuilder {
30    base_url: Option<Url>,
31    timeout: Option<Duration>,
32    retry: Option<RetryPolicy>,
33    auth: Option<Auth>,
34    default_headers: http::HeaderMap,
35    hooks: Hooks,
36    plugins: PluginRegistry,
37    reqwest_client: Option<ReqwestClient>,
38    custom_backend: Option<Arc<dyn HttpBackend>>,
39    max_in_flight: Option<usize>,
40    max_response_bytes: Option<u64>,
41    retry_body_peek_bytes: Option<u64>,
42    #[cfg(feature = "schema")]
43    schema_registry: Option<Arc<SchemaRegistry>>,
44    #[cfg(feature = "json")]
45    json_parser: Option<JsonParserFn>,
46}
47
48impl ClientBuilder {
49    /// Creates an empty builder; [`Self::base_url`] is required before [`Self::build`].
50    pub fn new() -> Self {
51        Self {
52            base_url: None,
53            timeout: None,
54            retry: None,
55            auth: None,
56            default_headers: http::HeaderMap::new(),
57            hooks: Hooks::default(),
58            plugins: PluginRegistry::new(),
59            reqwest_client: None,
60            custom_backend: None,
61            max_in_flight: None,
62            max_response_bytes: None,
63            retry_body_peek_bytes: None,
64            #[cfg(feature = "schema")]
65            schema_registry: None,
66            #[cfg(feature = "json")]
67            json_parser: None,
68        }
69    }
70
71    /// Sets the base URL (required).
72    pub fn base_url(mut self, base_url: impl AsRef<str>) -> Result<Self> {
73        self.base_url = Some(Url::parse(base_url.as_ref()).map_err(Error::InvalidBaseUrl)?);
74        Ok(self)
75    }
76
77    /// Sets the default request timeout.
78    pub fn timeout(mut self, timeout: Duration) -> Self {
79        self.timeout = Some(timeout);
80        self
81    }
82
83    /// Sets the default [`RetryPolicy`] for all requests.
84    pub fn retry(mut self, policy: RetryPolicy) -> Self {
85        self.retry = Some(policy);
86        self
87    }
88
89    /// Sets default authentication for all requests.
90    pub fn auth(mut self, auth: Auth) -> Self {
91        self.auth = Some(auth);
92        self
93    }
94
95    /// Adds a default header applied to every request.
96    pub fn default_header(mut self, key: impl AsRef<str>, value: impl AsRef<str>) -> Result<Self> {
97        let (name, value) = parse_request_header(key, value)?;
98        self.default_headers.insert(name, value);
99        Ok(self)
100    }
101
102    /// Sets client-level lifecycle hooks.
103    pub fn hooks(mut self, hooks: Hooks) -> Self {
104        self.hooks = hooks;
105        self
106    }
107
108    /// Registers a [`Plugin`] on this client.
109    pub fn plugin<P: crate::plugin::Plugin + 'static>(mut self, plugin: P) -> Self {
110        self.plugins.push(Box::new(plugin));
111        self
112    }
113
114    /// Uses a custom reqwest client for the default [`ReqwestBackend`].
115    pub fn reqwest_client(mut self, client: ReqwestClient) -> Self {
116        self.reqwest_client = Some(client);
117        self
118    }
119
120    /// Use a custom HTTP backend (for testing or alternate transports).
121    ///
122    /// # Examples
123    ///
124    /// ```no_run
125    /// # use better_fetch::{ClientBuilder, Error, HttpBackend, HttpRequest, HttpResponse, HttpStreamingResponse, Result};
126    /// # use async_trait::async_trait;
127    /// # use bytes::Bytes;
128    /// # use http::StatusCode;
129    /// # use std::sync::Arc;
130    /// # struct MockBackend;
131    /// # #[async_trait]
132    /// # impl HttpBackend for MockBackend {
133    /// #     async fn execute(&self, _req: HttpRequest) -> Result<HttpResponse> {
134    /// #         Ok(HttpResponse {
135    /// #             status: StatusCode::OK,
136    /// #             headers: Default::default(),
137    /// #             body: Bytes::from_static(b"{}"),
138    /// #         })
139    /// #     }
140    /// #     async fn execute_stream(
141    /// #         &self,
142    /// #         _req: HttpRequest,
143    /// #     ) -> Result<HttpStreamingResponse> {
144    /// #         Err(Error::Other("streaming not supported".into()))
145    /// #     }
146    /// # }
147    /// # fn example() -> Result<()> {
148    /// let client = ClientBuilder::new()
149    ///     .base_url("https://api.example.com")?
150    ///     .backend(Arc::new(MockBackend))
151    ///     .build()?;
152    /// # Ok(())
153    /// # }
154    /// ```
155    pub fn backend(mut self, backend: Arc<dyn HttpBackend>) -> Self {
156        self.custom_backend = Some(backend);
157        self
158    }
159
160    /// Limits how many requests this client may have in flight at once (including retries).
161    ///
162    /// Implemented with a tokio semaphore in the core client. This counts the full request
163    /// lifecycle (hooks and retries), not just the transport hop. For wire-level limits only,
164    /// use [`Self::transport_stack`] with Tower's [`ConcurrencyLimitLayer`](crate::tower::stack::ConcurrencyLimitLayer)
165    /// (feature `tower`) instead of—or deliberately alongside—this setting.
166    pub fn max_in_flight(mut self, limit: usize) -> Self {
167        self.max_in_flight = Some(limit);
168        self
169    }
170
171    /// Maximum response body size (in bytes) for [`RequestBuilder::send`](crate::RequestBuilder::send),
172    /// [`send_json`](crate::RequestBuilder::send_json), and [`send_stream`](crate::RequestBuilder::send_stream)
173    /// when the request does not set its own limit.
174    pub fn max_response_bytes(mut self, limit: u64) -> Self {
175        self.max_response_bytes = Some(limit);
176        self
177    }
178
179    /// Maximum bytes read from a streaming body when a custom retry predicate is configured.
180    ///
181    /// Defaults to 64 KiB. Capped by [`Self::max_response_bytes`] when that is also set.
182    pub fn retry_body_peek_bytes(mut self, limit: u64) -> Self {
183        self.retry_body_peek_bytes = Some(limit);
184        self
185    }
186
187    /// Attach a [`SchemaRegistry`] for strict route validation (feature `schema`).
188    #[cfg(feature = "schema")]
189    pub fn schema_registry(mut self, registry: Arc<SchemaRegistry>) -> Self {
190        self.schema_registry = Some(registry);
191        self
192    }
193
194    /// Use a Tower [`Service`](tower::Service) as the HTTP transport for **buffered** `send()` only.
195    ///
196    /// `send_stream()` uses the default reqwest streaming transport without your Tower layers.
197    /// For middleware on both paths, use [`Self::transport_stack`].
198    #[cfg(feature = "tower")]
199    pub fn http_service<S>(mut self, service: S) -> Self
200    where
201        S: tower::Service<
202                crate::backend::HttpRequest,
203                Response = crate::backend::HttpResponse,
204                Error = Error,
205            > + Clone
206            + Send
207            + 'static,
208        S::Future: Send + 'static,
209    {
210        use crate::tower::ServiceBackend;
211
212        let client = self.reqwest_client.clone().unwrap_or_default();
213        self.custom_backend = Some(Arc::new(ServiceBackend::buffered_with_reqwest_streaming(
214            service, client,
215        )));
216        self
217    }
218
219    /// Use a boxed Tower transport stack for **buffered** `send()` only (streaming uses plain reqwest).
220    ///
221    /// Prefer [`Self::transport_stack`] when `send_stream()` must see the same middleware.
222    #[cfg(feature = "tower")]
223    pub fn http_service_boxed(mut self, service: crate::tower::BoxHttpService) -> Self {
224        use crate::tower::ServiceBackend;
225
226        let client = self.reqwest_client.clone().unwrap_or_default();
227        self.custom_backend = Some(Arc::new(ServiceBackend::new(
228            service,
229            crate::tower::ReqwestStreamingHttpService::new(client),
230        )));
231        self
232    }
233
234    /// Build a Tower transport stack on top of the configured (or default) reqwest client.
235    ///
236    /// Application hooks and [`RetryPolicy`](crate::RetryPolicy) remain in the core client;
237    /// only wire-level behavior is configured here.
238    ///
239    /// # Examples
240    ///
241    /// ```no_run
242    /// # use better_fetch::{ClientBuilder, Result};
243    /// # use better_fetch::tower::stack::{ConcurrencyLimitLayer, IntoBoxHttpService, IntoBoxStreamingHttpService, ServiceBuilder};
244    /// let client = ClientBuilder::new()
245    ///     .base_url("https://api.example.com")?
246    ///     .transport_stack(|buffered, streaming| {
247    ///         (
248    ///             ServiceBuilder::new()
249    ///                 .layer(ConcurrencyLimitLayer::new(32))
250    ///                 .service(buffered)
251    ///                 .into_box(),
252    ///             ServiceBuilder::new()
253    ///                 .layer(ConcurrencyLimitLayer::new(32))
254    ///                 .service(streaming)
255    ///                 .into_streaming_box(),
256    ///         )
257    ///     })
258    ///     .build()?;
259    /// # Ok::<(), better_fetch::Error>(())
260    /// ```
261    #[cfg(feature = "tower")]
262    pub fn transport_stack<F>(mut self, configure: F) -> Self
263    where
264        F: FnOnce(
265            crate::tower::ReqwestHttpService,
266            crate::tower::ReqwestStreamingHttpService,
267        ) -> (
268            crate::tower::BoxHttpService,
269            crate::tower::BoxStreamingHttpService,
270        ),
271    {
272        use crate::tower::ServiceBackend;
273
274        let client = self.reqwest_client.clone().unwrap_or_default();
275        let (buffered, streaming) = crate::tower::stack::build_dual(client, configure);
276        self.custom_backend = Some(Arc::new(ServiceBackend::from_boxes(buffered, streaming)));
277        self
278    }
279
280    /// Sets a custom JSON parser for all responses from this client.
281    ///
282    /// See [`crate::json_parser`] for the two-step `Bytes` → `Value` → `T` pipeline vs the
283    /// default single-step fast path, and [`Response::into_json_with`](crate::response::Response::into_json_with)
284    /// for per-response `Bytes` → `T` without a global parser.
285    ///
286    /// # Examples
287    ///
288    /// ```no_run
289    /// # use better_fetch::{ClientBuilder, Result};
290    /// # use bytes::Bytes;
291    /// let client = ClientBuilder::new()
292    ///     .base_url("https://api.example.com")?
293    ///     .json_parser(|body: &Bytes| {
294    ///         let slice = body.strip_prefix(b"\xef\xbb\xbf").unwrap_or(body);
295    ///         serde_json::from_slice(slice).map_err(|e| e.to_string())
296    ///     })
297    ///     .build()?;
298    /// # Ok::<(), better_fetch::Error>(())
299    /// ```
300    #[cfg(feature = "json")]
301    pub fn json_parser<F>(mut self, f: F) -> Self
302    where
303        F: Fn(&bytes::Bytes) -> std::result::Result<serde_json::Value, String>
304            + Send
305            + Sync
306            + 'static,
307    {
308        self.json_parser = Some(crate::json_parser::json_parser(f));
309        self
310    }
311
312    /// Sets a custom JSON parser from an existing [`JsonParserFn`].
313    #[cfg(feature = "json")]
314    pub fn json_parser_fn(mut self, parser: JsonParserFn) -> Self {
315        self.json_parser = Some(parser);
316        self
317    }
318
319    /// Builds the [`Client`]. Requires [`Self::base_url`].
320    ///
321    /// # Examples
322    ///
323    /// ```no_run
324    /// # use better_fetch::{ClientBuilder, Result};
325    /// let client = ClientBuilder::new()
326    ///     .base_url("https://api.example.com")?
327    ///     .build()?;
328    /// # Ok::<(), better_fetch::Error>(())
329    /// ```
330    pub fn build(self) -> Result<Client> {
331        let base_url = self.base_url.ok_or(Error::MissingBaseUrl)?;
332
333        let backend: Arc<dyn HttpBackend> = if let Some(b) = self.custom_backend {
334            b
335        } else {
336            let reqwest_client = self.reqwest_client.unwrap_or_default();
337            Arc::new(ReqwestBackend::new(reqwest_client))
338        };
339
340        let plugins = Arc::new(self.plugins);
341        let merged_hooks = self.hooks.clone().merge(plugins.merged_hooks());
342
343        Ok(Client {
344            config: Arc::new(ClientConfig {
345                base_url,
346                timeout: self.timeout,
347                retry: self.retry,
348                auth: self.auth,
349                default_headers: self.default_headers,
350                hooks: self.hooks,
351                merged_hooks,
352                plugins,
353                max_in_flight: self.max_in_flight.map(|n| Arc::new(Semaphore::new(n))),
354                #[cfg(feature = "schema")]
355                schema_registry: self.schema_registry,
356                #[cfg(feature = "json")]
357                json_parser: self.json_parser,
358                max_response_bytes: self.max_response_bytes,
359                retry_body_peek_bytes: self
360                    .retry_body_peek_bytes
361                    .unwrap_or(RETRY_BODY_PEEK_DEFAULT),
362            }),
363            backend,
364        })
365    }
366}
367
368impl Default for ClientBuilder {
369    fn default() -> Self {
370        Self::new()
371    }
372}