Skip to main content

oxihttp_client/
lib.rs

1//! OxiHTTP Client - Pure-Rust HTTP client for the OxiHTTP stack.
2//!
3//! Provides a high-level HTTP client with connection pooling, redirect handling,
4//! retry logic, timeouts, and a fluent request builder API.
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! # async fn example() -> Result<(), oxihttp_core::OxiHttpError> {
10//! use oxihttp_client::Client;
11//!
12//! let client = Client::builder().build()?;
13//! let resp = client.get("http://example.com")?.send().await?;
14//! assert_eq!(resp.status(), http::StatusCode::OK);
15//! # Ok(())
16//! # }
17//! ```
18
19#![forbid(unsafe_code)]
20
21pub mod client_builder;
22pub mod middleware;
23pub mod proxy;
24pub mod redirect;
25pub mod resolver;
26pub mod retry;
27
28#[cfg(feature = "tls")]
29pub mod connector;
30#[cfg(feature = "tls")]
31pub mod request_config;
32#[cfg(feature = "tls")]
33pub mod tls;
34
35#[cfg(feature = "h3")]
36pub mod h3;
37
38#[cfg(feature = "tls")]
39pub use connector::{MaybeHttpsStream, OxiHttpsConnector};
40#[cfg(feature = "tls")]
41pub use request_config::RequestTlsConfig;
42#[cfg(feature = "tls")]
43pub use tls::DangerousNoVerification;
44
45#[cfg(feature = "socks")]
46pub use proxy::Socks5Connector;
47pub use proxy::{ProxyConnector, ProxyKind};
48
49use bytes::Bytes;
50use futures_core::Stream;
51use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri};
52use http_body_util::{BodyExt, Full};
53use hyper::body::Incoming;
54use hyper_util::client::legacy::connect::{Connect, HttpConnector};
55use hyper_util::client::legacy::Client as HyperClient;
56#[cfg(feature = "tls")]
57use hyper_util::rt::TokioExecutor;
58use resolver::BoxResolver;
59use std::pin::Pin;
60use std::str::FromStr;
61use std::sync::Arc;
62use std::task::{Context, Poll};
63use std::time::{Duration, Instant};
64
65#[cfg(feature = "tls")]
66pub(crate) use client_builder::apply_http2_settings;
67pub use client_builder::{ClientBuilder, Http2Settings};
68pub use middleware::{ClientMiddleware, LoggingMiddleware, TimingMiddleware};
69use oxihttp_core::OxiHttpError;
70pub use redirect::RedirectPolicy;
71pub use retry::RetryPolicy;
72
73// ---------------------------------------------------------------------------
74// BodyStream — streaming response body
75// ---------------------------------------------------------------------------
76
77/// An async stream of response body chunks produced by `Response::body_stream()`.
78pub struct BodyStream {
79    inner: http_body_util::BodyStream<Incoming>,
80}
81
82impl Stream for BodyStream {
83    type Item = Result<Bytes, OxiHttpError>;
84
85    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
86        loop {
87            match Pin::new(&mut self.inner).poll_next(cx) {
88                Poll::Ready(Some(Ok(frame))) => {
89                    if let Ok(data) = frame.into_data() {
90                        return Poll::Ready(Some(Ok(data)));
91                    }
92                    // Trailers or other non-data frames — skip and poll again
93                }
94                Poll::Ready(Some(Err(e))) => {
95                    return Poll::Ready(Some(Err(OxiHttpError::Body(e.to_string()))));
96                }
97                Poll::Ready(None) => return Poll::Ready(None),
98                Poll::Pending => return Poll::Pending,
99            }
100        }
101    }
102}
103
104// ---------------------------------------------------------------------------
105// Response
106// ---------------------------------------------------------------------------
107
108/// HTTP response wrapper providing convenience methods for body consumption.
109pub struct Response {
110    inner: http::Response<Incoming>,
111    /// Whether to auto-decompress the response body using Content-Encoding.
112    decompress: bool,
113}
114
115impl Response {
116    /// HTTP status code.
117    pub fn status(&self) -> StatusCode {
118        self.inner.status()
119    }
120
121    /// Response headers.
122    pub fn headers(&self) -> &HeaderMap {
123        self.inner.headers()
124    }
125
126    /// Return the first value of the named response header as a UTF-8 string,
127    /// or `None` if the header is absent or its value is not valid UTF-8.
128    ///
129    /// # Example
130    ///
131    /// ```rust,no_run
132    /// # async fn example() -> Result<(), oxihttp_core::OxiHttpError> {
133    /// use oxihttp_client::Client;
134    ///
135    /// let client = Client::builder().build()?;
136    /// let resp = client.get("http://example.com/new-resource")?.send().await?;
137    /// if let Some(location) = resp.header("location") {
138    ///     println!("redirected to: {location}");
139    /// }
140    /// if let Some(nonce) = resp.header("replay-nonce") {
141    ///     println!("ACME nonce: {nonce}");
142    /// }
143    /// # Ok(())
144    /// # }
145    /// ```
146    pub fn header(&self, name: &str) -> Option<&str> {
147        self.inner
148            .headers()
149            .get(name)
150            .and_then(|v| v.to_str().ok())
151    }
152
153    /// HTTP version used for this response.
154    pub fn version(&self) -> http::Version {
155        self.inner.version()
156    }
157
158    /// Content-Length header as u64 if present and valid.
159    pub fn content_length(&self) -> Option<u64> {
160        self.inner
161            .headers()
162            .get(http::header::CONTENT_LENGTH)
163            .and_then(|v| v.to_str().ok())
164            .and_then(|s| s.parse().ok())
165    }
166
167    /// Consume the body and return raw bytes, auto-decompressing if enabled.
168    pub async fn body_bytes(self) -> Result<Bytes, OxiHttpError> {
169        let decompress = self.decompress;
170        let ce = self
171            .inner
172            .headers()
173            .get(http::header::CONTENT_ENCODING)
174            .and_then(|v| v.to_str().ok())
175            .map(|s| s.to_ascii_lowercase());
176
177        let raw = self
178            .inner
179            .into_body()
180            .collect()
181            .await
182            .map(|c| c.to_bytes())
183            .map_err(|e| OxiHttpError::Body(e.to_string()))?;
184
185        if decompress {
186            match ce.as_deref() {
187                Some("gzip") => {
188                    #[cfg(feature = "decompression")]
189                    {
190                        let decompressed = oxiarc_deflate::gzip_decompress(&raw).map_err(|e| {
191                            OxiHttpError::Body(format!("gzip decompression error: {e}"))
192                        })?;
193                        return Ok(Bytes::from(decompressed));
194                    }
195                    #[cfg(not(feature = "decompression"))]
196                    {
197                        // Feature not enabled; return raw bytes
198                    }
199                }
200                Some("deflate") => {
201                    #[cfg(feature = "decompression")]
202                    {
203                        let decompressed = oxiarc_deflate::zlib_decompress(&raw)
204                            .or_else(|_| {
205                                // Some servers send raw DEFLATE without the zlib wrapper
206                                oxiarc_deflate::inflate(&raw).map_err(|e| {
207                                    OxiHttpError::Body(format!("deflate decompression error: {e}"))
208                                })
209                            })
210                            .map_err(|e| {
211                                OxiHttpError::Body(format!("deflate decompression error: {e}"))
212                            })?;
213                        return Ok(Bytes::from(decompressed));
214                    }
215                    #[cfg(not(feature = "decompression"))]
216                    {
217                        // Feature not enabled; return raw bytes
218                    }
219                }
220                _ => {}
221            }
222        }
223
224        Ok(raw)
225    }
226
227    /// Consume the body and return it as a UTF-8 string.
228    pub async fn body_text(self) -> Result<String, OxiHttpError> {
229        let bytes = self.body_bytes().await?;
230        String::from_utf8(bytes.to_vec())
231            .map_err(|e| OxiHttpError::Body(format!("invalid UTF-8: {e}")))
232    }
233
234    /// Consume the body and deserialize it as JSON.
235    pub async fn body_json<T: serde::de::DeserializeOwned>(self) -> Result<T, OxiHttpError> {
236        let bytes = self.body_bytes().await?;
237        serde_json::from_slice(&bytes).map_err(|e| OxiHttpError::Json(e.to_string()))
238    }
239
240    /// Return an error if the response status is a client (4xx) or server (5xx) error.
241    ///
242    /// Returns `Ok(self)` for success and redirect status codes.
243    pub fn error_for_status(self) -> Result<Self, OxiHttpError> {
244        let status = self.inner.status();
245        if status.is_client_error() || status.is_server_error() {
246            Err(OxiHttpError::Body(format!(
247                "HTTP error: {} {}",
248                status.as_u16(),
249                status.canonical_reason().unwrap_or("Unknown")
250            )))
251        } else {
252            Ok(self)
253        }
254    }
255
256    /// Returns the `Content-Type` header value as a string, if present.
257    pub fn content_type(&self) -> Option<&str> {
258        self.inner
259            .headers()
260            .get(http::header::CONTENT_TYPE)
261            .and_then(|v| v.to_str().ok())
262    }
263
264    /// Parse all `Set-Cookie` response headers using `oxihttp_core::Cookie::parse_set_cookie`.
265    ///
266    /// Returns an empty `Vec` when there are no `Set-Cookie` headers or none parse
267    /// successfully.
268    pub fn cookies(&self) -> Vec<oxihttp_core::Cookie> {
269        self.inner
270            .headers()
271            .get_all(http::header::SET_COOKIE)
272            .iter()
273            .filter_map(|v| v.to_str().ok())
274            .filter_map(oxihttp_core::Cookie::parse_set_cookie)
275            .collect()
276    }
277
278    /// Consume the response and return the body as an async stream of chunks.
279    pub fn body_stream(self) -> BodyStream {
280        BodyStream {
281            inner: http_body_util::BodyStream::new(self.inner.into_body()),
282        }
283    }
284}
285
286impl std::fmt::Debug for Response {
287    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288        f.debug_struct("Response")
289            .field("status", &self.inner.status())
290            .field("version", &self.inner.version())
291            .field("headers", self.inner.headers())
292            .finish()
293    }
294}
295
296// ---------------------------------------------------------------------------
297// RequestBuilder
298// ---------------------------------------------------------------------------
299
300/// Builder for a single HTTP request.
301///
302/// Created via `Client::get()`, `Client::post()`, etc.
303pub struct RequestBuilder<C = HttpConnector> {
304    client: HyperClient<C, Full<Bytes>>,
305    method: Method,
306    uri: Uri,
307    headers: HeaderMap,
308    body: Bytes,
309    timeout: Option<Duration>,
310    redirect_policy: RedirectPolicy,
311    retry_policy: Option<RetryPolicy>,
312    decompression: bool,
313    middleware: Vec<Arc<dyn ClientMiddleware>>,
314    cookie_jar: Option<Arc<std::sync::Mutex<oxihttp_core::CookieJar>>>,
315}
316
317impl<C> RequestBuilder<C>
318where
319    C: Connect + Clone + Send + Sync + 'static,
320{
321    #[allow(clippy::too_many_arguments)]
322    fn new(
323        client: HyperClient<C, Full<Bytes>>,
324        method: Method,
325        uri: Uri,
326        redirect_policy: RedirectPolicy,
327        retry_policy: Option<RetryPolicy>,
328        decompression: bool,
329        middleware: Vec<Arc<dyn ClientMiddleware>>,
330        cookie_jar: Option<Arc<std::sync::Mutex<oxihttp_core::CookieJar>>>,
331    ) -> Self {
332        Self {
333            client,
334            method,
335            uri,
336            headers: HeaderMap::new(),
337            body: Bytes::new(),
338            timeout: None,
339            redirect_policy,
340            retry_policy,
341            decompression,
342            middleware,
343            cookie_jar,
344        }
345    }
346
347    /// Add a request header.
348    pub fn header(mut self, key: &str, value: &str) -> Result<Self, OxiHttpError> {
349        let k =
350            HeaderName::from_str(key).map_err(|e| OxiHttpError::InvalidHeader(e.to_string()))?;
351        let v =
352            HeaderValue::from_str(value).map_err(|e| OxiHttpError::InvalidHeader(e.to_string()))?;
353        self.headers.insert(k, v);
354        Ok(self)
355    }
356
357    /// Add multiple headers from a `HeaderMap`.
358    pub fn headers(mut self, map: HeaderMap) -> Self {
359        self.headers.extend(map);
360        self
361    }
362
363    /// Set a Bearer token for the Authorization header.
364    pub fn bearer_token(mut self, token: &str) -> Result<Self, OxiHttpError> {
365        let v = HeaderValue::from_str(&format!("Bearer {token}"))
366            .map_err(|e| OxiHttpError::InvalidHeader(e.to_string()))?;
367        self.headers.insert(http::header::AUTHORIZATION, v);
368        Ok(self)
369    }
370
371    /// Set Basic authentication for the Authorization header.
372    pub fn basic_auth(
373        mut self,
374        username: &str,
375        password: Option<&str>,
376    ) -> Result<Self, OxiHttpError> {
377        let credentials = match password {
378            Some(pw) => format!("{username}:{pw}"),
379            None => format!("{username}:"),
380        };
381        let encoded = base64_encode(credentials.as_bytes());
382        let v = HeaderValue::from_str(&format!("Basic {encoded}"))
383            .map_err(|e| OxiHttpError::InvalidHeader(e.to_string()))?;
384        self.headers.insert(http::header::AUTHORIZATION, v);
385        Ok(self)
386    }
387
388    /// Set the request body as raw bytes.
389    pub fn body(mut self, b: impl Into<Bytes>) -> Self {
390        self.body = b.into();
391        self
392    }
393
394    /// Set the request body as JSON, automatically setting the Content-Type header.
395    pub fn json<T: serde::Serialize>(mut self, value: &T) -> Result<Self, OxiHttpError> {
396        let json_bytes =
397            serde_json::to_vec(value).map_err(|e| OxiHttpError::Json(e.to_string()))?;
398        self.body = Bytes::from(json_bytes);
399        let ct = HeaderValue::from_static("application/json");
400        self.headers.insert(http::header::CONTENT_TYPE, ct);
401        Ok(self)
402    }
403
404    /// Set the request body as URL-encoded form data.
405    pub fn form(mut self, form_body: &oxihttp_core::FormBody) -> Self {
406        self.body = form_body.clone().build();
407        if let Ok(ct) = HeaderValue::from_str("application/x-www-form-urlencoded") {
408            self.headers.insert(http::header::CONTENT_TYPE, ct);
409        }
410        self
411    }
412
413    /// Set the request body from a [`MultipartBuilder`], automatically setting
414    /// the `Content-Type: multipart/form-data; boundary=…` header.
415    ///
416    /// The Content-Type is only set if the caller has not already provided one.
417    /// This allows overriding the header with an explicit `.header()` call made
418    /// *before* `.multipart()`.
419    ///
420    /// [`MultipartBuilder`]: oxihttp_core::MultipartBuilder
421    ///
422    /// # Example
423    ///
424    /// ```rust,no_run
425    /// # async fn example() -> Result<(), oxihttp_core::OxiHttpError> {
426    /// use oxihttp_client::Client;
427    /// use oxihttp_core::MultipartBuilder;
428    ///
429    /// let client = Client::builder().build()?;
430    /// let builder = MultipartBuilder::new().add_text("field", "value");
431    /// let resp = client.post("http://example.com/upload")?
432    ///     .multipart(builder)
433    ///     .send()
434    ///     .await?;
435    /// # Ok(())
436    /// # }
437    /// ```
438    pub fn multipart(mut self, builder: oxihttp_core::MultipartBuilder) -> Self {
439        // Retrieve content_type BEFORE build() because build() consumes the builder.
440        let ct_str = builder.content_type();
441        self.body = builder.build();
442        // Only set Content-Type when the caller has not already provided one.
443        if !self.headers.contains_key(http::header::CONTENT_TYPE) {
444            if let Ok(ct) = HeaderValue::from_str(&ct_str) {
445                self.headers.insert(http::header::CONTENT_TYPE, ct);
446            }
447        }
448        self
449    }
450
451    /// Set a per-request timeout.
452    pub fn timeout(mut self, duration: Duration) -> Self {
453        self.timeout = Some(duration);
454        self
455    }
456
457    /// Send the request and return the response.
458    ///
459    /// Respects retry policy and per-request timeout.
460    /// Before the first attempt the `before_request` hook is called on each
461    /// registered middleware; after a successful response `after_response` is
462    /// called with the final status and elapsed wall-clock time.
463    pub async fn send(self) -> Result<Response, OxiHttpError> {
464        let RequestBuilder {
465            client,
466            method,
467            uri,
468            headers,
469            body,
470            timeout,
471            redirect_policy,
472            retry_policy,
473            decompression,
474            middleware,
475            cookie_jar,
476        } = self;
477
478        // --- middleware: before_request -----------------------------------
479        {
480            let ctx = middleware::RequestContext {
481                method: &method,
482                uri: &uri,
483                headers: &headers,
484            };
485            for mw in &middleware {
486                mw.before_request(&ctx);
487            }
488        }
489
490        let start = Instant::now();
491
492        let max_attempts = retry_policy
493            .as_ref()
494            .map(|p| p.max_retries + 1)
495            .unwrap_or(1);
496
497        for attempt in 0..max_attempts {
498            let result = {
499                let fut = send_inner(
500                    &client,
501                    method.clone(),
502                    uri.clone(),
503                    body.clone(),
504                    headers.clone(),
505                    &redirect_policy,
506                    decompression,
507                    cookie_jar.clone(),
508                );
509                if let Some(dur) = timeout {
510                    match tokio::time::timeout(dur, fut).await {
511                        Ok(r) => r,
512                        Err(_) => Err(OxiHttpError::Timeout(format!(
513                            "request timed out after {}ms",
514                            dur.as_millis()
515                        ))),
516                    }
517                } else {
518                    fut.await
519                }
520            };
521
522            match result {
523                Ok(resp) => {
524                    if let Some(ref policy) = retry_policy {
525                        if attempt < max_attempts - 1
526                            && policy.should_retry_status(resp.status().as_u16())
527                        {
528                            let delay = policy.backoff_delay(attempt);
529                            tokio::time::sleep(delay).await;
530                            continue;
531                        }
532                    }
533                    // --- middleware: after_response ----------------------
534                    let elapsed = start.elapsed();
535                    let resp_ctx = middleware::ResponseContext {
536                        status: resp.status(),
537                        elapsed,
538                    };
539                    for mw in &middleware {
540                        mw.after_response(&resp_ctx);
541                    }
542                    return Ok(resp);
543                }
544                Err(e) => {
545                    if let Some(ref policy) = retry_policy {
546                        let should_retry = match &e {
547                            OxiHttpError::Hyper(_) => policy.retry_on_connection_error,
548                            OxiHttpError::Timeout(_) => policy.retry_on_timeout,
549                            OxiHttpError::Io(_) => policy.retry_on_connection_error,
550                            _ => false,
551                        };
552                        if should_retry && attempt < max_attempts - 1 {
553                            let delay = policy.backoff_delay(attempt);
554                            tokio::time::sleep(delay).await;
555                            continue;
556                        }
557                    }
558                    return Err(e);
559                }
560            }
561        }
562
563        // This is unreachable when max_attempts >= 1, but needed for the type checker.
564        Err(OxiHttpError::Hyper("max retries exceeded".to_string()))
565    }
566}
567
568/// Inner request executor: handles redirect loop and returns a `Response`.
569///
570/// All clone-able fields are passed by value so the outer retry loop can
571/// re-invoke this function on each attempt.
572#[allow(clippy::too_many_arguments)]
573async fn send_inner<C>(
574    client: &HyperClient<C, Full<Bytes>>,
575    mut method: Method,
576    mut uri: Uri,
577    mut body: Bytes,
578    headers: HeaderMap,
579    redirect_policy: &RedirectPolicy,
580    decompression: bool,
581    cookie_jar: Option<Arc<std::sync::Mutex<oxihttp_core::CookieJar>>>,
582) -> Result<Response, OxiHttpError>
583where
584    C: Connect + Clone + Send + Sync + 'static,
585{
586    let max_redirects = redirect_policy.max_redirects();
587    let mut redirect_count: usize = 0;
588
589    loop {
590        let mut req_builder = http::Request::builder()
591            .method(method.clone())
592            .uri(uri.clone());
593        for (k, v) in &headers {
594            req_builder = req_builder.header(k, v);
595        }
596
597        // Inject Accept-Encoding when decompression is enabled and the user
598        // hasn't already set the header.
599        if decompression && !headers.contains_key(http::header::ACCEPT_ENCODING) {
600            req_builder = req_builder.header(
601                http::header::ACCEPT_ENCODING,
602                HeaderValue::from_static("gzip, deflate"),
603            );
604        }
605
606        let mut req = req_builder
607            .body(Full::new(body.clone()))
608            .map_err(|e| OxiHttpError::Http(Arc::new(e)))?;
609
610        // Inject cookies from jar for this URL
611        if let Some(ref jar) = cookie_jar {
612            if let Ok(guard) = jar.lock() {
613                if let Some(cookie_header) = guard.to_cookie_header_for_url(&uri) {
614                    if let Ok(hv) = HeaderValue::from_str(&cookie_header) {
615                        req.headers_mut().insert(http::header::COOKIE, hv);
616                    }
617                }
618            }
619        }
620
621        let resp = client
622            .request(req)
623            .await
624            .map_err(|e| OxiHttpError::Hyper(e.to_string()))?;
625
626        // Persist Set-Cookie headers into jar
627        if let Some(ref jar) = cookie_jar {
628            if let Ok(mut guard) = jar.lock() {
629                guard.add_from_response_headers(resp.headers(), &uri);
630            }
631        }
632
633        // Check for redirect
634        let status = resp.status();
635        if redirect::is_redirect_status(status) {
636            if let Some(max) = max_redirects {
637                if max == 0 || redirect_count >= max {
638                    // Return the redirect response as-is when not following
639                    if max == 0 {
640                        return Ok(Response {
641                            inner: resp,
642                            decompress: decompression,
643                        });
644                    }
645                    return Err(OxiHttpError::Redirect(format!(
646                        "too many redirects (max: {max})"
647                    )));
648                }
649            }
650            redirect_count += 1;
651
652            // Extract the Location header
653            let location = resp
654                .headers()
655                .get(http::header::LOCATION)
656                .and_then(|v| v.to_str().ok())
657                .ok_or_else(|| {
658                    OxiHttpError::Redirect("redirect response missing Location header".to_string())
659                })?;
660
661            // Resolve relative URIs
662            let new_uri = resolve_redirect_uri(&uri, location)?;
663
664            // Update method (POST -> GET for 301/302/303)
665            let new_method = redirect::redirect_method(status, &method);
666
667            // Clear body if method changed away from body-carrying
668            if !redirect::should_preserve_body(status) {
669                body = Bytes::new();
670            }
671
672            method = new_method;
673            uri = new_uri;
674            continue;
675        }
676
677        return Ok(Response {
678            inner: resp,
679            decompress: decompression,
680        });
681    }
682}
683
684/// Resolve a redirect URI, handling both absolute and relative URIs.
685fn resolve_redirect_uri(base: &Uri, location: &str) -> Result<Uri, OxiHttpError> {
686    // Try parsing as absolute URI first
687    if let Ok(uri) = Uri::from_str(location) {
688        if uri.scheme().is_some() {
689            return Ok(uri);
690        }
691    }
692
693    // Relative URI: combine with base
694    let scheme = base.scheme_str().unwrap_or("http");
695    let authority = base.authority().map(|a| a.as_str()).unwrap_or("localhost");
696    let full = format!("{scheme}://{authority}{location}");
697    Uri::from_str(&full).map_err(|e| OxiHttpError::InvalidUri(Arc::new(e)))
698}
699
700// TlsRebuildConfig — stores all TLS + pool params needed to re-create an
701// HttpsClient with modified trust settings (used by with_request_tls_config).
702#[cfg(feature = "tls")]
703#[derive(Clone)]
704pub(crate) struct TlsRebuildConfig {
705    pub trusted_certs_der: Vec<Vec<u8>>,
706    pub alpn: Vec<String>,
707    pub accept_invalid_certs: bool,
708    pub use_webpki_roots: bool,
709    pub key_log_path: Option<std::path::PathBuf>,
710    pub early_data: bool,
711    pub connect_timeout: Option<Duration>,
712    pub tcp_nodelay: Option<bool>,
713    pub tcp_keepalive: Option<Duration>,
714    pub http2_settings: Option<Http2Settings>,
715    pub pool_max_idle_per_host: Option<usize>,
716    pub pool_idle_timeout: Option<Duration>,
717    /// Optional custom certificate verifier injected via
718    /// [`ClientBuilder::with_custom_cert_verifier`].  When `Some`, this verifier
719    /// takes precedence over all other trust-store settings.
720    pub custom_cert_verifier: Option<Arc<dyn rustls::client::danger::ServerCertVerifier>>,
721}
722
723#[cfg(feature = "tls")]
724impl std::fmt::Debug for TlsRebuildConfig {
725    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
726        f.debug_struct("TlsRebuildConfig")
727            .field("trusted_certs_der_count", &self.trusted_certs_der.len())
728            .field("alpn", &self.alpn)
729            .field("accept_invalid_certs", &self.accept_invalid_certs)
730            .field("use_webpki_roots", &self.use_webpki_roots)
731            .field("early_data", &self.early_data)
732            .field("connect_timeout", &self.connect_timeout)
733            .field("tcp_nodelay", &self.tcp_nodelay)
734            .field("tcp_keepalive", &self.tcp_keepalive)
735            .field(
736                "custom_cert_verifier",
737                &self.custom_cert_verifier.as_ref().map(|_| "<dyn ServerCertVerifier>"),
738            )
739            .finish_non_exhaustive()
740    }
741}
742
743// ---------------------------------------------------------------------------
744// Client<C>
745// ---------------------------------------------------------------------------
746
747/// HTTP client with connection pooling, redirect handling, and retry support.
748///
749/// The default type parameter `C = HttpConnector` gives a plain HTTP-only
750/// client. Use `HttpsClient` (feature `tls`) for a TLS-capable client.
751///
752/// Created via `Client::builder().build()` or `Client::builder().build_https()`.
753#[derive(Clone)]
754pub struct Client<C = HttpConnector> {
755    pub(crate) inner: HyperClient<C, Full<Bytes>>,
756    pub(crate) redirect_policy: RedirectPolicy,
757    pub(crate) retry_policy: Option<RetryPolicy>,
758    pub(crate) default_headers: HeaderMap,
759    pub(crate) connect_timeout: Option<Duration>,
760    pub(crate) read_timeout: Option<Duration>,
761    pub(crate) decompression: bool,
762    /// Ordered list of middleware interceptors applied to every request.
763    pub(crate) middleware: Vec<Arc<dyn ClientMiddleware>>,
764    /// Optional shared cookie jar for automatic RFC 6265 cookie management.
765    pub(crate) cookie_jar: Option<Arc<std::sync::Mutex<oxihttp_core::CookieJar>>>,
766    /// TLS rebuild parameters, populated only for [`HttpsClient`] instances.
767    ///
768    /// Used by [`HttpsClient::with_request_tls_config`] to construct a fresh
769    /// client with modified TLS trust settings.
770    #[cfg(feature = "tls")]
771    pub(crate) tls_rebuild: Option<Arc<TlsRebuildConfig>>,
772}
773
774/// A TLS-capable client that supports both `http://` and `https://` URIs.
775///
776/// Created via `Client::builder().build_https()`.
777#[cfg(feature = "tls")]
778pub type HttpsClient = Client<OxiHttpsConnector<HttpConnector>>;
779
780/// An HTTP client using a custom DNS resolver (plain HTTP).
781///
782/// Created via `Client::builder().with_resolver(r).build_with_resolver()`.
783pub type ResolverClient = Client<HttpConnector<BoxResolver>>;
784
785/// An HTTP client using a custom DNS resolver with TLS support.
786///
787/// Created via `Client::builder().with_resolver(r).build_https_with_resolver()`.
788#[cfg(feature = "tls")]
789pub type ResolverHttpsClient =
790    Client<crate::connector::OxiHttpsConnector<HttpConnector<BoxResolver>>>;
791
792/// Provide `builder()` only on the default `Client<HttpConnector>` variant so
793/// that type-inference works without annotation at call sites.
794impl Client<HttpConnector> {
795    /// Return a `ClientBuilder` for configuring a new client.
796    pub fn builder() -> ClientBuilder {
797        ClientBuilder::new()
798    }
799}
800
801// ---------------------------------------------------------------------------
802// HttpsClient — per-request TLS config override
803// ---------------------------------------------------------------------------
804
805/// Per-request TLS overrides for [`HttpsClient`].
806///
807/// These methods are only available on clients built via
808/// [`ClientBuilder::build_https`].
809#[cfg(feature = "tls")]
810impl Client<OxiHttpsConnector<HttpConnector>> {
811    /// Return a new `HttpsClient` that shares all settings with `self` except
812    /// for the TLS trust configuration, which is replaced by `override_cfg`.
813    ///
814    /// The returned client has its **own independent connection pool**.  Use it
815    /// to make requests that require different TLS trust than the original
816    /// client (e.g., certificate pinning to a different CA).
817    ///
818    /// # Errors
819    ///
820    /// Returns an error if the TLS connector cannot be built from the merged
821    /// configuration (e.g., a supplied DER-encoded certificate is malformed).
822    ///
823    /// # Notes on connection pooling
824    ///
825    /// Because the returned client uses a separate pool, it will always open a
826    /// fresh connection even if the original client already has an idle
827    /// connection to the same host.  This guarantees that the override TLS
828    /// config is applied.
829    ///
830    /// # Example
831    ///
832    /// ```no_run
833    /// # use oxihttp_client::{Client, request_config::RequestTlsConfig};
834    /// # async fn example() -> Result<(), oxihttp_core::OxiHttpError> {
835    /// let global_client = Client::builder()
836    ///     .with_trusted_cert_der(vec![/* CA cert A DER … */])
837    ///     .build_https()?;
838    ///
839    /// // Override: trust CA cert B instead of CA cert A for a single request.
840    /// let pinned = global_client.with_request_tls_config(
841    ///     RequestTlsConfig::new().with_trusted_cert(vec![/* CA cert B DER … */]),
842    /// )?;
843    /// let resp = pinned.get("https://pinned-endpoint.example.com")?.send().await?;
844    /// # Ok(())
845    /// # }
846    /// ```
847    pub fn with_request_tls_config(
848        &self,
849        override_cfg: RequestTlsConfig,
850    ) -> Result<Self, OxiHttpError> {
851        use crate::connector::OxiHttpsConnector;
852
853        let base = self.tls_rebuild.as_ref().ok_or_else(|| {
854            OxiHttpError::Tls(
855                "client has no TLS rebuild config (was it built with build_https()?)".to_string(),
856            )
857        })?;
858
859        // Merge: per-request overrides win over global config.
860        let effective_certs = if override_cfg.trusted_cert_ders.is_empty() {
861            base.trusted_certs_der.as_slice()
862        } else {
863            override_cfg.trusted_cert_ders.as_slice()
864        };
865        let accept_invalid = base.accept_invalid_certs || override_cfg.accept_invalid_certs;
866
867        // If a custom verifier is installed on the base config, use the
868        // verifier-path builder so the custom verifier is preserved.
869        let new_tls = if let Some(ref verifier) = base.custom_cert_verifier {
870            tls::build_tls_connector_with_verifier(
871                Arc::clone(verifier),
872                &base.alpn,
873                base.early_data,
874            )?
875        } else {
876            tls::build_tls_connector(
877                effective_certs,
878                &base.alpn,
879                accept_invalid,
880                base.use_webpki_roots,
881                base.key_log_path.clone(),
882                base.early_data,
883            )?
884        };
885
886        let mut http = HttpConnector::new();
887        http.enforce_http(false);
888        if let Some(dur) = base.connect_timeout {
889            http.set_connect_timeout(Some(dur));
890        }
891        if let Some(nodelay) = base.tcp_nodelay {
892            http.set_nodelay(nodelay);
893        }
894        if let Some(ka) = base.tcp_keepalive {
895            http.set_keepalive(Some(ka));
896        }
897        let https_connector = OxiHttpsConnector::new(http, new_tls);
898
899        let mut hb = HyperClient::builder(TokioExecutor::new());
900        if let Some(n) = base.pool_max_idle_per_host {
901            hb.pool_max_idle_per_host(n);
902        }
903        if let Some(dur) = base.pool_idle_timeout {
904            hb.pool_idle_timeout(dur);
905        }
906        if let Some(ref h2) = base.http2_settings {
907            apply_http2_settings(&mut hb, h2);
908        }
909
910        // Build a new TlsRebuildConfig reflecting the merged settings so that
911        // further calls to `with_request_tls_config` on the returned client
912        // start from a consistent state.
913        let new_rebuild = Arc::new(TlsRebuildConfig {
914            trusted_certs_der: effective_certs.to_vec(),
915            alpn: base.alpn.clone(),
916            accept_invalid_certs: accept_invalid,
917            use_webpki_roots: base.use_webpki_roots,
918            key_log_path: base.key_log_path.clone(),
919            early_data: base.early_data,
920            connect_timeout: base.connect_timeout,
921            tcp_nodelay: base.tcp_nodelay,
922            tcp_keepalive: base.tcp_keepalive,
923            http2_settings: base.http2_settings.clone(),
924            pool_max_idle_per_host: base.pool_max_idle_per_host,
925            pool_idle_timeout: base.pool_idle_timeout,
926            custom_cert_verifier: base.custom_cert_verifier.clone(),
927        });
928
929        Ok(Client {
930            inner: hb.build(https_connector),
931            redirect_policy: self.redirect_policy.clone(),
932            retry_policy: self.retry_policy.clone(),
933            default_headers: self.default_headers.clone(),
934            connect_timeout: self.connect_timeout,
935            read_timeout: self.read_timeout,
936            decompression: self.decompression,
937            middleware: self.middleware.clone(),
938            cookie_jar: self.cookie_jar.clone(),
939            tls_rebuild: Some(new_rebuild),
940        })
941    }
942}
943
944impl<C> Client<C>
945where
946    C: Connect + Clone + Send + Sync + 'static,
947{
948    /// Create a request builder for the given method and URL.
949    fn request_builder(
950        &self,
951        method: Method,
952        url: &str,
953    ) -> Result<RequestBuilder<C>, OxiHttpError> {
954        let uri = Uri::from_str(url)?;
955        let mut rb = RequestBuilder::new(
956            self.inner.clone(),
957            method,
958            uri,
959            self.redirect_policy.clone(),
960            self.retry_policy.clone(),
961            self.decompression,
962            self.middleware.clone(),
963            self.cookie_jar.clone(),
964        );
965        // Apply default headers
966        for (k, v) in &self.default_headers {
967            rb.headers.insert(k.clone(), v.clone());
968        }
969        Ok(rb)
970    }
971
972    /// Build a GET request for the given URL.
973    pub fn get(&self, url: &str) -> Result<RequestBuilder<C>, OxiHttpError> {
974        self.request_builder(Method::GET, url)
975    }
976
977    /// Build a POST request for the given URL.
978    pub fn post(&self, url: &str) -> Result<RequestBuilder<C>, OxiHttpError> {
979        self.request_builder(Method::POST, url)
980    }
981
982    /// Build a PUT request for the given URL.
983    pub fn put(&self, url: &str) -> Result<RequestBuilder<C>, OxiHttpError> {
984        self.request_builder(Method::PUT, url)
985    }
986
987    /// Build a DELETE request for the given URL.
988    pub fn delete(&self, url: &str) -> Result<RequestBuilder<C>, OxiHttpError> {
989        self.request_builder(Method::DELETE, url)
990    }
991
992    /// Build a PATCH request for the given URL.
993    pub fn patch(&self, url: &str) -> Result<RequestBuilder<C>, OxiHttpError> {
994        self.request_builder(Method::PATCH, url)
995    }
996
997    /// Build a HEAD request for the given URL.
998    pub fn head(&self, url: &str) -> Result<RequestBuilder<C>, OxiHttpError> {
999        self.request_builder(Method::HEAD, url)
1000    }
1001
1002    /// Execute a pre-built `http::Request`.
1003    pub async fn execute(&self, req: http::Request<Full<Bytes>>) -> Result<Response, OxiHttpError> {
1004        let resp = self
1005            .inner
1006            .request(req)
1007            .await
1008            .map_err(|e| OxiHttpError::Hyper(e.to_string()))?;
1009        Ok(Response {
1010            inner: resp,
1011            decompress: self.decompression,
1012        })
1013    }
1014
1015    /// Convenience: GET the URL and return the response body as bytes.
1016    pub async fn get_bytes(&self, url: &str) -> Result<Bytes, OxiHttpError> {
1017        let resp = self.get(url)?.send().await?;
1018        resp.error_for_status()?.body_bytes().await
1019    }
1020
1021    /// Convenience: GET the URL and deserialize the JSON response body.
1022    pub async fn get_json<T: serde::de::DeserializeOwned>(
1023        &self,
1024        url: &str,
1025    ) -> Result<T, OxiHttpError> {
1026        let resp = self.get(url)?.send().await?;
1027        resp.error_for_status()?.body_json().await
1028    }
1029
1030    /// Convenience: POST JSON and deserialize the response.
1031    pub async fn post_json<T: serde::Serialize, R: serde::de::DeserializeOwned>(
1032        &self,
1033        url: &str,
1034        body: &T,
1035    ) -> Result<R, OxiHttpError> {
1036        let resp = self.post(url)?.json(body)?.send().await?;
1037        resp.error_for_status()?.body_json().await
1038    }
1039
1040    /// Returns a reference to the retry policy, if configured.
1041    pub fn retry_policy(&self) -> Option<&RetryPolicy> {
1042        self.retry_policy.as_ref()
1043    }
1044
1045    /// Returns a reference to the connect timeout, if set.
1046    pub fn connect_timeout(&self) -> Option<Duration> {
1047        self.connect_timeout
1048    }
1049
1050    /// Returns a reference to the read timeout, if set.
1051    pub fn read_timeout(&self) -> Option<Duration> {
1052        self.read_timeout
1053    }
1054}
1055
1056impl<C> std::fmt::Debug for Client<C> {
1057    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1058        f.debug_struct("Client")
1059            .field("redirect_policy", &self.redirect_policy)
1060            .field("retry_policy", &self.retry_policy)
1061            .field("default_headers_count", &self.default_headers.len())
1062            .finish()
1063    }
1064}
1065
1066/// Simple base64 encoding (RFC 4648) without external dependency.
1067fn base64_encode(data: &[u8]) -> String {
1068    const CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1069    let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
1070    for chunk in data.chunks(3) {
1071        let b0 = chunk[0] as u32;
1072        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
1073        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
1074        let triple = (b0 << 16) | (b1 << 8) | b2;
1075
1076        result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
1077        result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
1078        if chunk.len() > 1 {
1079            result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
1080        } else {
1081            result.push('=');
1082        }
1083        if chunk.len() > 2 {
1084            result.push(CHARS[(triple & 0x3F) as usize] as char);
1085        } else {
1086            result.push('=');
1087        }
1088    }
1089    result
1090}
1091
1092// ---------------------------------------------------------------------------
1093// Unit tests
1094// ---------------------------------------------------------------------------
1095
1096#[cfg(test)]
1097mod tests {
1098    use super::*;
1099    use oxihttp_core::MultipartBuilder;
1100
1101    /// Helper: build a plain-HTTP client and a POST RequestBuilder targeting a
1102    /// dummy URL. The builder is never actually sent, so the URL doesn't need to
1103    /// resolve — we only inspect the headers that would be set.
1104    fn post_builder() -> RequestBuilder {
1105        let client = Client::builder().build().expect("client build");
1106        client
1107            .post("http://127.0.0.1:0/test")
1108            .expect("request builder")
1109    }
1110
1111    /// `.multipart()` without a prior Content-Type must auto-set
1112    /// `multipart/form-data; boundary=…` including the exact boundary value.
1113    #[test]
1114    fn multipart_sets_content_type_automatically() {
1115        let mp = MultipartBuilder::new().add_text("field", "value");
1116        // Capture boundary before the builder is consumed by .multipart().
1117        let expected_boundary = mp.boundary().to_owned();
1118
1119        let rb = post_builder().multipart(mp);
1120
1121        let ct = rb
1122            .headers
1123            .get(http::header::CONTENT_TYPE)
1124            .and_then(|v| v.to_str().ok())
1125            .expect("Content-Type header must be set after .multipart()");
1126
1127        assert!(
1128            ct.starts_with("multipart/form-data; boundary="),
1129            "Content-Type must start with multipart/form-data; boundary= but got: {ct}"
1130        );
1131        assert!(
1132            ct.contains(&expected_boundary),
1133            "Content-Type must contain the boundary '{expected_boundary}' but got: {ct}"
1134        );
1135    }
1136
1137    /// If the caller sets Content-Type *before* `.multipart()`, the explicit
1138    /// header must be preserved (not overridden by the auto-detection).
1139    #[test]
1140    fn multipart_does_not_override_explicit_content_type() {
1141        let mp = MultipartBuilder::new().add_text("x", "y");
1142
1143        let rb = post_builder()
1144            .header("content-type", "application/octet-stream")
1145            .expect("header set")
1146            .multipart(mp);
1147
1148        let ct = rb
1149            .headers
1150            .get(http::header::CONTENT_TYPE)
1151            .and_then(|v| v.to_str().ok())
1152            .expect("Content-Type header must be present");
1153
1154        assert_eq!(
1155            ct, "application/octet-stream",
1156            "explicit Content-Type must not be overridden by .multipart()"
1157        );
1158    }
1159}