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 [`RequestBuilder::send_stream`](crate::RequestBuilder::send_stream).
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).
143    ///
144    /// For ad-hoc requests with string paths, use [`Self::get`] / [`Self::post`] instead.
145    ///
146    /// # Examples
147    ///
148    /// ```no_run
149    /// # use better_fetch::{Client, Endpoint, Result, define_params};
150    /// # use http::Method;
151    /// # use serde::Deserialize;
152    /// define_params!(GetTodoParams for "/todos/:id" { id: u64 });
153    ///
154    /// struct GetTodo;
155    /// impl Endpoint for GetTodo {
156    ///     const METHOD: http::Method = http::Method::GET;
157    ///     const PATH: &'static str = "/todos/:id";
158    ///     type Response = Todo;
159    ///     type Params = GetTodoParams;
160    ///     type Query = ();
161    ///     type Body = ();
162    ///     type Headers = ();
163    /// }
164    ///
165    /// # #[derive(Deserialize)]
166    /// # struct Todo { id: u64, title: String }
167    /// # #[tokio::main]
168    /// # async fn main() -> Result<()> {
169    /// let client = Client::new("https://api.example.com")?;
170    /// let todo = client
171    ///     .call::<GetTodo>()
172    ///     .params(GetTodoParams { id: 1 })
173    ///     .send_json()
174    ///     .await?;
175    /// # Ok(())
176    /// # }
177    /// ```
178    pub fn call<E: Endpoint>(
179        &self,
180    ) -> EndpointRequestBuilder<'_, E, <E::Params as EndpointParamsInitial<E>>::State>
181    where
182        E::Params: EndpointParamsInitial<E>,
183    {
184        E::Params::initial(self)
185    }
186
187    /// Returns a snapshot of this client's configuration.
188    ///
189    /// Use [`ClientConfig::effective_hooks`] for the hook chain used at runtime (client hooks
190    /// merged with plugin hooks at build time).
191    pub fn config(&self) -> &ClientConfig {
192        &self.config
193    }
194
195    pub(crate) fn backend_arc(&self) -> &Arc<dyn HttpBackend> {
196        &self.backend
197    }
198
199    /// Starts a `GET` request for `path` (supports `:param` templates).
200    pub fn get(&self, path: impl Into<String>) -> RequestBuilder<'_> {
201        self.request(Method::GET, path)
202    }
203
204    /// Starts a `POST` request for `path`.
205    pub fn post(&self, path: impl Into<String>) -> RequestBuilder<'_> {
206        self.request(Method::POST, path)
207    }
208
209    /// Starts a `PUT` request for `path`.
210    pub fn put(&self, path: impl Into<String>) -> RequestBuilder<'_> {
211        self.request(Method::PUT, path)
212    }
213
214    /// Starts a `PATCH` request for `path`.
215    pub fn patch(&self, path: impl Into<String>) -> RequestBuilder<'_> {
216        self.request(Method::PATCH, path)
217    }
218
219    /// Starts a `DELETE` request for `path`.
220    pub fn delete(&self, path: impl Into<String>) -> RequestBuilder<'_> {
221        self.request(Method::DELETE, path)
222    }
223
224    /// Starts a `HEAD` request for `path`.
225    pub fn head(&self, path: impl Into<String>) -> RequestBuilder<'_> {
226        self.request(Method::HEAD, path)
227    }
228
229    /// Starts a request with an explicit HTTP method and path.
230    pub fn request(&self, method: Method, path: impl Into<String>) -> RequestBuilder<'_> {
231        RequestBuilder {
232            client: self,
233            method,
234            path: path.into(),
235            base_url: None,
236            params: HashMap::new(),
237            query: IndexMap::new(),
238            headers: self.config.default_headers.clone(),
239            body: HttpBody::Empty,
240            #[cfg(feature = "multipart")]
241            multipart: None,
242            timeout: self.config.timeout,
243            retry: self.config.retry.clone(),
244            auth: self.config.auth.clone(),
245            cancellation: None,
246            throw_on_error: false,
247            #[cfg(feature = "json")]
248            json_parser: None,
249            #[cfg(feature = "validate")]
250            validate_response: true,
251            max_response_bytes: None,
252            retry_body_peek_bytes: None,
253        }
254    }
255
256    pub(crate) async fn execute_stream(
257        &self,
258        builder: RequestBuilder<'_>,
259    ) -> Result<StreamingResponse> {
260        let prep = crate::request_pipeline::prepare_execution(self, builder).await?;
261        crate::request_pipeline::run_stream_loop(prep).await
262    }
263
264    pub(crate) async fn execute(&self, builder: RequestBuilder<'_>) -> Result<Response> {
265        let prep = crate::request_pipeline::prepare_execution(self, builder).await?;
266        crate::request_pipeline::run_buffered_loop(prep).await
267    }
268}
269
270pub use crate::client_builder::ClientBuilder;