Skip to main content

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