Skip to main content

switchy_http/
lib.rs

1//! Generic HTTP client abstraction layer.
2//!
3//! This crate provides a unified interface for making HTTP requests across different backend
4//! implementations. It defines generic traits for HTTP clients, request builders, and responses,
5//! allowing you to write code that works with multiple HTTP client libraries.
6//!
7//! # Features
8//!
9//! * `reqwest` - Enable the reqwest HTTP client backend (real network requests)
10//! * `simulator` - Enable the simulator backend (no-op client for testing)
11//! * `json` - Enable JSON serialization/deserialization support
12//! * `stream` - Enable streaming response bodies
13//!
14//! # Backends
15//!
16//! The crate supports multiple HTTP client backends through feature flags:
17//!
18//! * **reqwest** - Production HTTP client using the `reqwest` crate
19//! * **simulator** - No-op client that returns empty responses, useful for testing
20//!
21//! # Examples
22//!
23//! Basic usage with the reqwest backend:
24//!
25//! ```rust,no_run
26//! # #[cfg(feature = "reqwest")]
27//! # {
28//! use switchy_http::{GenericClient, GenericRequestBuilder, GenericResponse};
29//!
30//! # async fn example() -> Result<(), switchy_http::Error> {
31//! let client = switchy_http::Client::new();
32//! let mut response = client.get("https://api.example.com/data").send().await?;
33//! let text = response.text().await?;
34//! # Ok(())
35//! # }
36//! # }
37//! ```
38//!
39//! With JSON support:
40//!
41//! ```rust,no_run
42//! # #[cfg(all(feature = "reqwest", feature = "json"))]
43//! # {
44//! use switchy_http::{GenericClient, GenericRequestBuilder};
45//! use serde::Deserialize;
46//!
47//! #[derive(Deserialize)]
48//! struct ApiResponse {
49//!     message: String,
50//! }
51//!
52//! # async fn example() -> Result<(), switchy_http::Error> {
53//! let client = switchy_http::Client::new();
54//! let response = client.get("https://api.example.com/data").send().await?;
55//! let data: ApiResponse = response.json().await?;
56//! # Ok(())
57//! # }
58//! # }
59//! ```
60#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
61#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
62#![allow(clippy::multiple_crate_versions)]
63
64use std::{collections::BTreeMap, marker::PhantomData};
65
66use async_trait::async_trait;
67use bytes::Bytes;
68use strum::{AsRefStr, EnumString};
69use switchy_http_models::{Method, StatusCode};
70use thiserror::Error;
71
72/// Re-exported HTTP models and types from `switchy_http_models`.
73///
74/// This module provides common HTTP types including [`models::Method`] and
75/// [`models::StatusCode`] that work across different HTTP libraries.
76///
77/// See the [`switchy_http_models`] crate documentation for full details.
78pub use switchy_http_models as models;
79
80#[cfg(feature = "reqwest")]
81pub mod reqwest;
82
83#[cfg(feature = "simulator")]
84pub mod simulator;
85
86/// Errors that can occur when making HTTP requests.
87#[derive(Debug, Error)]
88pub enum Error {
89    /// Failed to decode response data.
90    #[error("Decode")]
91    Decode,
92
93    /// JSON deserialization error (requires `json` feature).
94    #[cfg(feature = "json")]
95    #[error(transparent)]
96    Deserialize(#[from] serde_json::Error),
97
98    /// Reqwest HTTP client error (requires `reqwest` feature).
99    #[cfg(feature = "reqwest")]
100    #[error(transparent)]
101    Reqwest(#[from] ::reqwest::Error),
102}
103
104/// Common HTTP header names.
105#[derive(Debug, Clone, Copy, EnumString, AsRefStr)]
106#[strum(serialize_all = "kebab-case")]
107pub enum Header {
108    /// HTTP `Authorization` header.
109    Authorization,
110    /// HTTP `User-Agent` header.
111    UserAgent,
112    /// HTTP `Range` header.
113    Range,
114    /// HTTP `Content-Length` header.
115    ContentLength,
116}
117
118/// Generic trait for building and configuring HTTP requests.
119#[async_trait]
120pub trait GenericRequestBuilder<R>: Send + Sync {
121    /// Add a header to the request.
122    fn header(&mut self, name: &str, value: &str);
123    /// Add a query parameter to the request.
124    fn query_param(&mut self, name: &str, value: &str);
125    /// Add an optional query parameter to the request.
126    fn query_param_opt(&mut self, name: &str, value: Option<&str>);
127    /// Add multiple query parameters to the request.
128    fn query_params(&mut self, params: &[(&str, &str)]);
129    /// Set the request body.
130    #[allow(unused)]
131    fn body(&mut self, body: Bytes);
132    /// Set the request body as a form (requires `json` feature).
133    #[cfg(feature = "json")]
134    fn form(&mut self, form: &serde_json::Value);
135    /// Send the HTTP request.
136    ///
137    /// # Errors
138    ///
139    /// * If the request fails to send
140    async fn send(&mut self) -> Result<R, Error>;
141}
142
143/// Generic trait for building HTTP clients.
144pub trait GenericClientBuilder<RB, C: GenericClient<RB>>: Send + Sync {
145    /// Build the HTTP client.
146    ///
147    /// # Errors
148    ///
149    /// * If the `Client` fails to build
150    fn build(self) -> Result<C, Error>;
151}
152
153/// Generic trait for HTTP clients.
154pub trait GenericClient<RB>: Send + Sync {
155    /// Create a GET request builder.
156    #[must_use]
157    fn get(&self, url: &str) -> RB {
158        self.request(Method::Get, url)
159    }
160
161    /// Create a POST request builder.
162    #[must_use]
163    fn post(&self, url: &str) -> RB {
164        self.request(Method::Post, url)
165    }
166
167    /// Create a PUT request builder.
168    #[must_use]
169    fn put(&self, url: &str) -> RB {
170        self.request(Method::Put, url)
171    }
172
173    /// Create a PATCH request builder.
174    #[must_use]
175    fn patch(&self, url: &str) -> RB {
176        self.request(Method::Patch, url)
177    }
178
179    /// Create a DELETE request builder.
180    #[must_use]
181    fn delete(&self, url: &str) -> RB {
182        self.request(Method::Delete, url)
183    }
184
185    /// Create a HEAD request builder.
186    #[must_use]
187    fn head(&self, url: &str) -> RB {
188        self.request(Method::Head, url)
189    }
190
191    /// Create an OPTIONS request builder.
192    #[must_use]
193    fn options(&self, url: &str) -> RB {
194        self.request(Method::Options, url)
195    }
196
197    /// Create a request builder with the specified HTTP method.
198    #[must_use]
199    fn request(&self, method: Method, url: &str) -> RB;
200}
201
202/// Generic trait for HTTP responses.
203#[async_trait]
204pub trait GenericResponse: Send + Sync {
205    /// Get the HTTP status code of the response.
206    #[must_use]
207    fn status(&self) -> StatusCode;
208    /// Get the response headers.
209    #[must_use]
210    fn headers(&mut self) -> &BTreeMap<String, String>;
211    /// Get the response body as text.
212    ///
213    /// # Errors
214    ///
215    /// * If the response body cannot be decoded as text
216    async fn text(&mut self) -> Result<String, Error>;
217    /// Get the response body as bytes.
218    ///
219    /// # Errors
220    ///
221    /// * If the response body cannot be read
222    async fn bytes(&mut self) -> Result<Bytes, Error>;
223    /// Get the response body as a stream of bytes (requires `stream` feature).
224    #[cfg(feature = "stream")]
225    fn bytes_stream(
226        &mut self,
227    ) -> std::pin::Pin<Box<dyn futures_core::Stream<Item = Result<Bytes, Error>> + Send>>;
228}
229
230/// Wrapper type for generic request builders.
231///
232/// This wrapper provides a unified interface for building HTTP requests across different
233/// backend implementations. It implements builder pattern methods for configuring requests.
234///
235/// Most users should use the type aliases exported by this crate (`ReqwestRequestBuilder`,
236/// `SimulatorRequestBuilder`, etc.) rather than using this wrapper directly.
237pub struct RequestBuilderWrapper<R, B: GenericRequestBuilder<R>>(
238    pub(crate) B,
239    pub(crate) PhantomData<R>,
240);
241
242/// Wrapper type for generic HTTP clients.
243///
244/// This wrapper provides a unified interface for creating HTTP requests across different
245/// backend implementations. It provides convenience methods for common HTTP methods like
246/// GET, POST, PUT, etc.
247///
248/// Most users should use the type aliases exported by this crate (`ReqwestClient`,
249/// `SimulatorClient`, etc.) rather than using this wrapper directly.
250pub struct ClientWrapper<RB, T: GenericClient<RB>>(pub(crate) T, pub(crate) PhantomData<RB>);
251
252/// Wrapper type for generic client builders.
253///
254/// This wrapper provides a unified interface for building HTTP clients across different
255/// backend implementations. Use the `build()` method to construct a configured client.
256///
257/// Most users should use the type aliases exported by this crate (`ReqwestClientBuilder`,
258/// `SimulatorClientBuilder`, etc.) rather than using this wrapper directly.
259pub struct ClientBuilderWrapper<RB, C: GenericClient<RB>, T: GenericClientBuilder<RB, C>>(
260    pub(crate) T,
261    PhantomData<RB>,
262    PhantomData<C>,
263);
264
265/// Wrapper type for generic HTTP responses.
266///
267/// This wrapper provides a unified interface for accessing HTTP response data across different
268/// backend implementations. It provides methods for reading response status, headers, and body
269/// in various formats (text, bytes, JSON, streams).
270///
271/// Most users should use the type aliases exported by this crate (`ReqwestResponse`,
272/// `SimulatorResponse`, etc.) rather than using this wrapper directly.
273pub struct ResponseWrapper<T: GenericResponse>(pub(crate) T);
274
275#[allow(unused)]
276macro_rules! impl_http {
277    ($module:ident, $local_module:ident $(,)?) => {
278        paste::paste! {
279            pub use [< impl_ $module >]::*;
280        }
281
282        mod $local_module {
283            use crate::*;
284
285            paste::paste! {
286                #[doc = concat!("HTTP response type for the ", stringify!($module), " backend.")]
287                pub type [< $module:camel Response >] = ResponseWrapper<$module::Response>;
288                type ModuleResponse = [< $module:camel Response >];
289
290                #[doc = concat!("Request builder type for the ", stringify!($module), " backend.")]
291                pub type [< $module:camel RequestBuilder >] = RequestBuilderWrapper<ModuleResponse, $module::RequestBuilder>;
292                type ModuleRequestBuilder = [< $module:camel RequestBuilder >];
293
294                #[doc = concat!("HTTP client type for the ", stringify!($module), " backend.")]
295                pub type [< $module:camel Client >] = ClientWrapper<ModuleRequestBuilder, $module::Client>;
296                type ModuleClient = [< $module:camel Client >];
297
298                #[doc = concat!("Client builder type for the ", stringify!($module), " backend.")]
299                pub type [< $module:camel ClientBuilder >] = ClientBuilderWrapper<ModuleRequestBuilder, ModuleClient, $module::ClientBuilder>;
300                type ModuleClientBuilder = [< $module:camel ClientBuilder >];
301            }
302
303            impl ModuleRequestBuilder {
304                /// Add a header to the request.
305                #[must_use]
306                pub fn header(mut self, name: &str, value: &str) -> Self {
307                    self.0.header(name, value);
308                    self
309                }
310
311                /// Add a query parameter to the request.
312                #[must_use]
313                pub fn query_param(mut self, name: &str, value: &str) -> Self {
314                    self.0.query_param(name, value);
315                    self
316                }
317
318                /// Add an optional query parameter to the request.
319                #[must_use]
320                pub fn query_param_opt(mut self, name: &str, value: Option<&str>) -> Self {
321                    self.0.query_param_opt(name, value);
322                    self
323                }
324
325                /// Add multiple query parameters to the request.
326                #[must_use]
327                pub fn query_params(mut self, params: &[(&str, &str)]) -> Self {
328                    self.0.query_params(params);
329                    self
330                }
331
332                /// Set the request body.
333                #[must_use]
334                pub fn body(mut self, body: Bytes) -> Self {
335                    self.0.body(body);
336                    self
337                }
338
339                /// Send the HTTP request.
340                ///
341                /// # Errors
342                ///
343                /// * If there was an error while sending request, redirect loop was
344                ///   detected or redirect limit was exhausted.
345                pub async fn send(mut self) -> Result<ModuleResponse, Error> {
346                    self.0.send().await
347                }
348            }
349
350            #[async_trait]
351            impl GenericRequestBuilder<ModuleResponse> for ModuleRequestBuilder {
352                fn header(&mut self, name: &str, value: &str) {
353                    self.0.header(name, value);
354                }
355
356                fn query_param(&mut self, name: &str, value: &str) {
357                    self.0.query_param(name, value);
358                }
359
360                fn query_param_opt(&mut self, name: &str, value: Option<&str>) {
361                    self.0.query_param_opt(name, value);
362                }
363
364                fn query_params(&mut self, params: &[(&str, &str)]) {
365                    self.0.query_params(params);
366                }
367
368                fn body(&mut self, body: Bytes) {
369                    self.0.body(body);
370                }
371
372                #[cfg(feature = "json")]
373                fn form(&mut self, form: &serde_json::Value) {
374                    self.0.form(form);
375                }
376
377                async fn send(&mut self) -> Result<ModuleResponse, Error> {
378                    self.0.send().await
379                }
380            }
381
382            #[cfg(feature = "json")]
383            impl ModuleRequestBuilder {
384                /// Set the request body as JSON.
385                ///
386                /// # Panics
387                ///
388                /// * If the `serde_json` serialization to bytes fails
389                #[must_use]
390                pub fn json<T: serde::Serialize + ?Sized>(mut self, body: &T) -> Self {
391                    let mut bytes: Vec<u8> = Vec::new();
392                    serde_json::to_writer(&mut bytes, body).unwrap();
393                    <Self as GenericRequestBuilder<ModuleResponse>>::body(&mut self, bytes.into());
394                    self
395                }
396
397                /// Set the request body as form data.
398                ///
399                /// # Panics
400                ///
401                /// * If the `serde_json` serialization to bytes fails
402                #[must_use]
403                pub fn form<T: serde::Serialize + ?Sized>(mut self, form: &T) -> Self {
404                    let value = serde_json::to_value(form).unwrap();
405                    <Self as GenericRequestBuilder<ModuleResponse>>::form(&mut self, &value);
406                    self
407                }
408            }
409
410            #[async_trait]
411            impl GenericResponse for ModuleResponse {
412                fn status(&self) -> StatusCode {
413                    self.0.status()
414                }
415
416                fn headers(&mut self) -> &BTreeMap<String, String> {
417                    self.0.headers()
418                }
419
420                async fn text(&mut self) -> Result<String, Error> {
421                    self.0.text().await
422                }
423
424                async fn bytes(&mut self) -> Result<Bytes, Error> {
425                    self.0.bytes().await
426                }
427
428                #[cfg(feature = "stream")]
429                fn bytes_stream(
430                    &mut self,
431                ) -> std::pin::Pin<Box<dyn futures_core::Stream<Item = Result<Bytes, Error>> + Send>>
432                {
433                    self.0.bytes_stream()
434                }
435            }
436
437            impl ModuleResponse {
438                /// Get the HTTP status code of the response.
439                #[must_use]
440                pub fn status(&self) -> StatusCode {
441                    <Self as GenericResponse>::status(self)
442                }
443
444                /// Get the response headers.
445                #[must_use]
446                pub fn headers(&mut self) -> &BTreeMap<String, String> {
447                    <Self as GenericResponse>::headers(self)
448                }
449
450                /// Get the response body as text.
451                ///
452                /// # Errors
453                ///
454                /// * If the text response fails
455                pub async fn text(mut self) -> Result<String, Error> {
456                    <Self as GenericResponse>::text(&mut self).await
457                }
458
459                /// Get the response body as bytes.
460                ///
461                /// # Errors
462                ///
463                /// * If the bytes response fails
464                pub async fn bytes(mut self) -> Result<Bytes, Error> {
465                    <Self as GenericResponse>::bytes(&mut self).await
466                }
467            }
468
469            impl GenericClientBuilder<ModuleRequestBuilder, ModuleClient> for ModuleClientBuilder {
470                fn build(self) -> Result<ModuleClient, Error> {
471                    self.0.build()
472                }
473            }
474
475            impl ModuleClientBuilder {
476                /// Build the configured HTTP client.
477                ///
478                /// # Errors
479                ///
480                /// * If the `Client` fails to build
481                pub fn build(self) -> Result<ModuleClient, Error> {
482                    <Self as GenericClientBuilder<ModuleRequestBuilder, ModuleClient>>::build(self)
483                }
484            }
485
486            impl ModuleResponse {
487                /// Get the response body as a stream of bytes.
488                ///
489                /// # Errors
490                ///
491                /// * If the `bytes_stream` response fails
492                #[cfg(feature = "stream")]
493                pub fn bytes_stream(
494                    mut self,
495                ) -> impl futures_core::Stream<Item = Result<Bytes, Error>> {
496                    <Self as GenericResponse>::bytes_stream(&mut self)
497                }
498            }
499
500            impl ModuleResponse {
501                /// Deserialize the response body as JSON.
502                ///
503                /// # Errors
504                ///
505                /// * If the json response fails
506                #[cfg(feature = "json")]
507                pub async fn json<T: serde::de::DeserializeOwned>(mut self) -> Result<T, Error> {
508                    let bytes = <Self as GenericResponse>::bytes(&mut self).await?;
509                    Ok(serde_json::from_slice(&bytes)?)
510                }
511            }
512
513            impl Default for ModuleClient {
514                fn default() -> Self {
515                    Self::new()
516                }
517            }
518
519            impl ModuleClient {
520                /// Create a new HTTP client with default configuration.
521                ///
522                /// # Panics
523                ///
524                /// * If the empty `ClientBuilder` somehow fails to build
525                #[must_use]
526                pub fn new() -> Self {
527                    Self::builder().0.build().unwrap()
528                }
529
530                /// Create a new client builder for configuring the HTTP client.
531                #[must_use]
532                pub const fn builder() -> ModuleClientBuilder {
533                    ModuleClientBuilder::new()
534                }
535
536                /// Create a GET request builder for the specified URL.
537                #[must_use]
538                pub fn get(&self, url: &str) -> ModuleRequestBuilder {
539                    <Self as GenericClient<ModuleRequestBuilder>>::get(self, url)
540                }
541
542                /// Create a POST request builder for the specified URL.
543                #[must_use]
544                pub fn post(&self, url: &str) -> ModuleRequestBuilder {
545                    <Self as GenericClient<ModuleRequestBuilder>>::post(self, url)
546                }
547
548                /// Create a PUT request builder for the specified URL.
549                #[must_use]
550                pub fn put(&self, url: &str) -> ModuleRequestBuilder {
551                    <Self as GenericClient<ModuleRequestBuilder>>::put(self, url)
552                }
553
554                /// Create a PATCH request builder for the specified URL.
555                #[must_use]
556                pub fn patch(&self, url: &str) -> ModuleRequestBuilder {
557                    <Self as GenericClient<ModuleRequestBuilder>>::patch(self, url)
558                }
559
560                /// Create a DELETE request builder for the specified URL.
561                #[must_use]
562                pub fn delete(&self, url: &str) -> ModuleRequestBuilder {
563                    <Self as GenericClient<ModuleRequestBuilder>>::delete(self, url)
564                }
565
566                /// Create a HEAD request builder for the specified URL.
567                #[must_use]
568                pub fn head(&self, url: &str) -> ModuleRequestBuilder {
569                    <Self as GenericClient<ModuleRequestBuilder>>::head(self, url)
570                }
571
572                /// Create an OPTIONS request builder for the specified URL.
573                #[must_use]
574                pub fn options(&self, url: &str) -> ModuleRequestBuilder {
575                    <Self as GenericClient<ModuleRequestBuilder>>::options(self, url)
576                }
577
578                /// Create a request builder with the specified HTTP method and URL.
579                #[must_use]
580                pub fn request(&self, method: Method, url: &str) -> ModuleRequestBuilder {
581                    <Self as GenericClient<ModuleRequestBuilder>>::request(self, method, url)
582                }
583            }
584
585            impl Default for ModuleClientBuilder {
586                fn default() -> Self {
587                    Self::new()
588                }
589            }
590
591            impl GenericClient<ModuleRequestBuilder> for ModuleClient {
592                fn request(&self, method: Method, url: &str) -> ModuleRequestBuilder {
593                    self.0.request(method, url)
594                }
595            }
596        }
597    };
598}
599
600#[cfg(feature = "simulator")]
601impl_http!(simulator, impl_simulator);
602
603#[cfg(feature = "reqwest")]
604impl_http!(reqwest, impl_reqwest);
605
606#[allow(unused)]
607macro_rules! impl_gen_types {
608    ($module:ident $(,)?) => {
609        paste::paste! {
610            /// Default request builder type alias for the enabled backend.
611            ///
612            /// This type alias points to the request builder implementation for the currently
613            /// active HTTP backend (simulator or reqwest, depending on enabled features).
614            pub type RequestBuilder = [< $module:camel RequestBuilder >];
615
616            /// Default HTTP client type alias for the enabled backend.
617            ///
618            /// This type alias points to the client implementation for the currently
619            /// active HTTP backend (simulator or reqwest, depending on enabled features).
620            pub type Client = [< $module:camel Client >];
621
622            /// Default HTTP response type alias for the enabled backend.
623            ///
624            /// This type alias points to the response implementation for the currently
625            /// active HTTP backend (simulator or reqwest, depending on enabled features).
626            pub type Response = [< $module:camel Response >];
627        }
628    };
629}
630
631#[cfg(feature = "simulator")]
632impl_gen_types!(simulator);
633
634#[cfg(all(not(feature = "simulator"), feature = "reqwest"))]
635impl_gen_types!(reqwest);
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640
641    #[test_log::test]
642    fn test_header_as_ref_str() {
643        assert_eq!(Header::Authorization.as_ref(), "authorization");
644        assert_eq!(Header::UserAgent.as_ref(), "user-agent");
645        assert_eq!(Header::Range.as_ref(), "range");
646        assert_eq!(Header::ContentLength.as_ref(), "content-length");
647    }
648
649    #[test_log::test]
650    fn test_error_decode_display() {
651        let error = Error::Decode;
652        assert_eq!(error.to_string(), "Decode");
653    }
654
655    #[cfg(feature = "json")]
656    #[test_log::test]
657    fn test_error_deserialize_display() {
658        let json_error = serde_json::from_str::<serde_json::Value>("invalid json");
659        assert!(json_error.is_err());
660        let error = Error::from(json_error.unwrap_err());
661        assert!(error.to_string().contains("expected"));
662    }
663}