Skip to main content

fetch_kit/
lib.rs

1#![forbid(unsafe_code)]
2#![deny(missing_docs)]
3
4//! Resilient HTTP client for Rust.
5//!
6//! `fetch_kit` wraps `reqwest` with a native middleware stack and sensible
7//! defaults for retries, timeouts, and typed JSON helpers. Enable the
8//! `circuit-breaker` feature for automatic circuit-breaking on repeated
9//! failures.
10//!
11//! # Middleware
12//!
13//! The [`middleware::Middleware`] trait is the composition point. Its
14//! shape mirrors `reqwest-middleware` 0.5, so migrating an existing
15//! middleware is an import-path swap. Middleware wrap the transport in an
16//! onion; the middleware registered **first** is the **outermost** layer:
17//!
18//! ```text
19//! ClientBuilder::new(client)
20//!     .with(AuthMiddleware)   // 1st registered → outermost
21//!     .with(RetryMiddleware)  // 2nd registered
22//!     .build();
23//!
24//! request  →  Auth  →  Retry  →  transport
25//! response ←  Auth  ←  Retry  ←  ╹
26//! ```
27//!
28//! Bundled middleware (each behind a feature): `middleware::RetryMiddleware`
29//! (default-on, powered by the estate `loop-retry` backoff),
30//! `middleware::CircuitBreakerMiddleware`, `middleware::ThrottleMiddleware`,
31//! `middleware::TimeoutMiddleware`, and `middleware::BaseUrlMiddleware`.
32//!
33//! # Built-ins are defaults; middleware are composition points
34//!
35//! The [`ClientBuilder`] knobs configure the built-in stack:
36//! `retries`/`retry_bounds` shape the default retry middleware (feature
37//! `retry`), `with_breaker` inserts the default breaker, and `base_url`
38//! resolves paths eagerly. Custom middleware registered via
39//! [`ClientBuilder::with_middleware`] run **outermost** — wrapping the
40//! built-ins — so they observe the final outcome of the whole default
41//! stack. Post-build, [`Client::with_middleware`] appends middleware
42//! **innermost** on a built client.
43
44/// Error types.
45pub mod error;
46/// Middleware implementations.
47pub mod middleware;
48
49use std::sync::Arc;
50use std::time::Duration;
51
52use middleware::{ClientBuilder as ChainBuilder, ClientWithMiddleware, Middleware};
53use serde::Serialize;
54#[cfg(feature = "json")]
55use serde::de::DeserializeOwned;
56
57pub use error::FetchError;
58pub use reqwest::Response;
59
60/// A resilient HTTP client with retry, timeout, and typed JSON support.
61#[derive(Clone, Debug)]
62pub struct Client {
63    inner: ClientWithMiddleware,
64    pub(crate) base_url: Option<String>,
65}
66
67/// Builder for constructing a [`Client`] with custom configuration.
68pub struct ClientBuilder {
69    reqwest_builder: reqwest::ClientBuilder,
70    base_url: Option<String>,
71    #[cfg(feature = "retry")]
72    retries: u32,
73    #[cfg(feature = "retry")]
74    initial_backoff: Duration,
75    #[cfg(feature = "retry")]
76    max_backoff: Duration,
77    default_headers: reqwest::header::HeaderMap,
78    user_middleware: Vec<Arc<dyn Middleware>>,
79    #[cfg(feature = "circuit-breaker")]
80    breaker: Option<breaker::CircuitBreaker>,
81}
82
83impl Default for ClientBuilder {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89impl ClientBuilder {
90    /// Create a new builder with defaults (30s timeout, 3 retries, exponential backoff).
91    pub fn new() -> Self {
92        Self {
93            reqwest_builder: reqwest::Client::builder().timeout(Duration::from_secs(30)),
94            base_url: None,
95            #[cfg(feature = "retry")]
96            retries: 3,
97            #[cfg(feature = "retry")]
98            initial_backoff: Duration::from_millis(500),
99            #[cfg(feature = "retry")]
100            max_backoff: Duration::from_secs(30),
101            default_headers: reqwest::header::HeaderMap::new(),
102            user_middleware: Vec::new(),
103            #[cfg(feature = "circuit-breaker")]
104            breaker: None,
105        }
106    }
107
108    /// Set a base URL that is prepended to all relative paths.
109    pub fn base_url(mut self, url: impl Into<String>) -> Self {
110        self.base_url = Some(url.into());
111        self
112    }
113
114    /// Set the request timeout.
115    pub fn timeout(mut self, timeout: Duration) -> Self {
116        self.reqwest_builder = self.reqwest_builder.timeout(timeout);
117        self
118    }
119
120    /// Set the maximum number of retries.
121    ///
122    /// Shapes the built-in [`middleware::RetryMiddleware`] (feature
123    /// `retry`, default-on). For custom retry behavior, register a
124    /// [`middleware::RetryMiddleware`] with a full
125    /// [`middleware::RetryConfig`] via [`ClientBuilder::with_middleware`].
126    #[cfg(feature = "retry")]
127    pub fn retries(mut self, retries: u32) -> Self {
128        self.retries = retries;
129        self
130    }
131
132    /// Set the initial and maximum retry backoff durations.
133    #[cfg(feature = "retry")]
134    pub fn retry_bounds(mut self, initial: Duration, max: Duration) -> Self {
135        self.initial_backoff = initial;
136        self.max_backoff = max;
137        self
138    }
139
140    /// Register custom middleware to run **outermost** — wrapping the
141    /// built-in retry/breaker stack — observing each logical request once.
142    ///
143    /// Middleware registered first runs outermost; see the
144    /// [`middleware`] module docs for the full ordering semantics.
145    pub fn with_middleware(mut self, middleware: impl Middleware) -> Self {
146        self.user_middleware.push(Arc::new(middleware));
147        self
148    }
149
150    /// Add a default header applied to every request (infallible for
151    /// compile-time string literals).
152    ///
153    /// # Panics
154    /// Panics if `key` or `value` is not a valid HTTP header string —
155    /// this is a caller programming error, not a runtime condition.
156    /// Use [`default_headers`](Self::default_headers) with a `HeaderMap`
157    /// for a fallible variant.
158    #[allow(clippy::expect_used)]
159    pub fn default_header(mut self, key: &'static str, value: &'static str) -> Self {
160        let name = http::header::HeaderName::from_static(key);
161        let val = http::HeaderValue::from_str(value).expect("invalid header value");
162        self.default_headers.insert(name, val);
163        self
164    }
165
166    /// Set multiple default headers applied to every request.
167    pub fn default_headers(mut self, headers: reqwest::header::HeaderMap) -> Self {
168        for (key, value) in headers {
169            if let Some(key) = key {
170                self.default_headers.insert(key, value);
171            }
172        }
173        self
174    }
175
176    /// Set the `User-Agent` header.
177    pub fn user_agent(mut self, agent: &'static str) -> Self {
178        self.reqwest_builder = self.reqwest_builder.user_agent(agent);
179        self
180    }
181
182    /// Add a raw `reqwest::ClientBuilder` for advanced configuration.
183    pub fn reqwest_builder(mut self, builder: reqwest::ClientBuilder) -> Self {
184        self.reqwest_builder = builder;
185        self
186    }
187
188    /// Attach a circuit breaker to the client (requires the `circuit-breaker` feature).
189    #[cfg(feature = "circuit-breaker")]
190    pub fn with_breaker(mut self, breaker: breaker::CircuitBreaker) -> Self {
191        self.breaker = Some(breaker);
192        self
193    }
194
195    /// Build the [`Client`].
196    ///
197    /// # Panics
198    ///
199    /// Panics if the underlying `reqwest::Client` cannot be built. This should
200    /// only happen due to process-startup defects (e.g. invalid TLS config),
201    /// not from user input at runtime.
202    #[allow(clippy::expect_used)]
203    pub fn build(self) -> Client {
204        self.try_build()
205            .expect("failed to build reqwest client — this is a process-startup defect")
206    }
207
208    /// Build the [`Client`], returning a [`FetchError`] on failure.
209    pub fn try_build(self) -> Result<Client, FetchError> {
210        let reqwest_client = self
211            .reqwest_builder
212            // Builder-level default headers must actually reach the wire:
213            // they apply to every request, with per-request headers taking
214            // precedence on conflict.
215            .default_headers(self.default_headers)
216            .build()
217            .map_err(|e| FetchError::BuildError(e.to_string()))?;
218
219        // Composition order (first = outermost):
220        //   [user middleware…] → retry → breaker → transport.
221        // The breaker sits inside the retry loop, so it records every
222        // HTTP attempt individually; user middleware wrap the whole
223        // built-in stack and observe only final outcomes.
224        let mut chain = ChainBuilder::new(reqwest_client);
225        for middleware in self.user_middleware {
226            chain = chain.with_middleware(middleware);
227        }
228
229        #[cfg(feature = "retry")]
230        let chain = {
231            use loop_retry::RetryConfig;
232            let config = RetryConfig {
233                max_retries: self.retries,
234                initial_delay: self.initial_backoff,
235                max_delay: self.max_backoff,
236                ..RetryConfig::default()
237            };
238            chain.with(middleware::RetryMiddleware::new(config))
239        };
240
241        #[cfg(feature = "circuit-breaker")]
242        let chain = match self.breaker {
243            Some(breaker) => chain.with(middleware::CircuitBreakerMiddleware::new(breaker)),
244            None => chain,
245        };
246
247        Ok(Client {
248            inner: chain.build(),
249            base_url: self.base_url,
250        })
251    }
252}
253
254impl Client {
255    /// Create a new client with default settings.
256    pub fn new() -> Self {
257        ClientBuilder::new().build()
258    }
259
260    /// Start building a client with custom settings.
261    pub fn builder() -> ClientBuilder {
262        ClientBuilder::new()
263    }
264
265    /// Construct a [`Client`] from a pre-built [`ClientWithMiddleware`] and
266    /// an optional base URL.
267    ///
268    /// This is useful when you need to attach custom middleware (e.g. a
269    /// circuit breaker from an external registry) that fetch_kit's builder
270    /// does not natively support. Use [`Client::with_middleware`] to append
271    /// further middleware to the chain after construction.
272    pub fn from_parts(inner: ClientWithMiddleware, base_url: Option<String>) -> Self {
273        Self { inner, base_url }
274    }
275
276    /// Append a middleware **inside** the existing chain (closest to the
277    /// transport) and return the new client.
278    ///
279    /// This is a composition point for already-built clients. When
280    /// building from scratch, prefer [`ClientBuilder::with_middleware`],
281    /// which registers middleware outermost.
282    pub fn with_middleware(self, middleware: impl Middleware) -> Self {
283        Self {
284            inner: self.inner.with_middleware(Arc::new(middleware)),
285            base_url: self.base_url,
286        }
287    }
288
289    /// Resolve a path against the optional base URL.
290    pub(crate) fn resolve_url(&self, url: &str) -> String {
291        match &self.base_url {
292            Some(base) => {
293                let base = base.trim_end_matches('/');
294                let path = url.trim_start_matches('/');
295                format!("{base}/{path}")
296            }
297            None => url.to_string(),
298        }
299    }
300
301    /// Create a [`RequestBuilder`] for a GET request.
302    pub fn get(&self, url: impl Into<String>) -> RequestBuilder<'_> {
303        let url = self.resolve_url(&url.into());
304        RequestBuilder {
305            client: self,
306            builder: self.inner.get(&url),
307        }
308    }
309
310    /// Create a [`RequestBuilder`] for a POST request.
311    pub fn post(&self, url: impl Into<String>) -> RequestBuilder<'_> {
312        let url = self.resolve_url(&url.into());
313        RequestBuilder {
314            client: self,
315            builder: self.inner.post(&url),
316        }
317    }
318
319    /// Create a [`RequestBuilder`] for a PUT request.
320    pub fn put(&self, url: impl Into<String>) -> RequestBuilder<'_> {
321        let url = self.resolve_url(&url.into());
322        RequestBuilder {
323            client: self,
324            builder: self.inner.put(&url),
325        }
326    }
327
328    /// Create a [`RequestBuilder`] for a DELETE request.
329    pub fn delete(&self, url: impl Into<String>) -> RequestBuilder<'_> {
330        let url = self.resolve_url(&url.into());
331        RequestBuilder {
332            client: self,
333            builder: self.inner.delete(&url),
334        }
335    }
336
337    /// Perform a GET request and deserialize the JSON response (requires
338    /// the `json` feature).
339    #[cfg(feature = "json")]
340    pub async fn get_json<T: DeserializeOwned>(&self, url: &str) -> Result<T, FetchError> {
341        let url = self.resolve_url(url);
342        let response = self.inner.get(&url).send().await?;
343        let response = Self::check_status(response).await?;
344        Ok(response.json().await?)
345    }
346
347    /// Perform a POST request with a JSON body and deserialize the JSON response (requires
348    /// the `json` feature).
349    #[cfg(feature = "json")]
350    pub async fn post_json<B: Serialize, T: DeserializeOwned>(
351        &self,
352        url: &str,
353        body: &B,
354    ) -> Result<T, FetchError> {
355        let url = self.resolve_url(url);
356        let response = self.inner.post(&url).json(body).send().await?;
357        let response = Self::check_status(response).await?;
358        Ok(response.json().await?)
359    }
360
361    /// Fetch from `primary_url`; on failure, fall back to `fallback_url` (requires
362    /// the `json` feature).
363    #[cfg(feature = "json")]
364    pub async fn fetch_with_fallback<T: DeserializeOwned>(
365        &self,
366        primary_url: &str,
367        fallback_url: &str,
368    ) -> Result<T, FetchError> {
369        match self.get_json(primary_url).await {
370            Ok(value) => Ok(value),
371            Err(_) => self.get_json(fallback_url).await,
372        }
373    }
374
375    /// Access the inner [`middleware::ClientWithMiddleware`].
376    ///
377    /// The returned client shares this client's connection pool and
378    /// middleware chain; use [`middleware::ClientWithMiddleware::with_middleware`]
379    /// (or [`Client::with_middleware`]) to compose further middleware onto it.
380    pub fn inner(&self) -> &ClientWithMiddleware {
381        &self.inner
382    }
383
384    async fn check_status(response: Response) -> Result<Response, FetchError> {
385        let status = response.status();
386        if status.is_success() {
387            Ok(response)
388        } else {
389            let code = status.as_u16();
390            let body = response.text().await.unwrap_or_default();
391            Err(FetchError::StatusCode { status: code, body })
392        }
393    }
394}
395
396impl Default for Client {
397    fn default() -> Self {
398        Self::new()
399    }
400}
401
402/// A wrapper around [`middleware::RequestBuilder`] that provides an
403/// ergonomic API and resolves the URL against the client's base URL.
404pub struct RequestBuilder<'a> {
405    #[allow(dead_code)]
406    client: &'a Client,
407    builder: middleware::RequestBuilder,
408}
409
410impl<'a> RequestBuilder<'a> {
411    /// Attach request-scoped typed data, carried through every middleware
412    /// in the chain's shared extension map (and surviving across retry
413    /// attempts). Middleware read it with `extensions.get::<T>()`.
414    pub fn with_extension<T: Clone + Send + Sync + 'static>(mut self, value: T) -> Self {
415        self.builder = self.builder.with_extension(value);
416        self
417    }
418
419    /// Add a single header.
420    pub fn header<K, V>(mut self, key: K, value: V) -> Self
421    where
422        http::header::HeaderName: TryFrom<K>,
423        <http::header::HeaderName as TryFrom<K>>::Error: Into<http::Error>,
424        http::HeaderValue: TryFrom<V>,
425        <http::HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
426    {
427        self.builder = self.builder.header(key, value);
428        self
429    }
430
431    /// Set all headers from a `HeaderMap`.
432    pub fn headers(mut self, headers: reqwest::header::HeaderMap) -> Self {
433        self.builder = self.builder.headers(headers);
434        self
435    }
436
437    /// Append query parameters.
438    pub fn query<T: Serialize + ?Sized>(mut self, params: &T) -> Self {
439        self.builder = self.builder.query(params);
440        self
441    }
442
443    /// Override the request timeout.
444    pub fn timeout(mut self, timeout: Duration) -> Self {
445        self.builder = self.builder.timeout(timeout);
446        self
447    }
448
449    /// Set a JSON body (requires the `json` feature).
450    #[cfg(feature = "json")]
451    pub fn json<T: Serialize + ?Sized>(mut self, body: &T) -> Self {
452        self.builder = self.builder.json(body);
453        self
454    }
455
456    /// Set a `Bearer` authorization token.
457    pub fn bearer_auth(mut self, token: impl std::fmt::Display) -> Self {
458        self.builder = self.builder.bearer_auth(token);
459        self
460    }
461
462    /// Set HTTP basic auth credentials.
463    pub fn basic_auth(
464        mut self,
465        username: impl std::fmt::Display,
466        password: Option<impl std::fmt::Display>,
467    ) -> Self {
468        self.builder = self.builder.basic_auth(username, password);
469        self
470    }
471
472    /// Set the request body directly.
473    pub fn body(mut self, body: impl Into<reqwest::Body>) -> Self {
474        self.builder = self.builder.body(body);
475        self
476    }
477
478    /// Set form-encoded body from a serializable value.
479    pub fn form<T: Serialize + ?Sized>(mut self, form: &T) -> Self {
480        self.builder = self.builder.form(form);
481        self
482    }
483
484    /// Set a multipart form body (requires the `multipart` feature).
485    #[cfg(feature = "multipart")]
486    pub fn multipart(mut self, form: reqwest::multipart::Form) -> Self {
487        self.builder = self.builder.multipart(form);
488        self
489    }
490
491    /// Send the request.
492    pub async fn send(self) -> Result<Response, FetchError> {
493        let response = self.builder.send().await?;
494        Client::check_status(response).await
495    }
496
497    /// Send the request and deserialize the JSON response (requires the
498    /// `json` feature).
499    #[cfg(feature = "json")]
500    pub async fn json_response<T: DeserializeOwned>(self) -> Result<T, FetchError> {
501        let response = self.send().await?;
502        Ok(response.json().await?)
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    #![allow(clippy::unwrap_used, clippy::expect_used)] // test assertions unwrap by design
509    use super::*;
510    use std::time::Duration;
511
512    #[test]
513    #[cfg(feature = "retry")]
514    fn client_builder_default_timeout() {
515        let builder = ClientBuilder::new();
516        assert_eq!(builder.retries, 3);
517    }
518
519    #[test]
520    #[cfg(feature = "retry")]
521    fn client_builder_default_retries() {
522        let builder = ClientBuilder::new();
523        assert_eq!(builder.retries, 3);
524    }
525
526    #[test]
527    fn client_builder_default_no_base_url() {
528        let builder = ClientBuilder::new();
529        assert!(builder.base_url.is_none());
530    }
531
532    #[test]
533    fn client_builder_custom_timeout() {
534        let builder = ClientBuilder::new().timeout(Duration::from_secs(10));
535        let client = builder.build();
536        let _ = client.inner();
537    }
538
539    #[test]
540    #[cfg(feature = "retry")]
541    fn client_builder_custom_retries() {
542        let builder = ClientBuilder::new().retries(5);
543        assert_eq!(builder.retries, 5);
544    }
545
546    #[test]
547    fn client_builder_custom_base_url() {
548        let builder = ClientBuilder::new().base_url("https://api.example.com");
549        let client = builder.build();
550        assert!(client.base_url.is_some());
551        assert_eq!(client.base_url.as_deref(), Some("https://api.example.com"));
552    }
553
554    #[test]
555    #[cfg(feature = "retry")]
556    fn client_builder_chaining() {
557        let client = ClientBuilder::new()
558            .base_url("https://api.example.com")
559            .timeout(Duration::from_secs(60))
560            .retries(10)
561            .build();
562        assert_eq!(client.base_url.as_deref(), Some("https://api.example.com"));
563    }
564
565    #[test]
566    fn fetch_error_network_display() {
567        let err = FetchError::Network("connection refused".into());
568        let msg = err.to_string();
569        assert!(msg.contains("connection refused"));
570    }
571
572    #[test]
573    fn fetch_error_timeout_display() {
574        let err = FetchError::Timeout(Duration::from_secs(5));
575        let msg = err.to_string();
576        assert!(msg.contains("5s"));
577    }
578
579    #[test]
580    fn fetch_error_status_code_display() {
581        let err = FetchError::StatusCode {
582            status: 404,
583            body: "not found".into(),
584        };
585        let msg = err.to_string();
586        assert!(msg.contains("404"));
587        assert!(msg.contains("not found"));
588    }
589
590    #[test]
591    fn fetch_error_serialization_display() {
592        let json_err = serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
593        let err = FetchError::Serialization(json_err);
594        let msg = err.to_string();
595        assert!(msg.contains("serialization error"));
596    }
597
598    #[test]
599    fn fetch_error_circuit_open_display() {
600        let err = FetchError::CircuitOpen;
601        let msg = err.to_string();
602        assert!(msg.contains("circuit breaker"));
603    }
604
605    #[test]
606    fn fetch_error_middleware_display() {
607        let err = FetchError::Middleware("tower error".into());
608        let msg = err.to_string();
609        assert!(msg.contains("tower error"));
610    }
611
612    #[test]
613    fn url_resolution_base_with_relative_path() {
614        let client = Client::new();
615        let client = Client {
616            base_url: Some("https://api.example.com".into()),
617            ..client
618        };
619        let resolved = client.resolve_url("/users/1");
620        assert_eq!(resolved, "https://api.example.com/users/1");
621    }
622
623    #[test]
624    fn url_resolution_no_base_url() {
625        let client = Client::new();
626        let resolved = client.resolve_url("/users/1");
627        assert_eq!(resolved, "/users/1");
628    }
629
630    #[test]
631    fn url_resolution_base_trailing_slash() {
632        let client = Client {
633            base_url: Some("https://api.example.com/".into()),
634            inner: Client::new().inner,
635        };
636        let resolved = client.resolve_url("/users/1");
637        assert_eq!(resolved, "https://api.example.com/users/1");
638    }
639
640    #[test]
641    fn url_resolution_path_no_leading_slash() {
642        let client = Client {
643            base_url: Some("https://api.example.com".into()),
644            inner: Client::new().inner,
645        };
646        let resolved = client.resolve_url("users/1");
647        assert_eq!(resolved, "https://api.example.com/users/1");
648    }
649
650    #[test]
651    fn url_resolution_empty_base_trailing_slash() {
652        let client = Client {
653            base_url: Some("https://api.example.com/".into()),
654            inner: Client::new().inner,
655        };
656        let resolved = client.resolve_url("users/1");
657        assert_eq!(resolved, "https://api.example.com/users/1");
658    }
659
660    #[test]
661    fn client_default_trait() {
662        let client = Client::default();
663        assert!(client.base_url.is_none());
664    }
665
666    #[test]
667    #[cfg(feature = "retry")]
668    fn client_builder_default_trait() {
669        let builder = ClientBuilder::default();
670        assert_eq!(builder.retries, 3);
671    }
672
673    // ---- Additional ClientBuilder tests ----
674
675    #[test]
676    #[cfg(feature = "retry")]
677    fn client_builder_new_creates_valid_builder() {
678        let builder = ClientBuilder::new();
679        assert_eq!(builder.retries, 3);
680        assert!(builder.base_url.is_none());
681        let client = builder.build();
682        assert!(client.base_url.is_none());
683    }
684
685    #[test]
686    fn client_builder_base_url_strips_trailing_slash_on_build() {
687        let client = ClientBuilder::new()
688            .base_url("https://api.example.com/")
689            .build();
690        let resolved = client.resolve_url("/users/1");
691        assert_eq!(resolved, "https://api.example.com/users/1");
692    }
693
694    #[test]
695    fn client_builder_timeout_zero() {
696        let client = ClientBuilder::new().timeout(Duration::from_secs(0)).build();
697        let _ = client.inner();
698    }
699
700    #[test]
701    fn client_builder_timeout_large() {
702        let client = ClientBuilder::new()
703            .timeout(Duration::from_secs(3600))
704            .build();
705        let _ = client.inner();
706    }
707
708    #[test]
709    #[cfg(feature = "retry")]
710    fn client_builder_retries_zero() {
711        let builder = ClientBuilder::new().retries(0);
712        assert_eq!(builder.retries, 0);
713    }
714
715    #[test]
716    #[cfg(feature = "retry")]
717    fn client_builder_retries_large() {
718        let builder = ClientBuilder::new().retries(1000);
719        assert_eq!(builder.retries, 1000);
720    }
721
722    #[test]
723    #[cfg(feature = "retry")]
724    fn client_builder_overwrite_retries() {
725        let builder = ClientBuilder::new().retries(5).retries(10);
726        assert_eq!(builder.retries, 10);
727    }
728
729    #[test]
730    fn client_builder_overwrite_timeout() {
731        let client = ClientBuilder::new()
732            .timeout(Duration::from_secs(10))
733            .timeout(Duration::from_secs(60))
734            .build();
735        let _ = client.inner();
736    }
737
738    #[test]
739    #[cfg(feature = "retry")]
740    fn client_builder_full_chain() {
741        let client = ClientBuilder::new()
742            .base_url("https://api.example.com")
743            .timeout(Duration::from_secs(15))
744            .retries(7)
745            .build();
746        assert_eq!(client.base_url.as_deref(), Some("https://api.example.com"));
747    }
748
749    // ---- Additional Client tests ----
750
751    #[test]
752    fn client_new_creates_default() {
753        let client = Client::new();
754        assert!(client.base_url.is_none());
755        let _ = client.inner();
756    }
757
758    #[test]
759    fn client_builder_factory_method() {
760        let client = Client::builder().base_url("https://example.com").build();
761        assert_eq!(client.base_url.as_deref(), Some("https://example.com"));
762    }
763
764    #[test]
765    fn client_builder_factory_default() {
766        let client = Client::builder().build();
767        assert!(client.base_url.is_none());
768    }
769
770    // ---- Additional URL resolution tests ----
771
772    #[test]
773    fn url_resolution_empty_path_with_base() {
774        let client = Client {
775            base_url: Some("https://api.example.com".into()),
776            inner: Client::new().inner,
777        };
778        let resolved = client.resolve_url("");
779        assert_eq!(resolved, "https://api.example.com/");
780    }
781
782    #[test]
783    fn url_resolution_base_multiple_trailing_slashes() {
784        let client = Client {
785            base_url: Some("https://api.example.com///".into()),
786            inner: Client::new().inner,
787        };
788        let resolved = client.resolve_url("/users/1");
789        assert_eq!(resolved, "https://api.example.com/users/1");
790    }
791
792    #[test]
793    fn url_resolution_full_url_with_base() {
794        let client = Client {
795            base_url: Some("https://api.example.com".into()),
796            inner: Client::new().inner,
797        };
798        let resolved = client.resolve_url("users/1");
799        assert_eq!(resolved, "https://api.example.com/users/1");
800    }
801
802    #[test]
803    fn url_resolution_deeply_nested_path() {
804        let client = Client {
805            base_url: Some("https://api.example.com".into()),
806            inner: Client::new().inner,
807        };
808        let resolved = client.resolve_url("/v1/users/42/posts/7/comments");
809        assert_eq!(
810            resolved,
811            "https://api.example.com/v1/users/42/posts/7/comments"
812        );
813    }
814
815    // ---- Additional FetchError display tests ----
816
817    #[test]
818    fn fetch_error_network_empty_message() {
819        let err = FetchError::Network("".into());
820        let msg = err.to_string();
821        assert!(msg.contains("network error"));
822    }
823
824    #[test]
825    fn fetch_error_network_long_message() {
826        let err = FetchError::Network("DNS resolution failed for api.example.com: NXDOMAIN".into());
827        let msg = err.to_string();
828        assert!(msg.contains("DNS resolution failed"));
829        assert!(msg.contains("NXDOMAIN"));
830    }
831
832    #[test]
833    fn fetch_error_timeout_zero_duration() {
834        let err = FetchError::Timeout(Duration::from_secs(0));
835        let msg = err.to_string();
836        assert!(msg.contains("request timed out"));
837        assert!(msg.contains("0ns"));
838    }
839
840    #[test]
841    fn fetch_error_timeout_large_duration() {
842        let err = FetchError::Timeout(Duration::from_secs(3600));
843        let msg = err.to_string();
844        assert!(msg.contains("request timed out"));
845        assert!(msg.contains("3600s"));
846    }
847
848    #[test]
849    fn fetch_error_status_code_various_codes() {
850        for code in [400, 401, 403, 404, 429, 500, 502, 503] {
851            let err = FetchError::StatusCode {
852                status: code,
853                body: format!("error {code}"),
854            };
855            let msg = err.to_string();
856            assert!(msg.contains(&code.to_string()));
857            assert!(msg.contains("HTTP"));
858        }
859    }
860
861    #[test]
862    fn fetch_error_status_code_empty_body() {
863        let err = FetchError::StatusCode {
864            status: 404,
865            body: String::new(),
866        };
867        let msg = err.to_string();
868        assert!(msg.contains("404"));
869    }
870
871    #[test]
872    fn fetch_error_serialization_various_errors() {
873        let json_err = serde_json::from_str::<serde_json::Value>("").unwrap_err();
874        let err = FetchError::Serialization(json_err);
875        let msg = err.to_string();
876        assert!(msg.contains("serialization error"));
877    }
878
879    #[test]
880    fn fetch_error_debug_format() {
881        let errors = vec![
882            FetchError::Network("test".into()),
883            FetchError::Timeout(Duration::from_secs(1)),
884            FetchError::StatusCode {
885                status: 500,
886                body: "err".into(),
887            },
888            FetchError::CircuitOpen,
889            FetchError::Middleware("mw".into()),
890        ];
891        for err in errors {
892            let debug_str = format!("{:?}", err);
893            assert!(!debug_str.is_empty());
894        }
895    }
896
897    #[test]
898    fn fetch_error_is_std_error() {
899        let err = FetchError::Network("test".into());
900        let std_err: &dyn std::error::Error = &err;
901        assert!(std_err.to_string().contains("network error"));
902    }
903
904    #[test]
905    fn fetch_error_from_serde_json_roundtrip() {
906        let json_err = serde_json::from_str::<serde_json::Value>("invalid json!!").unwrap_err();
907        let msg = json_err.to_string();
908        let fetch_err = FetchError::from(json_err);
909        let fetch_msg = fetch_err.to_string();
910        assert!(fetch_msg.contains("serialization error"));
911        assert!(fetch_msg.contains(&msg));
912    }
913
914    // ---- Client clone test ----
915
916    #[test]
917    fn client_clone_preserves_base_url() {
918        let client = Client::builder()
919            .base_url("https://api.example.com")
920            .build();
921        let cloned = client.clone();
922        assert_eq!(cloned.base_url.as_deref(), Some("https://api.example.com"));
923    }
924
925    #[test]
926    fn client_clone_independence() {
927        let client1 = Client::builder().base_url("https://first.com").build();
928        let client2 = client1.clone();
929        assert_eq!(client2.base_url.as_deref(), Some("https://first.com"));
930    }
931
932    // ---- New builder method tests ----
933
934    #[test]
935    fn client_builder_default_header() {
936        let client = ClientBuilder::new()
937            .default_header("x-custom", "value")
938            .build();
939        let _ = client.inner();
940    }
941
942    #[test]
943    fn client_builder_user_agent() {
944        let client = ClientBuilder::new().user_agent("fetch_kit/0.1").build();
945        let _ = client.inner();
946    }
947
948    #[test]
949    #[cfg(feature = "retry")]
950    fn client_builder_retry_bounds() {
951        let builder =
952            ClientBuilder::new().retry_bounds(Duration::from_millis(100), Duration::from_secs(5));
953        assert_eq!(builder.retries, 3);
954        let client = builder.build();
955        let _ = client.inner();
956    }
957
958    #[test]
959    fn client_builder_try_build_success() {
960        let result = ClientBuilder::new().try_build();
961        assert!(result.is_ok());
962    }
963
964    #[test]
965    fn fetch_error_build_error_display() {
966        let err = FetchError::BuildError("tls error".into());
967        let msg = err.to_string();
968        assert!(msg.contains("build error"));
969        assert!(msg.contains("tls error"));
970    }
971
972    #[test]
973    fn fetch_error_header_error_display() {
974        let err = FetchError::HeaderError("invalid header".into());
975        let msg = err.to_string();
976        assert!(msg.contains("header error"));
977        assert!(msg.contains("invalid header"));
978    }
979
980    #[test]
981    fn client_get_returns_request_builder() {
982        let client = Client::new();
983        let builder = client.get("https://example.com");
984        let _ = builder;
985    }
986
987    #[test]
988    fn client_post_returns_request_builder() {
989        let client = Client::new();
990        let builder = client.post("https://example.com");
991        let _ = builder;
992    }
993
994    #[test]
995    fn client_put_returns_request_builder() {
996        let client = Client::new();
997        let builder = client.put("https://example.com");
998        let _ = builder;
999    }
1000
1001    #[test]
1002    fn client_delete_returns_request_builder() {
1003        let client = Client::new();
1004        let builder = client.delete("https://example.com");
1005        let _ = builder;
1006    }
1007
1008    #[test]
1009    fn request_builder_header_chain() {
1010        let client = Client::new();
1011        let builder = client
1012            .get("https://example.com")
1013            .header("X-Test", "value")
1014            .bearer_auth("token");
1015        let _ = builder;
1016    }
1017
1018    #[test]
1019    fn request_builder_query_chain() {
1020        let client = Client::new();
1021        let builder = client.get("https://example.com").query(&[("key", "value")]);
1022        let _ = builder;
1023    }
1024
1025    #[test]
1026    fn request_builder_timeout_chain() {
1027        let client = Client::new();
1028        let builder = client
1029            .get("https://example.com")
1030            .timeout(Duration::from_secs(5));
1031        let _ = builder;
1032    }
1033
1034    #[test]
1035    fn fetch_error_from_reqwest_timeout_uses_zero_duration() {
1036        let err = FetchError::Timeout(Duration::ZERO);
1037        assert!(err.to_string().contains("request timed out"));
1038        assert!(err.to_string().contains("0"));
1039    }
1040}