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}