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;