Skip to main content

qubit_http/client/
http_client_factory.rs

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