qubit_http/client/
http_client_factory.rs1use 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#[derive(Debug, Clone, Copy, Default)]
23struct Ipv4OnlyResolver;
24
25impl Resolve for Ipv4OnlyResolver {
26 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#[derive(Debug, Default, Clone, Copy)]
49pub struct HttpClientFactory;
50
51impl HttpClientFactory {
52 pub fn new() -> Self {
57 Self
58 }
59
60 pub fn create_default(&self) -> HttpResult<HttpClient> {
65 self.create(HttpClientOptions::default())
66 }
67
68 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 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 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
180fn 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
207fn 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
223fn 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#[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}