qubit_http/client/
http_client_factory.rs1use 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;
20use qubit_error::{BoxError, IntoBoxError};
21
22#[derive(Debug, Clone, Copy, Default)]
24struct Ipv4OnlyResolver;
25
26impl Resolve for Ipv4OnlyResolver {
27 fn resolve(&self, name: Name) -> Resolving {
38 let host = name.as_str().to_string();
39 Box::pin(async move {
40 let resolved = tokio::net::lookup_host((host.as_str(), 0))
41 .await
42 .map_err(|error| error.into_box_error())?;
43 filter_ipv4_addrs(&host, resolved)
44 })
45 }
46}
47
48#[derive(Debug, Default, Clone, Copy)]
50pub struct HttpClientFactory;
51
52impl HttpClientFactory {
53 pub fn new() -> Self {
58 Self
59 }
60
61 pub fn create_default(&self) -> HttpResult<HttpClient> {
66 self.create(HttpClientOptions::default())
67 }
68
69 pub fn create(&self, options: HttpClientOptions) -> HttpResult<HttpClient> {
77 options.validate().map_err(map_validation_error)?;
78
79 let mut builder = reqwest::Client::builder();
80
81 builder = builder.connect_timeout(options.timeouts.connect_timeout);
82 if let Some(request_timeout) = options.timeouts.request_timeout {
83 builder = builder.timeout(request_timeout);
84 }
85 if let Some(user_agent) = options.user_agent.as_deref() {
86 builder = builder.user_agent(user_agent);
87 }
88 if let Some(max_redirects) = options.max_redirects {
89 builder = builder.redirect(Policy::limited(max_redirects));
90 }
91 if let Some(pool_idle_timeout) = options.pool_idle_timeout {
92 builder = builder.pool_idle_timeout(pool_idle_timeout);
93 }
94 if let Some(pool_max_idle_per_host) = options.pool_max_idle_per_host {
95 builder = builder.pool_max_idle_per_host(pool_max_idle_per_host);
96 }
97 if options.ipv4_only {
98 builder = builder.dns_resolver(Ipv4OnlyResolver);
99 }
100
101 if options.proxy.enabled {
102 let host = options
103 .proxy
104 .host
105 .clone()
106 .expect("proxy.host must exist after HttpClientOptions::validate");
107 if options.ipv4_only && is_ipv6_literal_host(&host) {
108 return Err(HttpError::proxy_config(format!(
109 "Proxy host '{host}' is IPv6, which is not allowed when ipv4_only=true",
110 )));
111 }
112 let port = options
113 .proxy
114 .port
115 .expect("proxy.port must exist after HttpClientOptions::validate");
116
117 let proxy_url = format!("{}://{}:{}", options.proxy.proxy_type.scheme(), host, port);
118 let mut proxy = reqwest::Proxy::all(&proxy_url).map_err(|error| {
119 HttpError::proxy_config(format!("Invalid proxy URL '{}': {}", proxy_url, error))
120 })?;
121
122 if let Some(username) = options.proxy.username.clone() {
123 let password = options.proxy.password.as_deref().unwrap_or("");
124 proxy = proxy.basic_auth(&username, password);
125 }
126
127 builder = builder.proxy(proxy);
128 } else if !options.use_env_proxy {
129 builder = builder.no_proxy();
133 }
134
135 let backend = builder.build().map_err(HttpError::from)?;
136
137 Ok(HttpClient::new(backend, options))
138 }
139
140 pub fn create_from_config<R>(&self, config: &R) -> Result<HttpClient, HttpConfigError>
151 where
152 R: ConfigReader + ?Sized,
153 {
154 let options =
155 HttpClientOptions::from_config(config).map_err(|e| resolve_config_error(config, e))?;
156 options
157 .validate()
158 .map_err(|e| resolve_config_error(config, e))?;
159 self.create(options).map_err(|e| {
160 HttpConfigError::new(
161 crate::HttpConfigErrorKind::InvalidValue,
162 config.resolve_key(""),
163 e.to_string(),
164 )
165 })
166 }
167}
168
169fn resolve_config_error<R>(config: &R, mut error: HttpConfigError) -> HttpConfigError
170where
171 R: ConfigReader + ?Sized,
172{
173 error.path = if error.path.is_empty() {
174 config.resolve_key("")
175 } else {
176 config.resolve_key(&error.path)
177 };
178 error
179}
180
181fn filter_ipv4_addrs<I>(host: &str, resolved: I) -> Result<Addrs, BoxError>
194where
195 I: IntoIterator<Item = SocketAddr>,
196{
197 let ipv4_addrs: Vec<SocketAddr> = resolved.into_iter().filter(SocketAddr::is_ipv4).collect();
198 if ipv4_addrs.is_empty() {
199 let error = std::io::Error::new(
200 std::io::ErrorKind::AddrNotAvailable,
201 format!("No IPv4 address found for host '{host}'"),
202 );
203 return Err(error.into_box_error());
204 }
205 Ok(Box::new(ipv4_addrs.into_iter()) as Addrs)
206}
207
208fn map_validation_error(error: HttpConfigError) -> HttpError {
217 if error.path.starts_with("proxy.") {
218 HttpError::proxy_config(error.to_string())
219 } else {
220 HttpError::other(format!("Invalid HTTP client options: {error}"))
221 }
222}
223
224fn is_ipv6_literal_host(host: &str) -> bool {
232 let trimmed = host.trim().trim_start_matches('[').trim_end_matches(']');
233 matches!(trimmed.parse::<IpAddr>(), Ok(IpAddr::V6(_)))
234}
235
236#[cfg(coverage)]
242#[doc(hidden)]
243pub(crate) fn coverage_exercise_factory_paths() -> Vec<String> {
244 let no_ipv4_error = filter_ipv4_addrs(
245 "coverage-host",
246 [SocketAddr::new(
247 IpAddr::V6(std::net::Ipv6Addr::LOCALHOST),
248 0,
249 )],
250 )
251 .err()
252 .expect("IPv6-only addresses should be rejected")
253 .to_string();
254 let ipv4_count = filter_ipv4_addrs(
255 "coverage-host",
256 [SocketAddr::new(IpAddr::from([127, 0, 0, 1]), 0)],
257 )
258 .expect("IPv4 address should be accepted")
259 .count()
260 .to_string();
261 let config = qubit_config::Config::new();
262 let scoped_path = resolve_config_error(
263 &config.prefix_view("coverage"),
264 HttpConfigError::invalid_value("", "coverage error"),
265 )
266 .path;
267 let proxy_kind = map_validation_error(HttpConfigError::invalid_value("proxy.host", "bad")).kind;
268 let other_kind = map_validation_error(HttpConfigError::invalid_value("user_agent", "bad")).kind;
269
270 vec![
271 no_ipv4_error,
272 ipv4_count,
273 scoped_path,
274 format!("{proxy_kind:?}"),
275 format!("{other_kind:?}"),
276 is_ipv6_literal_host("[::1]").to_string(),
277 is_ipv6_literal_host("127.0.0.1").to_string(),
278 ]
279}