tanu_core/
http.rs

1//! # HTTP Client Module
2//!
3//! Tanu's HTTP client provides a wrapper around `reqwest::Client` with enhanced
4//! logging and testing capabilities. It offers the same interface as `reqwest::Client`
5//! while automatically capturing request and response logs for debugging and reporting.
6//!
7//! ## Key Features
8//!
9//! - **Automatic Logging**: Captures all HTTP requests and responses
10//! - **Same API as reqwest**: Drop-in replacement for familiar `reqwest` usage
11//! - **Integration with Assertions**: Works seamlessly with tanu's assertion macros
12//! - **Error Handling**: Enhanced error types with context for better debugging
13//!
14//! ## Basic Usage
15//!
16//! ```rust,ignore
17//! use tanu::{check_eq, http::Client};
18//!
19//! #[tanu::test]
20//! async fn test_api() -> eyre::Result<()> {
21//!     let client = Client::new();
22//!     
23//!     let response = client
24//!         .get("https://api.example.com/users")
25//!         .header("accept", "application/json")
26//!         .send()
27//!         .await?;
28//!     
29//!     check_eq!(200, response.status().as_u16());
30//!     
31//!     let users: serde_json::Value = response.json().await?;
32//!     check!(users.is_array());
33//!     
34//!     Ok(())
35//! }
36//! ```
37use eyre::OptionExt;
38pub use http::{header, Method, StatusCode, Version};
39use std::time::{Duration, Instant};
40use tracing::*;
41
42#[derive(Debug, thiserror::Error)]
43pub enum Error {
44    #[error("HttpError: {0}")]
45    Http(#[from] reqwest::Error),
46    #[error("failed to deserialize http response into the specified type: {0}")]
47    Deserialize(#[from] serde_json::Error),
48    #[error("{0:#}")]
49    Unexpected(#[from] eyre::Error),
50}
51
52#[derive(Debug, Clone)]
53pub struct LogRequest {
54    pub url: url::Url,
55    pub method: Method,
56    pub headers: header::HeaderMap,
57}
58
59#[derive(Debug, Clone, Default)]
60pub struct LogResponse {
61    pub headers: header::HeaderMap,
62    pub body: String,
63    pub status: StatusCode,
64    pub duration_req: Duration,
65}
66
67#[derive(Debug, Clone)]
68pub struct Log {
69    pub request: LogRequest,
70    pub response: LogResponse,
71}
72
73/// HTTP response wrapper with enhanced testing capabilities.
74///
75/// This struct wraps HTTP response data and provides convenient methods
76/// for accessing response information in tests. All data is captured
77/// for logging and debugging purposes.
78///
79/// # Examples
80///
81/// ```rust,ignore
82/// use tanu::{check_eq, http::Client};
83///
84/// #[tanu::test]
85/// async fn test_response() -> eyre::Result<()> {
86///     let client = Client::new();
87///     let response = client.get("https://api.example.com").send().await?;
88///     
89///     // Check status
90///     check_eq!(200, response.status().as_u16());
91///     
92///     // Access headers
93///     let content_type = response.headers().get("content-type");
94///     
95///     // Parse JSON
96///     let data: serde_json::Value = response.json().await?;
97///     
98///     Ok(())
99/// }
100/// ```
101#[derive(Debug, Clone)]
102pub struct Response {
103    pub headers: header::HeaderMap,
104    pub status: StatusCode,
105    pub text: String,
106    pub url: url::Url,
107    #[cfg(feature = "cookies")]
108    cookies: Vec<cookie::Cookie<'static>>,
109}
110
111impl Response {
112    /// Returns the HTTP status code of the response.
113    ///
114    /// # Examples
115    ///
116    /// ```rust,ignore
117    /// let status = response.status();
118    /// check_eq!(200, status.as_u16());
119    /// check!(status.is_success());
120    /// ```
121    pub fn status(&self) -> StatusCode {
122        self.status
123    }
124
125    /// Returns a reference to the response headers.
126    ///
127    /// # Examples
128    ///
129    /// ```rust,ignore
130    /// let headers = response.headers();
131    /// let content_type = headers.get("content-type").unwrap();
132    /// check_str_eq!("application/json", content_type.to_str().unwrap());
133    /// ```
134    pub fn headers(&self) -> &header::HeaderMap {
135        &self.headers
136    }
137
138    /// Returns the final URL of the response, after following redirects.
139    ///
140    /// # Examples
141    ///
142    /// ```rust,ignore
143    /// let url = response.url();
144    /// check!(url.host_str().unwrap().contains("example.com"));
145    /// ```
146    pub fn url(&self) -> &url::Url {
147        &self.url
148    }
149
150    /// Consumes the response and returns the response body as a string.
151    ///
152    /// # Examples
153    ///
154    /// ```rust,ignore
155    /// let body = response.text().await?;
156    /// check!(body.contains("expected content"));
157    /// ```
158    pub async fn text(self) -> Result<String, Error> {
159        Ok(self.text)
160    }
161
162    /// Consumes the response and deserializes the JSON body into the given type.
163    ///
164    /// # Examples
165    ///
166    /// ```rust,ignore
167    /// // Parse as serde_json::Value
168    /// let data: serde_json::Value = response.json().await?;
169    /// check_eq!("John", data["name"]);
170    ///
171    /// // Parse into custom struct
172    /// #[derive(serde::Deserialize)]
173    /// struct User { name: String, id: u64 }
174    /// let user: User = response.json().await?;
175    /// check_eq!("John", user.name);
176    /// ```
177    pub async fn json<T: serde::de::DeserializeOwned>(self) -> Result<T, Error> {
178        Ok(serde_json::from_str(&self.text)?)
179    }
180
181    #[cfg(feature = "cookies")]
182    pub fn cookies(&self) -> impl Iterator<Item = &cookie::Cookie<'static>> + '_ {
183        self.cookies.iter()
184    }
185
186    async fn from(res: reqwest::Response) -> Self {
187        let headers = res.headers().clone();
188        let status = res.status();
189        let url = res.url().clone();
190
191        #[cfg(feature = "cookies")]
192        let cookies: Vec<cookie::Cookie<'static>> = res
193            .cookies()
194            .map(|cookie| {
195                cookie::Cookie::build((cookie.name().to_string(), cookie.value().to_string()))
196                    .build()
197            })
198            .collect();
199
200        let text = res.text().await.unwrap_or_default();
201
202        Response {
203            headers,
204            status,
205            url,
206            text,
207            #[cfg(feature = "cookies")]
208            cookies,
209        }
210    }
211}
212
213/// Tanu's HTTP client that provides enhanced testing capabilities.
214///
215/// This client is a wrapper around `reqwest::Client` that offers the same API
216/// while adding automatic request/response logging, better error handling,
217/// and integration with tanu's test reporting system.
218///
219/// # Features
220///
221/// - **Compatible API**: Drop-in replacement for `reqwest::Client`
222/// - **Automatic Logging**: All requests and responses are captured for debugging
223/// - **Enhanced Errors**: Detailed error context for better test debugging
224/// - **Cookie Support**: Optional cookie handling with the `cookies` feature
225///
226/// # Examples
227///
228/// ```rust,ignore
229/// use tanu::{check, http::Client};
230///
231/// #[tanu::test]
232/// async fn test_api() -> eyre::Result<()> {
233///     let client = Client::new();
234///     
235///     let response = client
236///         .get("https://api.example.com/health")
237///         .send()
238///         .await?;
239///     
240///     check!(response.status().is_success());
241///     Ok(())
242/// }
243/// ```
244#[derive(Clone, Default)]
245pub struct Client {
246    pub(crate) inner: reqwest::Client,
247}
248
249impl Client {
250    /// Creates a new HTTP client instance.
251    ///
252    /// This creates a client with default settings, including cookie support
253    /// if the `cookies` feature is enabled. The client is configured for
254    /// optimal testing performance and reliability.
255    ///
256    /// # Examples
257    ///
258    /// ```rust,ignore
259    /// use tanu::http::Client;
260    ///
261    /// let client = Client::new();
262    /// ```
263    pub fn new() -> Client {
264        #[cfg(feature = "cookies")]
265        let inner = reqwest::Client::builder()
266            .cookie_store(true)
267            .build()
268            .unwrap_or_default();
269
270        #[cfg(not(feature = "cookies"))]
271        let inner = reqwest::Client::default();
272
273        Client { inner }
274    }
275
276    pub fn get(&self, url: impl reqwest::IntoUrl) -> RequestBuilder {
277        let url = url.into_url().unwrap();
278        debug!("Requesting {url}");
279        RequestBuilder {
280            inner: Some(self.inner.get(url)),
281            client: self.inner.clone(),
282        }
283    }
284
285    pub fn post(&self, url: impl reqwest::IntoUrl) -> RequestBuilder {
286        let url = url.into_url().unwrap();
287        debug!("Requesting {url}");
288        RequestBuilder {
289            inner: Some(self.inner.post(url)),
290            client: self.inner.clone(),
291        }
292    }
293
294    pub fn put(&self, url: impl reqwest::IntoUrl) -> RequestBuilder {
295        let url = url.into_url().unwrap();
296        debug!("Requesting {url}");
297        RequestBuilder {
298            inner: Some(self.inner.put(url)),
299            client: self.inner.clone(),
300        }
301    }
302
303    pub fn patch(&self, url: impl reqwest::IntoUrl) -> RequestBuilder {
304        let url = url.into_url().unwrap();
305        debug!("Requesting {url}");
306        RequestBuilder {
307            inner: Some(self.inner.patch(url)),
308            client: self.inner.clone(),
309        }
310    }
311
312    pub fn delete(&self, url: impl reqwest::IntoUrl) -> RequestBuilder {
313        let url = url.into_url().unwrap();
314        debug!("Requesting {url}");
315        RequestBuilder {
316            inner: Some(self.inner.delete(url)),
317            client: self.inner.clone(),
318        }
319    }
320
321    pub fn head(&self, url: impl reqwest::IntoUrl) -> RequestBuilder {
322        let url = url.into_url().unwrap();
323        debug!("Requesting {url}");
324        RequestBuilder {
325            inner: Some(self.inner.head(url)),
326            client: self.inner.clone(),
327        }
328    }
329}
330
331pub struct RequestBuilder {
332    pub(crate) inner: Option<reqwest::RequestBuilder>,
333    pub(crate) client: reqwest::Client,
334}
335
336impl RequestBuilder {
337    pub fn header<K, V>(mut self, key: K, value: V) -> RequestBuilder
338    where
339        header::HeaderName: TryFrom<K>,
340        <header::HeaderName as TryFrom<K>>::Error: Into<http::Error>,
341        header::HeaderValue: TryFrom<V>,
342        <header::HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
343    {
344        let inner = self.inner.take().expect("inner missing");
345        self.inner = Some(inner.header(key, value));
346        self
347    }
348
349    pub fn headers(mut self, headers: header::HeaderMap) -> RequestBuilder {
350        let inner = self.inner.take().expect("inner missing");
351        self.inner = Some(inner.headers(headers));
352        self
353    }
354
355    pub fn basic_auth<U, P>(mut self, username: U, password: Option<P>) -> RequestBuilder
356    where
357        U: std::fmt::Display,
358        P: std::fmt::Display,
359    {
360        let inner = self.inner.take().expect("inner missing");
361        self.inner = Some(inner.basic_auth(username, password));
362        self
363    }
364
365    pub fn bearer_auth<T>(mut self, token: T) -> RequestBuilder
366    where
367        T: std::fmt::Display,
368    {
369        let inner = self.inner.take().expect("inner missing");
370        self.inner = Some(inner.bearer_auth(token));
371        self
372    }
373
374    pub fn body<T: Into<reqwest::Body>>(mut self, body: T) -> RequestBuilder {
375        let inner = self.inner.take().expect("inner missing");
376        self.inner = Some(inner.body(body));
377        self
378    }
379
380    pub fn query<T: serde::Serialize + ?Sized>(mut self, query: &T) -> RequestBuilder {
381        let inner = self.inner.take().expect("inner missing");
382        self.inner = Some(inner.query(query));
383        self
384    }
385
386    pub fn form<T: serde::Serialize + ?Sized>(mut self, form: &T) -> RequestBuilder {
387        let inner = self.inner.take().expect("inner missing");
388        self.inner = Some(inner.form(form));
389        self
390    }
391
392    #[cfg(feature = "json")]
393    pub fn json<T: serde::Serialize + ?Sized>(mut self, json: &T) -> RequestBuilder {
394        self.inner = self.inner.take().map(|inner| inner.json(json));
395        self
396    }
397
398    #[cfg(feature = "multipart")]
399    pub fn multipart(mut self, multipart: reqwest::multipart::Form) -> RequestBuilder {
400        let inner = self.inner.take().expect("inner missing");
401        self.inner = Some(inner.multipart(multipart));
402        self
403    }
404
405    pub async fn send(mut self) -> Result<Response, Error> {
406        let req = self.inner.take().ok_or_eyre("inner missing")?.build()?;
407
408        let log_request = LogRequest {
409            url: req.url().clone(),
410            method: req.method().clone(),
411            headers: req.headers().clone(),
412        };
413
414        let time_req = Instant::now();
415        let res = self.client.execute(req).await;
416
417        match res {
418            Ok(res) => {
419                let res = Response::from(res).await;
420                let duration_req = time_req.elapsed();
421
422                let log_response = LogResponse {
423                    headers: res.headers.clone(),
424                    body: res.text.clone(),
425                    status: res.status(),
426                    duration_req,
427                };
428
429                crate::runner::publish(crate::runner::EventBody::Http(Box::new(Log {
430                    request: log_request.clone(),
431                    response: log_response,
432                })))?;
433                Ok(res)
434            }
435            Err(e) => {
436                crate::runner::publish(crate::runner::EventBody::Http(Box::new(Log {
437                    request: log_request,
438                    response: Default::default(),
439                })))?;
440                Err(e.into())
441            }
442        }
443    }
444
445    pub fn timeout(mut self, timeout: std::time::Duration) -> RequestBuilder {
446        let inner = self.inner.take().expect("inner missing");
447        self.inner = Some(inner.timeout(timeout));
448        self
449    }
450
451    pub fn try_clone(&self) -> Option<RequestBuilder> {
452        let inner = self.inner.as_ref()?;
453        Some(RequestBuilder {
454            inner: Some(inner.try_clone()?),
455            client: self.client.clone(),
456        })
457    }
458
459    pub fn version(mut self, version: Version) -> RequestBuilder {
460        let inner = self.inner.take().expect("inner missing");
461        self.inner = Some(inner.version(version));
462        self
463    }
464}