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).map_err(|error| {
135                HttpError::proxy_config(format!("Invalid proxy URL '{}': {}", proxy_url, error))
136            })?;
137
138            if let Some(username) = options.proxy.username.clone() {
139                let password = options.proxy.password.as_deref().unwrap_or("");
140                proxy = proxy.basic_auth(&username, password);
141            }
142
143            builder = builder.proxy(proxy);
144        } else if !options.use_env_proxy {
145            // Keep behavior aligned with explicit proxy switch semantics:
146            // when both explicit proxy and env proxy inheritance are disabled,
147            // do not inherit environment proxies.
148            builder = builder.no_proxy();
149        }
150
151        let backend = builder.build().map_err(HttpError::from)?;
152
153        Ok(HttpClient::new(backend, options))
154    }
155
156    /// Loads [`HttpClientOptions`] from `config`, validates them, then calls
157    /// [`HttpClientFactory::create`].
158    ///
159    /// # Parameters
160    /// - `config`: Any [`ConfigReader`] (root [`qubit_config::Config`] or a
161    ///   [`qubit_config::ConfigPrefixView`] from [`ConfigReader::prefix_view`]).
162    ///
163    /// # Returns
164    /// - `Ok(HttpClient)` when parsing, validation, and client build succeed.
165    /// - `Err(HttpConfigError)` on config or validation errors; build failures are mapped to [`HttpConfigError`].
166    pub fn create_from_config<R>(&self, config: &R) -> Result<HttpClient, HttpConfigError>
167    where
168        R: ConfigReader + ?Sized,
169    {
170        let options =
171            HttpClientOptions::from_config(config).map_err(|e| resolve_config_error(config, e))?;
172        options
173            .validate()
174            .map_err(|e| resolve_config_error(config, e))?;
175        self.create(options).map_err(|e| {
176            HttpConfigError::new(
177                crate::HttpConfigErrorKind::InvalidValue,
178                config.resolve_key(""),
179                e.to_string(),
180            )
181        })
182    }
183}
184
185fn resolve_config_error<R>(config: &R, mut error: HttpConfigError) -> HttpConfigError
186where
187    R: ConfigReader + ?Sized,
188{
189    error.path = if error.path.is_empty() {
190        config.resolve_key("")
191    } else {
192        config.resolve_key(&error.path)
193    };
194    error
195}
196
197/// Filters resolved socket addresses down to IPv4 addresses.
198///
199/// # Parameters
200/// - `host`: Hostname used for diagnostics when no IPv4 address remains.
201/// - `resolved`: Iterator of resolved socket addresses.
202///
203/// # Returns
204/// Boxed reqwest DNS address iterator containing only IPv4 addresses.
205///
206/// # Errors
207/// Returns an [`std::io::ErrorKind::AddrNotAvailable`] error when resolution
208/// produced no IPv4 address.
209fn filter_ipv4_addrs<I>(host: &str, resolved: I) -> Result<Addrs, BoxError>
210where
211    I: IntoIterator<Item = SocketAddr>,
212{
213    let ipv4_addrs: Vec<SocketAddr> = resolved.into_iter().filter(SocketAddr::is_ipv4).collect();
214    if ipv4_addrs.is_empty() {
215        let error = std::io::Error::new(
216            std::io::ErrorKind::AddrNotAvailable,
217            format!("No IPv4 address found for host '{host}'"),
218        );
219        return Err(error.into_box_error());
220    }
221    Ok(Box::new(ipv4_addrs.into_iter()) as Addrs)
222}
223
224/// Maps options validation errors to runtime [`HttpError`] values.
225///
226/// # Parameters
227/// - `error`: Validation error returned by [`HttpClientOptions::validate`].
228///
229/// # Returns
230/// - [`HttpError::proxy_config`] for proxy section validation failures;
231/// - [`HttpError::other`] for all other option validation failures.
232fn map_validation_error(error: HttpConfigError) -> HttpError {
233    if error.path.starts_with("proxy.") {
234        HttpError::proxy_config(error.to_string())
235    } else {
236        HttpError::other(format!("Invalid HTTP client options: {error}"))
237    }
238}
239
240/// Checks whether `host` is an IPv6 literal value.
241///
242/// # Parameters
243/// - `host`: Raw host text, optionally wrapped by square brackets.
244///
245/// # Returns
246/// `true` if `host` parses as an IPv6 literal.
247fn is_ipv6_literal_host(host: &str) -> bool {
248    let trimmed = host.trim().trim_start_matches('[').trim_end_matches(']');
249    matches!(trimmed.parse::<IpAddr>(), Ok(IpAddr::V6(_)))
250}