Skip to main content

tanu_core/
http.rs

1//! # HTTP Client Module
2//!
3//! Tanu's HTTP client provides a high-performance wrapper around `hyper` with enhanced
4//! logging and testing capabilities. Built directly on hyper for minimal overhead and
5//! precise control over HTTP operations.
6//!
7//! ## Request/Response Flow (block diagram)
8//!
9//! ```text
10//! +-------------------+     +-------------------+     +-------------------+
11//! | Client            | --> | RequestBuilder    | --> | hyper Client      |
12//! | get/post/put/...  |     | headers/body/json |     | request(req)      |
13//! +-------------------+     +-------------------+     +-------------------+
14//!                                                              |
15//!                                                              v
16//! +-------------------+     +-------------------+     +-------------------+
17//! | Response          | <-- | Log (captured)    | <-- | hyper::Response   |
18//! | status/headers/   |     | request + response|     | async response    |
19//! | text/json         |     | timing info       |     |                   |
20//! +-------------------+     +-------------------+     +-------------------+
21//!                                   |
22//!                                   v
23//!                           +-------------------+
24//!                           | Event channel     |
25//!                           | publish(Http log) |
26//!                           +-------------------+
27//! ```
28//!
29//! ## Key Features
30//!
31//! - **High Performance**: Built directly on hyper for minimal overhead
32//! - **Automatic Logging**: Captures all HTTP requests and responses
33//! - **Precise Control**: Direct access to hyper's low-level HTTP functionality
34//! - **Integration with Assertions**: Works seamlessly with tanu's assertion macros
35//! - **Error Handling**: Enhanced error types with context for better debugging
36//!
37//! ## Basic Usage
38//!
39//! ```rust,ignore
40//! use tanu::{check_eq, http::Client};
41//!
42//! #[tanu::test]
43//! async fn test_api() -> eyre::Result<()> {
44//!     let client = Client::new();
45//!
46//!     let response = client
47//!         .get("https://api.example.com/users")
48//!         .header("accept", "application/json")
49//!         .send()
50//!         .await?;
51//!
52//!     check_eq!(200, response.status().as_u16());
53//!
54//!     let users: serde_json::Value = response.json().await?;
55//!     check!(users.is_array());
56//!
57//!     Ok(())
58//! }
59//! ```
60use bytes::Bytes;
61pub use http::{header, Method, StatusCode, Version};
62use http_body_util::{BodyExt, Full};
63use hyper::body::Incoming;
64use hyper::Request;
65use hyper_util::client::legacy::Client as HyperClient;
66use hyper_util::rt::TokioExecutor;
67use std::io::Read;
68use std::time::{Duration, Instant, SystemTime};
69use tracing::*;
70
71use crate::masking;
72
73// Ensure exactly one TLS backend is selected
74#[cfg(all(feature = "native-tls", feature = "rustls-tls"))]
75compile_error!(
76    "Features `native-tls` and `rustls-tls` are mutually exclusive. Please enable only one."
77);
78
79#[cfg(not(any(feature = "native-tls", feature = "rustls-tls")))]
80compile_error!("Either feature `native-tls` or `rustls-tls` must be enabled for TLS support.");
81
82#[cfg(feature = "cookies")]
83use std::collections::HashMap;
84
85/// A trait to convert various types into URL strings.
86/// This provides API compatibility for URL handling.
87pub trait IntoUrl {
88    fn into_url_string(self) -> String;
89}
90
91impl IntoUrl for &str {
92    fn into_url_string(self) -> String {
93        self.to_string()
94    }
95}
96
97impl IntoUrl for String {
98    fn into_url_string(self) -> String {
99        self
100    }
101}
102
103impl IntoUrl for &String {
104    fn into_url_string(self) -> String {
105        self.clone()
106    }
107}
108
109impl IntoUrl for url::Url {
110    fn into_url_string(self) -> String {
111        self.to_string()
112    }
113}
114
115impl IntoUrl for &url::Url {
116    fn into_url_string(self) -> String {
117        self.to_string()
118    }
119}
120
121#[cfg(feature = "multipart")]
122#[derive(Debug)]
123pub struct MultipartForm {
124    // Placeholder for multipart form data
125    // Full implementation would require additional work
126}
127
128#[derive(Debug, thiserror::Error)]
129pub enum Error {
130    #[error("HttpError: {0}")]
131    Http(#[from] hyper::Error),
132    #[error("HttpError: {0}")]
133    HttpLegacy(#[from] hyper_util::client::legacy::Error),
134    #[error("UriError: {0}")]
135    Uri(#[from] http::uri::InvalidUri),
136    #[error("HeaderError: {0}")]
137    Header(#[from] http::Error),
138    #[cfg(feature = "native-tls")]
139    #[error("TlsError: {0}")]
140    Tls(#[from] hyper_tls::native_tls::Error),
141    #[cfg(feature = "rustls-tls")]
142    #[error("TlsError: {0}")]
143    Tls(#[from] rustls::Error),
144    #[error("Request timed out after {0:?}")]
145    Timeout(Duration),
146    #[error("failed to deserialize http response into the specified type: {0}")]
147    Deserialize(#[from] serde_json::Error),
148    #[error("{0:#}")]
149    Unexpected(#[from] eyre::Error),
150}
151
152#[derive(Debug, Clone)]
153pub struct LogRequest {
154    pub url: url::Url,
155    pub method: Method,
156    pub headers: header::HeaderMap,
157}
158
159#[derive(Debug, Clone, Default)]
160pub struct LogResponse {
161    pub headers: header::HeaderMap,
162    pub body: String,
163    pub status: StatusCode,
164    pub duration_req: Duration,
165}
166
167#[derive(Debug, Clone)]
168pub struct Log {
169    pub request: LogRequest,
170    pub response: LogResponse,
171    pub started_at: SystemTime,
172    pub ended_at: SystemTime,
173}
174
175/// HTTP response wrapper with enhanced testing capabilities.
176///
177/// This struct wraps HTTP response data and provides convenient methods
178/// for accessing response information in tests. All data is captured
179/// for logging and debugging purposes.
180///
181/// # Examples
182///
183/// ```rust,ignore
184/// use tanu::{check_eq, http::Client};
185///
186/// #[tanu::test]
187/// async fn test_response() -> eyre::Result<()> {
188///     let client = Client::new();
189///     let response = client.get("https://api.example.com").send().await?;
190///
191///     // Check status
192///     check_eq!(200, response.status().as_u16());
193///
194///     // Access headers
195///     let content_type = response.headers().get("content-type");
196///
197///     // Parse JSON
198///     let data: serde_json::Value = response.json().await?;
199///
200///     Ok(())
201/// }
202/// ```
203#[derive(Debug, Clone)]
204pub struct Response {
205    pub headers: header::HeaderMap,
206    pub status: StatusCode,
207    pub text: String,
208    pub url: url::Url,
209    #[cfg(feature = "cookies")]
210    cookies: Vec<cookie::Cookie<'static>>,
211}
212
213impl Response {
214    /// Returns the HTTP status code of the response.
215    ///
216    /// # Examples
217    ///
218    /// ```rust,ignore
219    /// let status = response.status();
220    /// check_eq!(200, status.as_u16());
221    /// check!(status.is_success());
222    /// ```
223    pub fn status(&self) -> StatusCode {
224        self.status
225    }
226
227    /// Returns a reference to the response headers.
228    ///
229    /// # Examples
230    ///
231    /// ```rust,ignore
232    /// let headers = response.headers();
233    /// let content_type = headers.get("content-type").unwrap();
234    /// check_str_eq!("application/json", content_type.to_str().unwrap());
235    /// ```
236    pub fn headers(&self) -> &header::HeaderMap {
237        &self.headers
238    }
239
240    /// Returns the final URL of the response, after following redirects.
241    ///
242    /// # Examples
243    ///
244    /// ```rust,ignore
245    /// let url = response.url();
246    /// check!(url.host_str().unwrap().contains("example.com"));
247    /// ```
248    pub fn url(&self) -> &url::Url {
249        &self.url
250    }
251
252    /// Consumes the response and returns the response body as a string.
253    ///
254    /// # Examples
255    ///
256    /// ```rust,ignore
257    /// let body = response.text().await?;
258    /// check!(body.contains("expected content"));
259    /// ```
260    pub async fn text(self) -> Result<String, Error> {
261        Ok(self.text)
262    }
263
264    /// Consumes the response and deserializes the JSON body into the given type.
265    ///
266    /// # Examples
267    ///
268    /// ```rust,ignore
269    /// // Parse as serde_json::Value
270    /// let data: serde_json::Value = response.json().await?;
271    /// check_eq!("John", data["name"]);
272    ///
273    /// // Parse into custom struct
274    /// #[derive(serde::Deserialize)]
275    /// struct User { name: String, id: u64 }
276    /// let user: User = response.json().await?;
277    /// check_eq!("John", user.name);
278    /// ```
279    pub async fn json<T: serde::de::DeserializeOwned>(self) -> Result<T, Error> {
280        Ok(serde_json::from_str(&self.text)?)
281    }
282
283    #[cfg(feature = "cookies")]
284    pub fn cookies(&self) -> impl Iterator<Item = &cookie::Cookie<'static>> + '_ {
285        self.cookies.iter()
286    }
287
288    async fn from(res: hyper::Response<Incoming>, url: url::Url) -> Result<Self, Error> {
289        let headers = res.headers().clone();
290        let status = res.status();
291
292        #[cfg(feature = "cookies")]
293        let cookies: Vec<cookie::Cookie<'static>> = headers
294            .get_all("set-cookie")
295            .iter()
296            .filter_map(|cookie_header| {
297                cookie_header.to_str().ok().and_then(|cookie_str| {
298                    cookie::Cookie::parse(cookie_str)
299                        .ok()
300                        .map(|c| c.into_owned())
301                })
302            })
303            .collect();
304
305        let body_bytes = res.into_body().collect().await?.to_bytes();
306
307        // Handle content decompression
308        let text = Self::decompress_body(&headers, &body_bytes);
309
310        Ok(Response {
311            headers,
312            status,
313            url,
314            text,
315            #[cfg(feature = "cookies")]
316            cookies,
317        })
318    }
319
320    fn decompress_body(headers: &header::HeaderMap, body_bytes: &Bytes) -> String {
321        match headers
322            .get("content-encoding")
323            .and_then(|v| v.to_str().ok())
324        {
325            Some("gzip") => {
326                use flate2::read::GzDecoder;
327                let mut decoder = GzDecoder::new(body_bytes.as_ref());
328                let mut decompressed = Vec::new();
329                match decoder.read_to_end(&mut decompressed) {
330                    Ok(_) => String::from_utf8_lossy(&decompressed).to_string(),
331                    Err(_) => String::from_utf8_lossy(body_bytes).to_string(),
332                }
333            }
334            Some("deflate") => {
335                use flate2::read::{DeflateDecoder, ZlibDecoder};
336
337                // Try zlib format first (most common for HTTP deflate)
338                let mut zlib_decoder = ZlibDecoder::new(body_bytes.as_ref());
339                let mut decompressed = Vec::new();
340                match zlib_decoder.read_to_end(&mut decompressed) {
341                    Ok(_) => String::from_utf8_lossy(&decompressed).to_string(),
342                    Err(_) => {
343                        // Fallback to raw deflate format
344                        let mut deflate_decoder = DeflateDecoder::new(body_bytes.as_ref());
345                        let mut decompressed = Vec::new();
346                        match deflate_decoder.read_to_end(&mut decompressed) {
347                            Ok(_) => String::from_utf8_lossy(&decompressed).to_string(),
348                            Err(_) => String::from_utf8_lossy(body_bytes).to_string(),
349                        }
350                    }
351                }
352            }
353            Some("br") => {
354                let mut decompressed = Vec::new();
355                match brotli::Decompressor::new(body_bytes.as_ref(), 4096)
356                    .read_to_end(&mut decompressed)
357                {
358                    Ok(_) => String::from_utf8_lossy(&decompressed).to_string(),
359                    Err(_) => String::from_utf8_lossy(body_bytes).to_string(),
360                }
361            }
362            Some("zstd") => match zstd::decode_all(body_bytes.as_ref()) {
363                Ok(decompressed) => String::from_utf8_lossy(&decompressed).to_string(),
364                Err(_) => String::from_utf8_lossy(body_bytes).to_string(),
365            },
366            _ => String::from_utf8_lossy(body_bytes).to_string(),
367        }
368    }
369}
370
371/// Tanu's HTTP client that provides enhanced testing capabilities.
372///
373/// This client is built on hyper for high performance and precise control
374/// while adding automatic request/response logging, better error handling,
375/// and integration with tanu's test reporting system.
376///
377/// # Features
378///
379/// - **High Performance**: Built on hyper for minimal overhead
380/// - **Automatic Logging**: All requests and responses are captured for debugging
381/// - **Enhanced Errors**: Detailed error context for better test debugging
382/// - **Cookie Support**: Optional cookie handling with the `cookies` feature
383///
384/// # Examples
385///
386/// ```rust,ignore
387/// use tanu::{check, http::Client};
388///
389/// #[tanu::test]
390/// async fn test_api() -> eyre::Result<()> {
391///     let client = Client::new();
392///
393///     let response = client
394///         .get("https://api.example.com/health")
395///         .send()
396///         .await?;
397///
398///     check!(response.status().is_success());
399///     Ok(())
400/// }
401/// ```
402#[derive(Clone)]
403pub struct Client {
404    #[cfg(feature = "native-tls")]
405    pub(crate) inner: HyperClient<
406        hyper_tls::HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>,
407        Full<Bytes>,
408    >,
409    #[cfg(feature = "rustls-tls")]
410    pub(crate) inner: HyperClient<
411        hyper_rustls::HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>,
412        Full<Bytes>,
413    >,
414    #[cfg(feature = "cookies")]
415    pub(crate) cookie_store:
416        std::sync::Arc<tokio::sync::RwLock<HashMap<String, Vec<cookie::Cookie<'static>>>>>,
417}
418
419impl Default for Client {
420    fn default() -> Self {
421        Self::new()
422    }
423}
424
425impl Client {
426    /// Creates a new HTTP client instance.
427    ///
428    /// This creates a client with default settings, including cookie support
429    /// if the `cookies` feature is enabled. The client is configured for
430    /// optimal testing performance and reliability.
431    ///
432    /// # Examples
433    ///
434    /// ```rust,ignore
435    /// use tanu::http::Client;
436    ///
437    /// let client = Client::new();
438    /// ```
439    pub fn new() -> Client {
440        #[cfg(feature = "native-tls")]
441        let inner = {
442            let https = hyper_tls::HttpsConnector::new();
443            HyperClient::builder(TokioExecutor::new()).build::<_, Full<Bytes>>(https)
444        };
445
446        #[cfg(feature = "rustls-tls")]
447        let inner = {
448            let mut root_store = rustls::RootCertStore::empty();
449
450            #[cfg(feature = "rustls-tls-native-roots")]
451            {
452                let native_certs = rustls_native_certs::load_native_certs();
453                for cert in native_certs.certs {
454                    root_store.add(cert).ok();
455                }
456            }
457
458            #[cfg(feature = "rustls-tls-webpki-roots")]
459            {
460                root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
461            }
462
463            let tls_config = rustls::ClientConfig::builder()
464                .with_root_certificates(root_store)
465                .with_no_client_auth();
466
467            let https = hyper_rustls::HttpsConnectorBuilder::new()
468                .with_tls_config(tls_config)
469                .https_or_http()
470                .enable_http1()
471                .enable_http2()
472                .build();
473
474            HyperClient::builder(TokioExecutor::new()).build::<_, Full<Bytes>>(https)
475        };
476
477        Client {
478            inner,
479            #[cfg(feature = "cookies")]
480            cookie_store: std::sync::Arc::new(tokio::sync::RwLock::new(HashMap::new())),
481        }
482    }
483
484    pub fn get<U: IntoUrl>(&self, url: U) -> RequestBuilder {
485        let url_str = url.into_url_string();
486        debug!("Requesting {url_str}");
487        RequestBuilder::new(self.clone(), Method::GET, &url_str)
488    }
489
490    pub fn post<U: IntoUrl>(&self, url: U) -> RequestBuilder {
491        let url_str = url.into_url_string();
492        debug!("Requesting {url_str}");
493        RequestBuilder::new(self.clone(), Method::POST, &url_str)
494    }
495
496    pub fn put<U: IntoUrl>(&self, url: U) -> RequestBuilder {
497        let url_str = url.into_url_string();
498        debug!("Requesting {url_str}");
499        RequestBuilder::new(self.clone(), Method::PUT, &url_str)
500    }
501
502    pub fn patch<U: IntoUrl>(&self, url: U) -> RequestBuilder {
503        let url_str = url.into_url_string();
504        debug!("Requesting {url_str}");
505        RequestBuilder::new(self.clone(), Method::PATCH, &url_str)
506    }
507
508    pub fn delete<U: IntoUrl>(&self, url: U) -> RequestBuilder {
509        let url_str = url.into_url_string();
510        debug!("Requesting {url_str}");
511        RequestBuilder::new(self.clone(), Method::DELETE, &url_str)
512    }
513
514    pub fn head<U: IntoUrl>(&self, url: U) -> RequestBuilder {
515        let url_str = url.into_url_string();
516        debug!("Requesting {url_str}");
517        RequestBuilder::new(self.clone(), Method::HEAD, &url_str)
518    }
519
520    #[cfg(feature = "graphql")]
521    pub fn graphql<U: IntoUrl>(&self, url: U) -> crate::graphql::GraphqlRequestBuilder {
522        let url_str = url.into_url_string();
523        debug!("Requesting {url_str}");
524        crate::graphql::GraphqlRequestBuilder::new(RequestBuilder::new(
525            self.clone(),
526            Method::POST,
527            &url_str,
528        ))
529    }
530}
531
532pub struct RequestBuilder {
533    client: Client,
534    method: Method,
535    url: String,
536    headers: header::HeaderMap,
537    body: Option<Vec<u8>>,
538    query_params: Vec<(String, String)>,
539    timeout: Option<Duration>,
540}
541
542impl RequestBuilder {
543    fn new(client: Client, method: Method, url: &str) -> Self {
544        Self {
545            client,
546            method,
547            url: url.to_string(),
548            headers: header::HeaderMap::new(),
549            body: None,
550            query_params: Vec::new(),
551            timeout: None,
552        }
553    }
554
555    pub fn header<K, V>(mut self, key: K, value: V) -> Self
556    where
557        header::HeaderName: TryFrom<K>,
558        <header::HeaderName as TryFrom<K>>::Error: Into<http::Error>,
559        header::HeaderValue: TryFrom<V>,
560        <header::HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
561    {
562        if let (Ok(name), Ok(val)) = (
563            header::HeaderName::try_from(key),
564            header::HeaderValue::try_from(value),
565        ) {
566            self.headers.insert(name, val);
567        }
568        self
569    }
570
571    pub fn headers(mut self, headers: header::HeaderMap) -> Self {
572        self.headers.extend(headers);
573        self
574    }
575
576    pub fn basic_auth<U, P>(mut self, username: U, password: Option<P>) -> Self
577    where
578        U: std::fmt::Display,
579        P: std::fmt::Display,
580    {
581        let auth_value = match password {
582            Some(p) => format!("{username}:{p}"),
583            None => username.to_string(),
584        };
585        let encoded = base64::Engine::encode(
586            &base64::engine::general_purpose::STANDARD,
587            auth_value.as_bytes(),
588        );
589        let auth_header = format!("Basic {encoded}");
590
591        if let Ok(header_value) = header::HeaderValue::from_str(&auth_header) {
592            self.headers.insert(header::AUTHORIZATION, header_value);
593        }
594        self
595    }
596
597    pub fn bearer_auth<T>(mut self, token: T) -> Self
598    where
599        T: std::fmt::Display,
600    {
601        let auth_header = format!("Bearer {token}");
602        if let Ok(header_value) = header::HeaderValue::from_str(&auth_header) {
603            self.headers.insert(header::AUTHORIZATION, header_value);
604        }
605        self
606    }
607
608    pub fn body<T: Into<Vec<u8>>>(mut self, body: T) -> Self {
609        self.body = Some(body.into());
610        self
611    }
612
613    pub fn query<T: serde::Serialize + ?Sized>(mut self, query: &T) -> Self {
614        if let Ok(params) = serde_urlencoded::to_string(query) {
615            for pair in params.split('&') {
616                if let Some((key, value)) = pair.split_once('=') {
617                    self.query_params.push((key.to_string(), value.to_string()));
618                }
619            }
620        }
621        self
622    }
623
624    pub fn form<T: serde::Serialize + ?Sized>(mut self, form: &T) -> Self {
625        if let Ok(body) = serde_urlencoded::to_string(form) {
626            self.body = Some(body.into_bytes());
627            self.headers.insert(
628                header::CONTENT_TYPE,
629                header::HeaderValue::from_static("application/x-www-form-urlencoded"),
630            );
631        }
632        self
633    }
634
635    #[cfg(feature = "json")]
636    pub fn json<T: serde::Serialize + ?Sized>(mut self, json: &T) -> Self {
637        if let Ok(body) = serde_json::to_string(json) {
638            self.body = Some(body.into_bytes());
639            self.headers.insert(
640                header::CONTENT_TYPE,
641                header::HeaderValue::from_static("application/json"),
642            );
643        }
644        self
645    }
646
647    #[cfg(feature = "multipart")]
648    pub fn multipart(self, _multipart: MultipartForm) -> Self {
649        // Note: Multipart support would need additional implementation
650        // For now, this is a placeholder to maintain API compatibility
651        self
652    }
653
654    pub async fn send(self) -> Result<Response, Error> {
655        let mut url = self.url.clone();
656
657        // Add query parameters
658        if !self.query_params.is_empty() {
659            let query_string: String = self
660                .query_params
661                .iter()
662                .map(|(k, v)| format!("{k}={v}"))
663                .collect::<Vec<_>>()
664                .join("&");
665
666            url = if url.contains('?') {
667                format!("{url}&{query_string}")
668            } else {
669                format!("{url}?{query_string}")
670            };
671        }
672
673        let parsed_url = url::Url::parse(&url).map_err(|e| eyre::eyre!("Invalid URL: {}", e))?;
674        let uri: http::Uri = url.parse()?;
675
676        let mut req_builder = Request::builder().method(self.method.clone()).uri(uri);
677
678        // Add headers
679        for (name, value) in &self.headers {
680            req_builder = req_builder.header(name, value);
681        }
682
683        #[cfg(feature = "cookies")]
684        {
685            // Add cookies for this domain
686            let cookie_store = self.client.cookie_store.read().await;
687            if let Some(domain_cookies) = cookie_store.get(parsed_url.host_str().unwrap_or("")) {
688                if !domain_cookies.is_empty() {
689                    let cookie_header = domain_cookies
690                        .iter()
691                        .map(|cookie| format!("{}={}", cookie.name(), cookie.value()))
692                        .collect::<Vec<_>>()
693                        .join("; ");
694
695                    if let Ok(cookie_value) = header::HeaderValue::from_str(&cookie_header) {
696                        req_builder = req_builder.header(header::COOKIE, cookie_value);
697                    }
698                }
699            }
700        }
701
702        let body = match &self.body {
703            Some(ref body_data) => Full::new(Bytes::from(body_data.clone())),
704            None => Full::new(Bytes::new()),
705        };
706
707        let req = req_builder.body(body)?;
708
709        let log_request = LogRequest {
710            url: if masking::should_mask_sensitive() {
711                masking::mask_url(&parsed_url)
712            } else {
713                parsed_url.clone()
714            },
715            method: self.method.clone(),
716            headers: if masking::should_mask_sensitive() {
717                masking::mask_headers(&self.headers)
718            } else {
719                self.headers.clone()
720            },
721        };
722
723        let started_at = SystemTime::now();
724        let time_req = Instant::now();
725
726        // Apply timeout if specified
727        let res = match self.timeout {
728            Some(timeout) => {
729                match tokio::time::timeout(timeout, self.client.inner.request(req)).await {
730                    Ok(result) => result,
731                    Err(_) => return Err(Error::Timeout(timeout)),
732                }
733            }
734            None => self.client.inner.request(req).await,
735        };
736        let ended_at = SystemTime::now();
737
738        match res {
739            Ok(res) => {
740                let status = res.status();
741
742                // Handle redirects - follow up to 10 redirects
743                if status.is_redirection() {
744                    return Self::follow_redirects(
745                        self.client.clone(),
746                        self.headers.clone(),
747                        self.method.clone(),
748                        self.body.clone(),
749                        res,
750                        parsed_url,
751                        log_request,
752                        started_at,
753                        time_req,
754                        10,
755                    )
756                    .await;
757                }
758
759                let response = Response::from(res, parsed_url).await?;
760                let duration_req = time_req.elapsed();
761
762                #[cfg(feature = "cookies")]
763                {
764                    // Store cookies from response
765                    if !response.cookies.is_empty() {
766                        let mut cookie_store = self.client.cookie_store.write().await;
767                        let domain = response.url().host_str().unwrap_or("").to_string();
768                        cookie_store.insert(domain, response.cookies.clone());
769                    }
770                }
771
772                let log_response = LogResponse {
773                    headers: if masking::should_mask_sensitive() {
774                        masking::mask_headers(&response.headers)
775                    } else {
776                        response.headers.clone()
777                    },
778                    body: response.text.clone(),
779                    status: response.status(),
780                    duration_req,
781                };
782
783                crate::runner::publish(crate::runner::EventBody::Call(
784                    crate::runner::CallLog::Http(Box::new(Log {
785                        request: log_request,
786                        response: log_response,
787                        started_at,
788                        ended_at,
789                    })),
790                ))?;
791                Ok(response)
792            }
793            Err(e) => {
794                crate::runner::publish(crate::runner::EventBody::Call(
795                    crate::runner::CallLog::Http(Box::new(Log {
796                        request: log_request,
797                        response: Default::default(),
798                        started_at,
799                        ended_at,
800                    })),
801                ))?;
802                Err(e.into())
803            }
804        }
805    }
806
807    #[allow(clippy::too_many_arguments)]
808    async fn follow_redirects(
809        client: Client,
810        headers: header::HeaderMap,
811        mut method: Method,
812        body: Option<Vec<u8>>,
813        mut response: hyper::Response<Incoming>,
814        mut current_url: url::Url,
815        original_request: LogRequest,
816        started_at: SystemTime,
817        start_time: Instant,
818        max_redirects: u8,
819    ) -> Result<Response, Error> {
820        let mut redirect_count = 0;
821
822        loop {
823            let status = response.status();
824
825            if !status.is_redirection() || redirect_count >= max_redirects {
826                let ended_at = SystemTime::now();
827                let final_response = Response::from(response, current_url).await?;
828                let duration_req = start_time.elapsed();
829
830                #[cfg(feature = "cookies")]
831                {
832                    if !final_response.cookies.is_empty() {
833                        let mut cookie_store = client.cookie_store.write().await;
834                        let domain = final_response.url().host_str().unwrap_or("").to_string();
835                        cookie_store.insert(domain, final_response.cookies.clone());
836                    }
837                }
838
839                let log_response = LogResponse {
840                    headers: if masking::should_mask_sensitive() {
841                        masking::mask_headers(&final_response.headers)
842                    } else {
843                        final_response.headers.clone()
844                    },
845                    body: final_response.text.clone(),
846                    status: final_response.status(),
847                    duration_req,
848                };
849
850                crate::runner::publish(crate::runner::EventBody::Call(
851                    crate::runner::CallLog::Http(Box::new(Log {
852                        request: original_request,
853                        response: log_response,
854                        started_at,
855                        ended_at,
856                    })),
857                ))?;
858
859                return Ok(final_response);
860            }
861
862            // Extract cookies from redirect response
863            #[cfg(feature = "cookies")]
864            {
865                let redirect_cookies: Vec<cookie::Cookie<'static>> = response
866                    .headers()
867                    .get_all("set-cookie")
868                    .iter()
869                    .filter_map(|cookie_header| {
870                        cookie_header.to_str().ok().and_then(|cookie_str| {
871                            cookie::Cookie::parse(cookie_str)
872                                .ok()
873                                .map(|c| c.into_owned())
874                        })
875                    })
876                    .collect();
877
878                if !redirect_cookies.is_empty() {
879                    let mut cookie_store = client.cookie_store.write().await;
880                    let domain = current_url.host_str().unwrap_or("").to_string();
881                    let existing_cookies =
882                        cookie_store.entry(domain.clone()).or_insert_with(Vec::new);
883                    existing_cookies.extend(redirect_cookies);
884                }
885            }
886
887            // Get redirect location
888            let location = match response
889                .headers()
890                .get("location")
891                .and_then(|v| v.to_str().ok())
892            {
893                Some(loc) => loc,
894                None => {
895                    // Some status codes don't require location headers
896                    let ended_at = SystemTime::now();
897                    let final_response = Response::from(response, current_url).await?;
898                    let duration_req = start_time.elapsed();
899
900                    let log_response = LogResponse {
901                        headers: if masking::should_mask_sensitive() {
902                            masking::mask_headers(&final_response.headers)
903                        } else {
904                            final_response.headers.clone()
905                        },
906                        body: final_response.text.clone(),
907                        status: final_response.status(),
908                        duration_req,
909                    };
910
911                    crate::runner::publish(crate::runner::EventBody::Call(
912                        crate::runner::CallLog::Http(Box::new(Log {
913                            request: original_request,
914                            response: log_response,
915                            started_at,
916                            ended_at,
917                        })),
918                    ))?;
919
920                    return Ok(final_response);
921                }
922            };
923
924            // Construct new URL
925            current_url = if location.starts_with("http") {
926                url::Url::parse(location).map_err(|e| eyre::eyre!("Invalid redirect URL: {}", e))?
927            } else {
928                current_url
929                    .join(location)
930                    .map_err(|e| eyre::eyre!("Invalid redirect URL: {}", e))?
931            };
932
933            // Update method for redirect (follow HTTP redirect semantics)
934            if status == StatusCode::SEE_OTHER
935                || (method == Method::POST
936                    && (status == StatusCode::MOVED_PERMANENTLY || status == StatusCode::FOUND))
937            {
938                method = Method::GET;
939            }
940
941            // Build redirect request
942            let redirect_uri: http::Uri = current_url.to_string().parse()?;
943            let mut redirect_req_builder =
944                Request::builder().method(method.clone()).uri(redirect_uri);
945
946            // Add original headers
947            for (name, value) in &headers {
948                redirect_req_builder = redirect_req_builder.header(name, value);
949            }
950
951            // Add cookies for new domain
952            #[cfg(feature = "cookies")]
953            {
954                let cookie_store = client.cookie_store.read().await;
955                if let Some(domain_cookies) = cookie_store.get(current_url.host_str().unwrap_or(""))
956                {
957                    if !domain_cookies.is_empty() {
958                        let cookie_header = domain_cookies
959                            .iter()
960                            .map(|cookie| format!("{}={}", cookie.name(), cookie.value()))
961                            .collect::<Vec<_>>()
962                            .join("; ");
963
964                        if let Ok(cookie_value) = header::HeaderValue::from_str(&cookie_header) {
965                            redirect_req_builder =
966                                redirect_req_builder.header(header::COOKIE, cookie_value);
967                        }
968                    }
969                }
970            }
971
972            let redirect_body = if method == Method::GET {
973                Full::new(Bytes::new())
974            } else {
975                match &body {
976                    Some(body_data) => Full::new(Bytes::from(body_data.clone())),
977                    None => Full::new(Bytes::new()),
978                }
979            };
980
981            let redirect_req = redirect_req_builder.body(redirect_body)?;
982            response = client.inner.request(redirect_req).await?;
983            redirect_count += 1;
984        }
985    }
986
987    pub fn timeout(mut self, timeout: Duration) -> Self {
988        self.timeout = Some(timeout);
989        self
990    }
991
992    pub fn try_clone(&self) -> Option<Self> {
993        Some(Self {
994            client: self.client.clone(),
995            method: self.method.clone(),
996            url: self.url.clone(),
997            headers: self.headers.clone(),
998            body: self.body.clone(),
999            query_params: self.query_params.clone(),
1000            timeout: self.timeout,
1001        })
1002    }
1003
1004    pub fn version(self, _version: Version) -> Self {
1005        // Note: hyper automatically handles HTTP versions
1006        // This method is kept for API compatibility
1007        self
1008    }
1009}