Skip to main content

better_fetch/
client.rs

1//! HTTP client, builder, and shared configuration.
2//!
3//! Start with [`Client::new`] or [`ClientBuilder`], then:
4//!
5//! - [`Client::get`] / [`Client::post`] — flexible [`RequestBuilder`] (string paths, `.param("id", 1)`).
6//! - [`Client::call`] — typed [`Endpoint`] routes ([`.params()`](EndpointRequestBuilder::params) with structs).
7//!
8//! See [`crate::request`] for per-request options on [`RequestBuilder`].
9
10use std::collections::HashMap;
11use std::sync::Arc;
12use std::time::Duration;
13
14use indexmap::IndexMap;
15use tokio::sync::Semaphore;
16
17use http::Method;
18use reqwest::Client as ReqwestClient;
19use url::Url;
20
21use crate::auth::Auth;
22use crate::backend::{HttpBackend, HttpBody};
23use crate::endpoint::{Endpoint, EndpointParamsInitial, EndpointRequestBuilder};
24use crate::hooks::Hooks;
25use crate::plugin::PluginRegistry;
26use crate::request::RequestBuilder;
27use crate::response::Response;
28use crate::retry::RetryPolicy;
29use crate::streaming::StreamingResponse;
30use crate::Result;
31
32#[cfg(feature = "json")]
33use crate::json_parser::JsonParserFn;
34
35#[cfg(feature = "schema")]
36use crate::schema::SchemaRegistry;
37
38/// Shared client configuration (returned by [`Client::config`]).
39#[derive(Clone)]
40pub struct ClientConfig {
41    /// Base URL joined with request paths.
42    pub base_url: Url,
43    /// Default per-request timeout when the builder does not override it.
44    pub timeout: Option<Duration>,
45    /// Default retry policy for requests that do not set their own.
46    pub retry: Option<RetryPolicy>,
47    /// Default authentication applied when a request has no per-request auth.
48    pub auth: Option<Auth>,
49    /// Headers merged into every request unless overridden.
50    pub default_headers: http::HeaderMap,
51    #[allow(dead_code)]
52    pub(crate) hooks: Hooks,
53    pub(crate) merged_hooks: Hooks,
54    /// Registered plugins (init hooks + merged hook chains).
55    pub plugins: Arc<PluginRegistry>,
56    /// Limits concurrent in-flight requests for this client (including retries).
57    ///
58    /// This is separate from Tower's [`ConcurrencyLimitLayer`](crate::tower::stack::ConcurrencyLimitLayer):
59    /// the client semaphore applies to the full request lifecycle (hooks + retries), while Tower
60    /// limits only transport-layer concurrency. Avoid stacking both without accounting for that.
61    pub max_in_flight: Option<Arc<Semaphore>>,
62    #[cfg(feature = "schema")]
63    /// Optional strict route registry (feature `schema`).
64    pub schema_registry: Option<Arc<SchemaRegistry>>,
65    #[cfg(feature = "json")]
66    /// Client-wide custom JSON parser (feature `json`).
67    pub json_parser: Option<JsonParserFn>,
68    /// Default maximum response body size for buffered and streaming responses.
69    pub max_response_bytes: Option<u64>,
70    /// Maximum bytes read from a streaming body when evaluating a custom retry predicate.
71    pub retry_body_peek_bytes: u64,
72}
73
74impl ClientConfig {
75    /// Hooks executed at runtime (client hooks merged with plugin hooks when the client was built).
76    ///
77    /// This is the chain used for `on_request`, `on_response`, and related hooks. The separate
78    /// `hooks` field on [`ClientConfig`] is the client-only configuration snapshot and is not
79    /// consulted during requests.
80    ///
81    /// # Examples
82    ///
83    /// ```no_run
84    /// # use better_fetch::{Client, Result};
85    /// # #[tokio::main]
86    /// # async fn main() -> Result<()> {
87    /// let client = Client::new("https://api.example.com")?;
88    /// let _hooks = client.config().effective_hooks();
89    /// # Ok(())
90    /// # }
91    /// ```
92    pub fn effective_hooks(&self) -> &Hooks {
93        &self.merged_hooks
94    }
95}
96
97/// Typed HTTP client built on reqwest.
98#[derive(Clone)]
99pub struct Client {
100    pub(crate) config: Arc<ClientConfig>,
101    pub(crate) backend: Arc<dyn HttpBackend>,
102}
103
104impl Client {
105    /// Creates a client with default reqwest settings and the given base URL.
106    ///
107    /// # Examples
108    ///
109    /// ```no_run
110    /// # use better_fetch::{Client, Result};
111    /// # #[tokio::main]
112    /// # async fn main() -> Result<()> {
113    /// let client = Client::new("https://api.example.com")?;
114    /// let _ = client.get("/health").send().await?;
115    /// # Ok(())
116    /// # }
117    /// ```
118    pub fn new(base_url: impl AsRef<str>) -> Result<Self> {
119        ClientBuilder::new().base_url(base_url)?.build()
120    }
121
122    /// Returns a [`ClientBuilder`] for advanced configuration.
123    pub fn builder() -> ClientBuilder {
124        ClientBuilder::new()
125    }
126
127    /// Builds a client with a custom reqwest instance. [`ClientBuilder::base_url`] is required.
128    pub fn with_http_client(
129        reqwest_client: ReqwestClient,
130        base_url: impl AsRef<str>,
131    ) -> Result<Self> {
132        ClientBuilder::new()
133            .reqwest_client(reqwest_client)
134            .base_url(base_url)?
135            .build()
136    }
137
138    /// Starts a typed request for [`Endpoint`] `E`.
139    ///
140    /// When `E::Params` is not unit, returns a builder in [`NeedsParams`](crate::NeedsParams) state
141    /// that requires [`.params()`](EndpointRequestBuilder::params) before
142    /// [`.send_json()`](EndpointRequestBuilder::send_json). Query (`E::Query`) is typed but not
143    /// enforced: call [`.query()`](EndpointRequestBuilder::query) when you need query parameters on the wire.
144    ///
145    /// For ad-hoc requests with string paths, use [`Self::get`] / [`Self::post`] instead.
146    ///
147    /// # Examples
148    ///
149    /// ```no_run
150    /// # use better_fetch::{Client, Endpoint, Result, define_params};
151    /// # use http::Method;
152    /// # use serde::Deserialize;
153    /// define_params!(GetTodoParams for "/todos/:id" { id: u64 });
154    ///
155    /// struct GetTodo;
156    /// impl Endpoint for GetTodo {
157    ///     const METHOD: http::Method = http::Method::GET;
158    ///     const PATH: &'static str = "/todos/:id";
159    ///     type Response = Todo;
160    ///     type Params = GetTodoParams;
161    ///     type Query = ();
162    ///     type Body = ();
163    ///     type Headers = ();
164    /// }
165    ///
166    /// # #[derive(Deserialize)]
167    /// # struct Todo { id: u64, title: String }
168    /// # #[tokio::main]
169    /// # async fn main() -> Result<()> {
170    /// let client = Client::new("https://api.example.com")?;
171    /// let todo = client
172    ///     .call::<GetTodo>()
173    ///     .params(GetTodoParams { id: 1 })
174    ///     .send_json()
175    ///     .await?;
176    /// # Ok(())
177    /// # }
178    /// ```
179    pub fn call<E: Endpoint>(
180        &self,
181    ) -> EndpointRequestBuilder<'_, E, <E::Params as EndpointParamsInitial<E>>::State>
182    where
183        E::Params: EndpointParamsInitial<E>,
184    {
185        E::Params::initial(self)
186    }
187
188    /// Returns a snapshot of this client's configuration.
189    ///
190    /// Use [`ClientConfig::effective_hooks`] for the hook chain used at runtime (client hooks
191    /// merged with plugin hooks at build time).
192    pub fn config(&self) -> &ClientConfig {
193        &self.config
194    }
195
196    pub(crate) fn backend_arc(&self) -> &Arc<dyn HttpBackend> {
197        &self.backend
198    }
199
200    /// Starts a `GET` request for `path` (supports `:param` templates).
201    pub fn get(&self, path: impl Into<String>) -> RequestBuilder<'_> {
202        self.request(Method::GET, path)
203    }
204
205    /// Starts a `POST` request for `path`.
206    pub fn post(&self, path: impl Into<String>) -> RequestBuilder<'_> {
207        self.request(Method::POST, path)
208    }
209
210    /// Starts a `PUT` request for `path`.
211    pub fn put(&self, path: impl Into<String>) -> RequestBuilder<'_> {
212        self.request(Method::PUT, path)
213    }
214
215    /// Starts a `PATCH` request for `path`.
216    pub fn patch(&self, path: impl Into<String>) -> RequestBuilder<'_> {
217        self.request(Method::PATCH, path)
218    }
219
220    /// Starts a `DELETE` request for `path`.
221    pub fn delete(&self, path: impl Into<String>) -> RequestBuilder<'_> {
222        self.request(Method::DELETE, path)
223    }
224
225    /// Starts a `HEAD` request for `path`.
226    pub fn head(&self, path: impl Into<String>) -> RequestBuilder<'_> {
227        self.request(Method::HEAD, path)
228    }
229
230    /// Starts a request with an explicit HTTP method and path.
231    pub fn request(&self, method: Method, path: impl Into<String>) -> RequestBuilder<'_> {
232        RequestBuilder {
233            client: self,
234            method,
235            path: path.into(),
236            base_url: None,
237            params: HashMap::new(),
238            query: IndexMap::new(),
239            headers: self.config.default_headers.clone(),
240            body: HttpBody::Empty,
241            #[cfg(feature = "multipart")]
242            multipart: None,
243            timeout: self.config.timeout,
244            retry: self.config.retry.clone(),
245            auth: self.config.auth.clone(),
246            cancellation: None,
247            throw_on_error: false,
248            #[cfg(feature = "json")]
249            json_parser: None,
250            #[cfg(feature = "validate")]
251            validate_response: true,
252            #[cfg(feature = "schema-validate")]
253            disable_validation: false,
254            max_response_bytes: None,
255            retry_body_peek_bytes: None,
256        }
257    }
258
259    pub(crate) async fn execute_stream(
260        &self,
261        builder: RequestBuilder<'_>,
262    ) -> Result<StreamingResponse> {
263        let prep = crate::request_pipeline::prepare_execution(self, builder).await?;
264        crate::request_pipeline::run_stream_loop(prep).await
265    }
266
267    pub(crate) async fn execute(&self, builder: RequestBuilder<'_>) -> Result<Response> {
268        let prep = crate::request_pipeline::prepare_execution(self, builder).await?;
269        crate::request_pipeline::run_buffered_loop(prep).await
270    }
271}
272
273pub use crate::client_builder::ClientBuilder;