Skip to main content

qubit_http/client/
http_client_factory.rs

1/*******************************************************************************
2 *
3 *    Copyright (c) 2025 - 2026 Haixing Hu.
4 *
5 *    SPDX-License-Identifier: Apache-2.0
6 *
7 *    Licensed under the Apache License, Version 2.0.
8 *
9 ******************************************************************************/
10//! Reqwest-backed HTTP client factory.
11
12use std::net::{IpAddr, SocketAddr};
13
14use reqwest::dns::{Addrs, Name, Resolve, Resolving};
15use reqwest::redirect::Policy;
16
17use crate::HttpConfigError;
18use crate::{HttpClient, HttpClientOptions, HttpError, HttpResult};
19use qubit_config::ConfigReader;
20use qubit_error::{BoxError, IntoBoxError};
21
22/// DNS resolver that filters out non-IPv4 addresses for `ipv4_only` mode.
23#[derive(Debug, Clone, Copy, Default)]
24struct Ipv4OnlyResolver;
25
26impl Resolve for Ipv4OnlyResolver {
27    /// Resolves `name` using the OS resolver and returns only IPv4 addresses.
28    ///
29    /// # Parameters
30    /// - `name`: DNS name to resolve.
31    ///
32    /// # Returns
33    /// A future yielding only IPv4 socket addresses.
34    ///
35    /// # Errors
36    /// Returns an error when DNS lookup fails or when no IPv4 address exists for `name`.
37    fn resolve(&self, name: Name) -> Resolving {
38        let host = name.as_str().to_string();
39        Box::pin(async move {
40            let resolved = tokio::net::lookup_host((host.as_str(), 0))
41                .await
42                .map_err(|error| error.into_box_error())?;
43            filter_ipv4_addrs(&host, resolved)
44        })
45    }
46}
47
48/// Public factory used to build reqwest-backed [`HttpClient`] instances.
49#[derive(Debug, Default, Clone, Copy)]
50pub struct HttpClientFactory;
51
52impl HttpClientFactory {
53    /// Returns a stateless factory instance.
54    ///
55    /// # Returns
56    /// New [`HttpClientFactory`].
57    pub fn new() -> Self {
58        Self
59    }
60
61    /// Creates a new [`HttpClient`] with default [`HttpClientOptions`].
62    ///
63    /// # Returns
64    /// [`HttpClient`] or [`HttpError`] (proxy/build failures).
65    pub fn create_default(&self) -> HttpResult<HttpClient> {
66        self.create(HttpClientOptions::default())
67    }
68
69    /// Applies `options` to a new [`reqwest::Client::builder`], then wraps the built client.
70    ///
71    /// # Parameters
72    /// - `options`: Full client configuration.
73    ///
74    /// # Returns
75    /// [`HttpClient`] or [`HttpError`] (proxy/build failures).
76    pub fn create(&self, options: HttpClientOptions) -> HttpResult<HttpClient> {
77        options.validate().map_err(map_validation_error)?;
78
79        let mut builder = reqwest::Client::builder();
80
81        builder = builder.connect_timeout(options.timeouts.connect_timeout);
82        if let Some(request_timeout) = options.timeouts.request_timeout {
83            builder = builder.timeout(request_timeout);
84        }
85        if let Some(user_agent) = options.user_agent.as_deref() {
86            builder = builder.user_agent(user_agent);
87        }
88        if let Some(max_redirects) = options.max_redirects {
89            builder = builder.redirect(Policy::limited(max_redirects));
90        }
91        if let Some(pool_idle_timeout) = options.pool_idle_timeout {
92            builder = builder.pool_idle_timeout(pool_idle_timeout);
93        }
94        if let Some(pool_max_idle_per_host) = options.pool_max_idle_per_host {
95            builder = builder.pool_max_idle_per_host(pool_max_idle_per_host);
96        }
97        if options.ipv4_only {
98            builder = builder.dns_resolver(Ipv4OnlyResolver);
99        }
100
101        if options.proxy.enabled {
102            let host = options
103                .proxy
104                .host
105                .clone()
106                .expect("proxy.host must exist after HttpClientOptions::validate");
107            if options.ipv4_only && is_ipv6_literal_host(&host) {
108                return Err(HttpError::proxy_config(format!(
109                    "Proxy host '{host}' is IPv6, which is not allowed when ipv4_only=true",
110                )));
111            }
112            let port = options
113                .proxy
114                .port
115                .expect("proxy.port must exist after HttpClientOptions::validate");
116
117            let proxy_url = format!("{}://{}:{}", options.proxy.proxy_type.scheme(), host, port);
118            let mut proxy = reqwest::Proxy::all(&proxy_url).map_err(|error| {
119                HttpError::proxy_config(format!("Invalid proxy URL '{}': {}", proxy_url, error))
120            })?;
121
122            if let Some(username) = options.proxy.username.clone() {
123                let password = options.proxy.password.as_deref().unwrap_or("");
124                proxy = proxy.basic_auth(&username, password);
125            }
126
127            builder = builder.proxy(proxy);
128        } else if !options.use_env_proxy {
129            // Keep behavior aligned with explicit proxy switch semantics:
130            // when both explicit proxy and env proxy inheritance are disabled,
131            // do not inherit environment proxies.
132            builder = builder.no_proxy();
133        }
134
135        let backend = builder.build().map_err(HttpError::from)?;
136
137        Ok(HttpClient::new(backend, options))
138    }
139
140    /// Loads [`HttpClientOptions`] from `config`, validates them, then calls
141    /// [`HttpClientFactory::create`].
142    ///
143    /// # Parameters
144    /// - `config`: Any [`ConfigReader`] (root [`qubit_config::Config`] or a
145    ///   [`qubit_config::ConfigPrefixView`] from [`ConfigReader::prefix_view`]).
146    ///
147    /// # Returns
148    /// - `Ok(HttpClient)` when parsing, validation, and client build succeed.
149    /// - `Err(HttpConfigError)` on config or validation errors; build failures are mapped to [`HttpConfigError`].
150    pub fn create_from_config<R>(&self, config: &R) -> Result<HttpClient, HttpConfigError>
151    where
152        R: ConfigReader + ?Sized,
153    {
154        let options =
155            HttpClientOptions::from_config(config).map_err(|e| resolve_config_error(config, e))?;
156        options
157            .validate()
158            .map_err(|e| resolve_config_error(config, e))?;
159        self.create(options).map_err(|e| {
160            HttpConfigError::new(
161                crate::HttpConfigErrorKind::InvalidValue,
162                config.resolve_key(""),
163                e.to_string(),
164            )
165        })
166    }
167}
168
169fn resolve_config_error<R>(config: &R, mut error: HttpConfigError) -> HttpConfigError
170where
171    R: ConfigReader + ?Sized,
172{
173    error.path = if error.path.is_empty() {
174        config.resolve_key("")
175    } else {
176        config.resolve_key(&error.path)
177    };
178    error
179}
180
181/// Filters resolved socket addresses down to IPv4 addresses.
182///
183/// # Parameters
184/// - `host`: Hostname used for diagnostics when no IPv4 address remains.
185/// - `resolved`: Iterator of resolved socket addresses.
186///
187/// # Returns
188/// Boxed reqwest DNS address iterator containing only IPv4 addresses.
189///
190/// # Errors
191/// Returns an [`std::io::ErrorKind::AddrNotAvailable`] error when resolution
192/// produced no IPv4 address.
193fn filter_ipv4_addrs<I>(host: &str, resolved: I) -> Result<Addrs, BoxError>
194where
195    I: IntoIterator<Item = SocketAddr>,
196{
197    let ipv4_addrs: Vec<SocketAddr> = resolved.into_iter().filter(SocketAddr::is_ipv4).collect();
198    if ipv4_addrs.is_empty() {
199        let error = std::io::Error::new(
200            std::io::ErrorKind::AddrNotAvailable,
201            format!("No IPv4 address found for host '{host}'"),
202        );
203        return Err(error.into_box_error());
204    }
205    Ok(Box::new(ipv4_addrs.into_iter()) as Addrs)
206}
207
208/// Maps options validation errors to runtime [`HttpError`] values.
209///
210/// # Parameters
211/// - `error`: Validation error returned by [`HttpClientOptions::validate`].
212///
213/// # Returns
214/// - [`HttpError::proxy_config`] for proxy section validation failures;
215/// - [`HttpError::other`] for all other option validation failures.
216fn map_validation_error(error: HttpConfigError) -> HttpError {
217    if error.path.starts_with("proxy.") {
218        HttpError::proxy_config(error.to_string())
219    } else {
220        HttpError::other(format!("Invalid HTTP client options: {error}"))
221    }
222}
223
224/// Checks whether `host` is an IPv6 literal value.
225///
226/// # Parameters
227/// - `host`: Raw host text, optionally wrapped by square brackets.
228///
229/// # Returns
230/// `true` if `host` parses as an IPv6 literal.
231fn is_ipv6_literal_host(host: &str) -> bool {
232    let trimmed = host.trim().trim_start_matches('[').trim_end_matches(']');
233    matches!(trimmed.parse::<IpAddr>(), Ok(IpAddr::V6(_)))
234}