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.headers().get(name).and_then(|v| v.to_str().ok())
148    }
149
150    /// HTTP version used for this response.
151    pub fn version(&self) -> http::Version {
152        self.inner.version()
153    }
154
155    /// Content-Length header as u64 if present and valid.
156    pub fn content_length(&self) -> Option<u64> {
157        self.inner
158            .headers()
159            .get(http::header::CONTENT_LENGTH)
160            .and_then(|v| v.to_str().ok())
161            .and_then(|s| s.parse().ok())
162    }
163
164    /// Consume the body and return raw bytes, auto-decompressing if enabled.
165    pub async fn body_bytes(self) -> Result<Bytes, OxiHttpError> {
166        let decompress = self.decompress;
167        let ce = self
168            .inner
169            .headers()
170            .get(http::header::CONTENT_ENCODING)
171            .and_then(|v| v.to_str().ok())
172            .map(|s| s.to_ascii_lowercase());
173
174        let raw = self
175            .inner
176            .into_body()
177            .collect()
178            .await
179            .map(|c| c.to_bytes())
180            .map_err(|e| OxiHttpError::Body(e.to_string()))?;
181
182        if decompress {
183            match ce.as_deref() {
184                Some("gzip") => {
185                    #[cfg(feature = "decompression")]
186                    {
187                        let decompressed = oxiarc_deflate::gzip_decompress(&raw).map_err(|e| {
188                            OxiHttpError::Body(format!("gzip decompression error: {e}"))
189                        })?;
190                        return Ok(Bytes::from(decompressed));
191                    }
192                    #[cfg(not(feature = "decompression"))]
193                    {
194                        // Feature not enabled; return raw bytes
195                    }
196                }
197                Some("deflate") => {
198                    #[cfg(feature = "decompression")]
199                    {
200                        let decompressed = oxiarc_deflate::zlib_decompress(&raw)
201                            .or_else(|_| {
202                                // Some servers send raw DEFLATE without the zlib wrapper
203                                oxiarc_deflate::inflate(&raw).map_err(|e| {
204                                    OxiHttpError::Body(format!("deflate decompression error: {e}"))
205                                })
206                            })
207                            .map_err(|e| {
208                                OxiHttpError::Body(format!("deflate decompression error: {e}"))
209                            })?;
210                        return Ok(Bytes::from(decompressed));
211                    }
212                    #[cfg(not(feature = "decompression"))]
213                    {
214                        // Feature not enabled; return raw bytes
215                    }
216                }
217                _ => {}
218            }
219        }
220
221        Ok(raw)
222    }
223
224    /// Consume the body and return it as a UTF-8 string.
225    pub async fn body_text(self) -> Result<String, OxiHttpError> {
226        let bytes = self.body_bytes().await?;
227        String::from_utf8(bytes.to_vec())
228            .map_err(|e| OxiHttpError::Body(format!("invalid UTF-8: {e}")))
229    }
230
231    /// Consume the body and deserialize it as JSON.
232    pub async fn body_json<T: serde::de::DeserializeOwned>(self) -> Result<T, OxiHttpError> {
233        let bytes = self.body_bytes().await?;
234        serde_json::from_slice(&bytes).map_err(|e| OxiHttpError::Json(e.to_string()))
235    }
236
237    /// Return an error if the response status is a client (4xx) or server (5xx) error.
238    ///
239    /// Returns `Ok(self)` for success and redirect status codes.
240    pub fn error_for_status(self) -> Result<Self, OxiHttpError> {
241        let status = self.inner.status();
242        if status.is_client_error() || status.is_server_error() {
243            Err(OxiHttpError::Body(format!(
244                "HTTP error: {} {}",
245                status.as_u16(),
246                status.canonical_reason().unwrap_or("Unknown")
247            )))
248        } else {
249            Ok(self)
250        }
251    }
252
253    /// Returns the `Content-Type` header value as a string, if present.
254    pub fn content_type(&self) -> Option<&str> {
255        self.inner
256            .headers()
257            .get(http::header::CONTENT_TYPE)
258            .and_then(|v| v.to_str().ok())
259    }
260
261    /// Parse all `Set-Cookie` response headers using `oxihttp_core::Cookie::parse_set_cookie`.
262    ///
263    /// Returns an empty `Vec` when there are no `Set-Cookie` headers or none parse
264    /// successfully.
265    pub fn cookies(&self) -> Vec<oxihttp_core::Cookie> {
266        self.inner
267            .headers()
268            .get_all(http::header::SET_COOKIE)
269            .iter()
270            .filter_map(|v| v.to_str().ok())
271            .filter_map(oxihttp_core::Cookie::parse_set_cookie)
272            .collect()
273    }
274
275    /// Consume the response and return the body as an async stream of chunks.
276    pub fn body_stream(self) -> BodyStream {
277        BodyStream {
278            inner: http_body_util::BodyStream::new(self.inner.into_body()),
279        }
280    }
281}
282
283impl std::fmt::Debug for Response {
284    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285        f.debug_struct("Response")
286            .field("status", &self.inner.status())
287            .field("version", &self.inner.version())
288            .field("headers", self.inner.headers())
289            .finish()
290    }
291}
292
293// ---------------------------------------------------------------------------
294// RequestBuilder
295// ---------------------------------------------------------------------------
296
297/// Builder for a single HTTP request.
298///
299/// Created via `Client::get()`, `Client::post()`, etc.
300pub struct RequestBuilder<C = HttpConnector> {
301    client: HyperClient<C, Full<Bytes>>,
302    method: Method,
303    uri: Uri,
304    headers: HeaderMap,
305    body: Bytes,
306    timeout: Option<Duration>,
307    redirect_policy: RedirectPolicy,
308    retry_policy: Option<RetryPolicy>,
309    decompression: bool,
310    middleware: Vec<Arc<dyn ClientMiddleware>>,
311    cookie_jar: Option<Arc<std::sync::Mutex<oxihttp_core::CookieJar>>>,
312}
313
314impl<C> RequestBuilder<C>
315where
316    C: Connect + Clone + Send + Sync + 'static,
317{
318    #[allow(clippy::too_many_arguments)]
319    fn new(
320        client: HyperClient<C, Full<Bytes>>,
321        method: Method,
322        uri: Uri,
323        redirect_policy: RedirectPolicy,
324        retry_policy: Option<RetryPolicy>,
325        decompression: bool,
326        middleware: Vec<Arc<dyn ClientMiddleware>>,
327        cookie_jar: Option<Arc<std::sync::Mutex<oxihttp_core::CookieJar>>>,
328    ) -> Self {
329        Self {
330            client,
331            method,
332            uri,
333            headers: HeaderMap::new(),
334            body: Bytes::new(),
335            timeout: None,
336            redirect_policy,
337            retry_policy,
338            decompression,
339            middleware,
340            cookie_jar,
341        }
342    }
343
344    /// Add a request header.
345    pub fn header(mut self, key: &str, value: &str) -> Result<Self, OxiHttpError> {
346        let k =
347            HeaderName::from_str(key).map_err(|e| OxiHttpError::InvalidHeader(e.to_string()))?;
348        let v =
349            HeaderValue::from_str(value).map_err(|e| OxiHttpError::InvalidHeader(e.to_string()))?;
350        self.headers.insert(k, v);
351        Ok(self)
352    }
353
354    /// Add multiple headers from a `HeaderMap`.
355    pub fn headers(mut self, map: HeaderMap) -> Self {
356        self.headers.extend(map);
357        self
358    }
359
360    /// Set a Bearer token for the Authorization header.
361    pub fn bearer_token(mut self, token: &str) -> Result<Self, OxiHttpError> {
362        let v = HeaderValue::from_str(&format!("Bearer {token}"))
363            .map_err(|e| OxiHttpError::InvalidHeader(e.to_string()))?;
364        self.headers.insert(http::header::AUTHORIZATION, v);
365        Ok(self)
366    }
367
368    /// Set Basic authentication for the Authorization header.
369    pub fn basic_auth(
370        mut self,
371        username: &str,
372        password: Option<&str>,
373    ) -> Result<Self, OxiHttpError> {
374        let credentials = match password {
375            Some(pw) => format!("{username}:{pw}"),
376            None => format!("{username}:"),
377        };
378        let encoded = base64_encode(credentials.as_bytes());
379        let v = HeaderValue::from_str(&format!("Basic {encoded}"))
380            .map_err(|e| OxiHttpError::InvalidHeader(e.to_string()))?;
381        self.headers.insert(http::header::AUTHORIZATION, v);
382        Ok(self)
383    }
384
385    /// Set the request body as raw bytes.
386    pub fn body(mut self, b: impl Into<Bytes>) -> Self {
387        self.body = b.into();
388        self
389    }
390
391    /// Set the request body as JSON, automatically setting the Content-Type header.
392    pub fn json<T: serde::Serialize>(mut self, value: &T) -> Result<Self, OxiHttpError> {
393        let json_bytes =
394            serde_json::to_vec(value).map_err(|e| OxiHttpError::Json(e.to_string()))?;
395        self.body = Bytes::from(json_bytes);
396        let ct = HeaderValue::from_static("application/json");
397        self.headers.insert(http::header::CONTENT_TYPE, ct);
398        Ok(self)
399    }
400
401    /// Set the request body as URL-encoded form data.
402    pub fn form(mut self, form_body: &oxihttp_core::FormBody) -> Self {
403        self.body = form_body.clone().build();
404        if let Ok(ct) = HeaderValue::from_str("application/x-www-form-urlencoded") {
405            self.headers.insert(http::header::CONTENT_TYPE, ct);
406        }
407        self
408    }
409
410    /// Set the request body from a [`MultipartBuilder`], automatically setting
411    /// the `Content-Type: multipart/form-data; boundary=…` header.
412    ///
413    /// The Content-Type is only set if the caller has not already provided one.
414    /// This allows overriding the header with an explicit `.header()` call made
415    /// *before* `.multipart()`.
416    ///
417    /// [`MultipartBuilder`]: oxihttp_core::MultipartBuilder
418    ///
419    /// # Example
420    ///
421    /// ```rust,no_run
422    /// # async fn example() -> Result<(), oxihttp_core::OxiHttpError> {
423    /// use oxihttp_client::Client;
424    /// use oxihttp_core::MultipartBuilder;
425    ///
426    /// let client = Client::builder().build()?;
427    /// let builder = MultipartBuilder::new().add_text("field", "value");
428    /// let resp = client.post("http://example.com/upload")?
429    ///     .multipart(builder)
430    ///     .send()
431    ///     .await?;
432    /// # Ok(())
433    /// # }
434    /// ```
435    pub fn multipart(mut self, builder: oxihttp_core::MultipartBuilder) -> Self {
436        // Retrieve content_type BEFORE build() because build() consumes the builder.
437        let ct_str = builder.content_type();
438        self.body = builder.build();
439        // Only set Content-Type when the caller has not already provided one.
440        if !self.headers.contains_key(http::header::CONTENT_TYPE) {
441            if let Ok(ct) = HeaderValue::from_str(&ct_str) {
442                self.headers.insert(http::header::CONTENT_TYPE, ct);
443            }
444        }
445        self
446    }
447
448    /// Set a per-request timeout.
449    pub fn timeout(mut self, duration: Duration) -> Self {
450        self.timeout = Some(duration);
451        self
452    }
453
454    /// Send the request and return the response.
455    ///
456    /// Respects retry policy and per-request timeout.
457    /// Before the first attempt the `before_request` hook is called on each
458    /// registered middleware; after a successful response `after_response` is
459    /// called with the final status and elapsed wall-clock time.
460    pub async fn send(self) -> Result<Response, OxiHttpError> {
461        let RequestBuilder {
462            client,
463            method,
464            uri,
465            headers,
466            body,
467            timeout,
468            redirect_policy,
469            retry_policy,
470            decompression,
471            middleware,
472            cookie_jar,
473        } = self;
474
475        // --- middleware: before_request -----------------------------------
476        {
477            let ctx = middleware::RequestContext {
478                method: &method,
479                uri: &uri,
480                headers: &headers,
481            };
482            for mw in &middleware {
483                mw.before_request(&ctx);
484            }
485        }
486
487        let start = Instant::now();
488
489        let max_attempts = retry_policy
490            .as_ref()
491            .map(|p| p.max_retries + 1)
492            .unwrap_or(1);
493
494        for attempt in 0..max_attempts {
495            let result = {
496                let fut = send_inner(
497                    &client,
498                    method.clone(),
499                    uri.clone(),
500                    body.clone(),
501                    headers.clone(),
502                    &redirect_policy,
503                    decompression,
504                    cookie_jar.clone(),
505                );
506                if let Some(dur) = timeout {
507                    match tokio::time::timeout(dur, fut).await {
508                        Ok(r) => r,
509                        Err(_) => Err(OxiHttpError::Timeout(format!(
510                            "request timed out after {}ms",
511                            dur.as_millis()
512                        ))),
513                    }
514                } else {
515                    fut.await
516                }
517            };
518
519            match result {
520                Ok(resp) => {
521                    if let Some(ref policy) = retry_policy {
522                        if attempt < max_attempts - 1
523                            && policy.should_retry_status(resp.status().as_u16())
524                        {
525                            let delay = policy.backoff_delay(attempt);
526                            tokio::time::sleep(delay).await;
527                            continue;
528                        }
529                    }
530                    // --- middleware: after_response ----------------------
531                    let elapsed = start.elapsed();
532                    let resp_ctx = middleware::ResponseContext {
533                        status: resp.status(),
534                        elapsed,
535                    };
536                    for mw in &middleware {
537                        mw.after_response(&resp_ctx);
538                    }
539                    return Ok(resp);
540                }
541                Err(e) => {
542                    if let Some(ref policy) = retry_policy {
543                        let should_retry = match &e {
544                            OxiHttpError::Hyper(_) => policy.retry_on_connection_error,
545                            OxiHttpError::Timeout(_) => policy.retry_on_timeout,
546                            OxiHttpError::Io(_) => policy.retry_on_connection_error,
547                            _ => false,
548                        };
549                        if should_retry && attempt < max_attempts - 1 {
550                            let delay = policy.backoff_delay(attempt);
551                            tokio::time::sleep(delay).await;
552                            continue;
553                        }
554                    }
555                    return Err(e);
556                }
557            }
558        }
559
560        // This is unreachable when max_attempts >= 1, but needed for the type checker.
561        Err(OxiHttpError::Hyper("max retries exceeded".to_string()))
562    }
563}
564
565/// Inner request executor: handles redirect loop and returns a `Response`.
566///
567/// All clone-able fields are passed by value so the outer retry loop can
568/// re-invoke this function on each attempt.
569#[allow(clippy::too_many_arguments)]
570async fn send_inner<C>(
571    client: &HyperClient<C, Full<Bytes>>,
572    mut method: Method,
573    mut uri: Uri,
574    mut body: Bytes,
575    headers: HeaderMap,
576    redirect_policy: &RedirectPolicy,
577    decompression: bool,
578    cookie_jar: Option<Arc<std::sync::Mutex<oxihttp_core::CookieJar>>>,
579) -> Result<Response, OxiHttpError>
580where
581    C: Connect + Clone + Send + Sync + 'static,
582{
583    let max_redirects = redirect_policy.max_redirects();
584    let mut redirect_count: usize = 0;
585
586    loop {
587        let mut req_builder = http::Request::builder()
588            .method(method.clone())
589            .uri(uri.clone());
590        for (k, v) in &headers {
591            req_builder = req_builder.header(k, v);
592        }
593
594        // Inject Accept-Encoding when decompression is enabled and the user
595        // hasn't already set the header.
596        if decompression && !headers.contains_key(http::header::ACCEPT_ENCODING) {
597            req_builder = req_builder.header(
598                http::header::ACCEPT_ENCODING,
599                HeaderValue::from_static("gzip, deflate"),
600            );
601        }
602
603        let mut req = req_builder
604            .body(Full::new(body.clone()))
605            .map_err(|e| OxiHttpError::Http(Arc::new(e)))?;
606
607        // Inject cookies from jar for this URL
608        if let Some(ref jar) = cookie_jar {
609            if let Ok(guard) = jar.lock() {
610                if let Some(cookie_header) = guard.to_cookie_header_for_url(&uri) {
611                    if let Ok(hv) = HeaderValue::from_str(&cookie_header) {
612                        req.headers_mut().insert(http::header::COOKIE, hv);
613                    }
614                }
615            }
616        }
617
618        let resp = client
619            .request(req)
620            .await
621            .map_err(|e| OxiHttpError::Hyper(e.to_string()))?;
622
623        // Persist Set-Cookie headers into jar
624        if let Some(ref jar) = cookie_jar {
625            if let Ok(mut guard) = jar.lock() {
626                guard.add_from_response_headers(resp.headers(), &uri);
627            }
628        }
629
630        // Check for redirect
631        let status = resp.status();
632        if redirect::is_redirect_status(status) {
633            if let Some(max) = max_redirects {
634                if max == 0 || redirect_count >= max {
635                    // Return the redirect response as-is when not following
636                    if max == 0 {
637                        return Ok(Response {
638                            inner: resp,
639                            decompress: decompression,
640                        });
641                    }
642                    return Err(OxiHttpError::Redirect(format!(
643                        "too many redirects (max: {max})"
644                    )));
645                }
646            }
647            redirect_count += 1;
648
649            // Extract the Location header
650            let location = resp
651                .headers()
652                .get(http::header::LOCATION)
653                .and_then(|v| v.to_str().ok())
654                .ok_or_else(|| {
655                    OxiHttpError::Redirect("redirect response missing Location header".to_string())
656                })?;
657
658            // Resolve relative URIs
659            let new_uri = resolve_redirect_uri(&uri, location)?;
660
661            // Update method (POST -> GET for 301/302/303)
662            let new_method = redirect::redirect_method(status, &method);
663
664            // Clear body if method changed away from body-carrying
665            if !redirect::should_preserve_body(status) {
666                body = Bytes::new();
667            }
668
669            method = new_method;
670            uri = new_uri;
671            continue;
672        }
673
674        return Ok(Response {
675            inner: resp,
676            decompress: decompression,
677        });
678    }
679}
680
681/// Resolve a redirect URI, handling both absolute and relative URIs.
682fn resolve_redirect_uri(base: &Uri, location: &str) -> Result<Uri, OxiHttpError> {
683    // Try parsing as absolute URI first
684    if let Ok(uri) = Uri::from_str(location) {
685        if uri.scheme().is_some() {
686            return Ok(uri);
687        }
688    }
689
690    // Relative URI: combine with base
691    let scheme = base.scheme_str().unwrap_or("http");
692    let authority = base.authority().map(|a| a.as_str()).unwrap_or("localhost");
693    let full = format!("{scheme}://{authority}{location}");
694    Uri::from_str(&full).map_err(|e| OxiHttpError::InvalidUri(Arc::new(e)))
695}
696
697// TlsRebuildConfig — stores all TLS + pool params needed to re-create an
698// HttpsClient with modified trust settings (used by with_request_tls_config).
699#[cfg(feature = "tls")]
700#[derive(Clone)]
701pub(crate) struct TlsRebuildConfig {
702    pub trusted_certs_der: Vec<Vec<u8>>,
703    pub alpn: Vec<String>,
704    pub accept_invalid_certs: bool,
705    pub use_webpki_roots: bool,
706    pub key_log_path: Option<std::path::PathBuf>,
707    pub early_data: bool,
708    pub connect_timeout: Option<Duration>,
709    pub tcp_nodelay: Option<bool>,
710    pub tcp_keepalive: Option<Duration>,
711    pub http2_settings: Option<Http2Settings>,
712    pub pool_max_idle_per_host: Option<usize>,
713    pub pool_idle_timeout: Option<Duration>,
714    /// Optional custom certificate verifier injected via
715    /// [`ClientBuilder::with_custom_cert_verifier`].  When `Some`, this verifier
716    /// takes precedence over all other trust-store settings.
717    pub custom_cert_verifier: Option<Arc<dyn rustls::client::danger::ServerCertVerifier>>,
718}
719
720#[cfg(feature = "tls")]
721impl std::fmt::Debug for TlsRebuildConfig {
722    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
723        f.debug_struct("TlsRebuildConfig")
724            .field("trusted_certs_der_count", &self.trusted_certs_der.len())
725            .field("alpn", &self.alpn)
726            .field("accept_invalid_certs", &self.accept_invalid_certs)
727            .field("use_webpki_roots", &self.use_webpki_roots)
728            .field("early_data", &self.early_data)
729            .field("connect_timeout", &self.connect_timeout)
730            .field("tcp_nodelay", &self.tcp_nodelay)
731            .field("tcp_keepalive", &self.tcp_keepalive)
732            .field(
733                "custom_cert_verifier",
734                &self
735                    .custom_cert_verifier
736                    .as_ref()
737                    .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}