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