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;