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