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}