Skip to main content

qubit_http/options/
http_client_options.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
11use std::collections::HashMap;
12use std::fmt;
13use std::time::Duration;
14
15use http::HeaderMap;
16use http::HeaderValue;
17use qubit_config::{
18    ConfigReader,
19    ConfigResult,
20};
21use qubit_sanitize::SensitivityLevel;
22use std::str::FromStr;
23use url::Url;
24
25use super::from_config_helpers::hashmap_to_headermap;
26use super::http_logging_options::HttpLoggingOptions;
27use super::http_retry_options::HttpRetryOptions;
28use super::http_timeout_options::HttpTimeoutOptions;
29use super::proxy_options::ProxyOptions;
30use super::HttpConfigError;
31use crate::{
32    constants::{
33        DEFAULT_ERROR_RESPONSE_PREVIEW_LIMIT_BYTES,
34        DEFAULT_SSE_MAX_FRAME_BYTES,
35        DEFAULT_SSE_MAX_LINE_BYTES,
36    },
37    request::parse_header,
38    sanitize::{
39        LogSanitizePolicy,
40        SanitizedDebugger,
41    },
42    sse::{
43        DoneMarkerPolicy,
44        SseJsonMode,
45    },
46    HttpResult,
47};
48
49/// Aggregated settings for [`crate::HttpClient`] and [`crate::HttpClientFactory`].
50#[derive(Clone)]
51pub struct HttpClientOptions {
52    /// Optional base URL.
53    pub base_url: Option<Url>,
54    /// Default request headers.
55    pub default_headers: HeaderMap,
56    /// Timeout options.
57    pub timeouts: HttpTimeoutOptions,
58    /// Proxy options.
59    pub proxy: ProxyOptions,
60    /// Logging options.
61    pub logging: HttpLoggingOptions,
62    /// Maximum bytes captured into `HttpError.response_body_preview` for non-success responses.
63    pub error_response_preview_limit: usize,
64    /// Optional default `User-Agent` header sent by reqwest.
65    pub user_agent: Option<String>,
66    /// Optional redirect limit applied by reqwest.
67    pub max_redirects: Option<usize>,
68    /// Optional connection pool idle-time timeout.
69    pub pool_idle_timeout: Option<Duration>,
70    /// Optional maximum idle connections per host.
71    pub pool_max_idle_per_host: Option<usize>,
72    /// Whether to inherit proxy settings from environment variables when
73    /// explicit proxy config is disabled.
74    pub use_env_proxy: bool,
75    /// Retry options.
76    pub retry: HttpRetryOptions,
77    /// Log sanitization policy for URL, header, and body previews.
78    pub log_sanitize_policy: LogSanitizePolicy,
79    /// Whether IPv4-only DNS behavior is requested.
80    pub ipv4_only: bool,
81    /// Default JSON handling mode used by [`crate::HttpResponse::sse_chunks`].
82    pub sse_json_mode: SseJsonMode,
83    /// Default done-marker policy used by [`crate::HttpResponse::sse_chunks`].
84    pub sse_done_marker_policy: DoneMarkerPolicy,
85    /// Default maximum bytes for one SSE line.
86    pub sse_max_line_bytes: usize,
87    /// Default maximum bytes for one SSE frame.
88    pub sse_max_frame_bytes: usize,
89}
90
91impl Default for HttpClientOptions {
92    /// Default: no base URL, empty headers, default timeouts/proxy/logging,
93    /// default log sanitization, IPv4-only off, lenient SSE JSON mode, default
94    /// SSE done-marker policy, and crate default SSE line/frame limits.
95    ///
96    /// # Returns
97    /// Default [`HttpClientOptions`].
98    fn default() -> Self {
99        Self {
100            base_url: None,
101            default_headers: HeaderMap::new(),
102            timeouts: HttpTimeoutOptions::default(),
103            proxy: ProxyOptions::default(),
104            logging: HttpLoggingOptions::default(),
105            error_response_preview_limit: DEFAULT_ERROR_RESPONSE_PREVIEW_LIMIT_BYTES,
106            user_agent: None,
107            max_redirects: None,
108            pool_idle_timeout: None,
109            pool_max_idle_per_host: None,
110            use_env_proxy: false,
111            retry: HttpRetryOptions::default(),
112            log_sanitize_policy: LogSanitizePolicy::default(),
113            ipv4_only: false,
114            sse_json_mode: SseJsonMode::Lenient,
115            sse_done_marker_policy: DoneMarkerPolicy::default(),
116            sse_max_line_bytes: DEFAULT_SSE_MAX_LINE_BYTES,
117            sse_max_frame_bytes: DEFAULT_SSE_MAX_FRAME_BYTES,
118        }
119    }
120}
121
122impl fmt::Debug for HttpClientOptions {
123    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
124        let debugger = SanitizedDebugger::new(&self.log_sanitize_policy);
125        let base_url = debugger.optional_url(self.base_url.as_ref());
126        formatter
127            .debug_struct("HttpClientOptions")
128            .field("base_url", &base_url)
129            .field("default_headers", &debugger.headers(&self.default_headers))
130            .field("timeouts", &self.timeouts)
131            .field("proxy", &self.proxy)
132            .field("logging", &self.logging)
133            .field("error_response_preview_limit", &self.error_response_preview_limit)
134            .field("user_agent", &self.user_agent)
135            .field("max_redirects", &self.max_redirects)
136            .field("pool_idle_timeout", &self.pool_idle_timeout)
137            .field("pool_max_idle_per_host", &self.pool_max_idle_per_host)
138            .field("use_env_proxy", &self.use_env_proxy)
139            .field("retry", &self.retry)
140            .field("log_sanitize_policy", &self.log_sanitize_policy)
141            .field("ipv4_only", &self.ipv4_only)
142            .field("sse_json_mode", &self.sse_json_mode)
143            .field("sse_done_marker_policy", &self.sse_done_marker_policy)
144            .field("sse_max_line_bytes", &self.sse_max_line_bytes)
145            .field("sse_max_frame_bytes", &self.sse_max_frame_bytes)
146            .finish()
147    }
148}
149
150/// Top-level scalar keys read before nested sections and `default_headers` iteration.
151struct HttpClientRootConfigInput {
152    base_url: Option<String>,
153    ipv4_only: Option<bool>,
154    error_response_preview_limit: Option<usize>,
155    user_agent: Option<String>,
156    max_redirects: Option<usize>,
157    pool_idle_timeout: Option<Duration>,
158    pool_max_idle_per_host: Option<usize>,
159    use_env_proxy: Option<bool>,
160}
161
162/// SSE scalar keys read from `sse.*`.
163struct HttpClientSseConfigInput {
164    json_mode: Option<String>,
165    done_marker: Option<String>,
166    max_line_bytes: Option<usize>,
167    max_frame_bytes: Option<usize>,
168}
169
170/// Log sanitization keys read from `log_sanitize.*` and added to defaults.
171struct HttpClientLogSanitizeConfigInput {
172    sensitive_headers: Option<Vec<String>>,
173    sensitive_query_params: Option<Vec<String>>,
174    sensitive_body_fields: Option<Vec<String>>,
175}
176
177impl HttpClientOptions {
178    fn resolve_config_error<R>(config: &R, mut error: HttpConfigError) -> HttpConfigError
179    where
180        R: ConfigReader + ?Sized,
181    {
182        error.path = if error.path.is_empty() {
183            config.resolve_key("")
184        } else {
185            config.resolve_key(&error.path)
186        };
187        error
188    }
189
190    fn read_config<R>(config: &R) -> ConfigResult<HttpClientRootConfigInput>
191    where
192        R: ConfigReader + ?Sized,
193    {
194        Ok(HttpClientRootConfigInput {
195            base_url: config.get_optional_string("base_url")?,
196            ipv4_only: config.get_optional("ipv4_only")?,
197            error_response_preview_limit: config.get_optional("error_response_preview_limit")?,
198            user_agent: config.get_optional_string("user_agent")?,
199            max_redirects: config.get_optional("max_redirects")?,
200            pool_idle_timeout: config.get_optional("pool_idle_timeout")?,
201            pool_max_idle_per_host: config.get_optional("pool_max_idle_per_host")?,
202            use_env_proxy: config.get_optional("use_env_proxy")?,
203        })
204    }
205
206    fn read_sse_config<R>(config: &R) -> ConfigResult<HttpClientSseConfigInput>
207    where
208        R: ConfigReader + ?Sized,
209    {
210        Ok(HttpClientSseConfigInput {
211            json_mode: config.get_optional_string("json_mode")?,
212            done_marker: config.get_optional_string("done_marker")?,
213            max_line_bytes: config.get_optional("max_line_bytes")?,
214            max_frame_bytes: config.get_optional("max_frame_bytes")?,
215        })
216    }
217
218    fn read_log_sanitize_config<R>(config: &R) -> ConfigResult<HttpClientLogSanitizeConfigInput>
219    where
220        R: ConfigReader + ?Sized,
221    {
222        Ok(HttpClientLogSanitizeConfigInput {
223            sensitive_headers: config.get_optional_string_list("sensitive_headers")?,
224            sensitive_query_params: config.get_optional_string_list("sensitive_query_params")?,
225            sensitive_body_fields: config.get_optional_string_list("sensitive_body_fields")?,
226        })
227    }
228
229    fn parse_sse_done_marker_policy(value: &str) -> Result<DoneMarkerPolicy, HttpConfigError> {
230        let trimmed = value.trim();
231        if trimmed.is_empty() {
232            return Err(HttpConfigError::invalid_value("done_marker", "Value must not be empty"));
233        }
234        Ok(DoneMarkerPolicy::from_str(trimmed).expect("DoneMarkerPolicy::from_str accepts arbitrary custom markers"))
235    }
236
237    fn parse_base_url(base_url: &str) -> Result<Url, HttpConfigError> {
238        Url::parse(base_url)
239            .map_err(|error| HttpConfigError::invalid_value("base_url", format!("Invalid URL: {error}")))
240    }
241
242    fn parse_sse_json_mode(value: &str) -> Result<SseJsonMode, HttpConfigError> {
243        SseJsonMode::from_str(value.trim())
244            .map_err(|_| HttpConfigError::invalid_value("json_mode", format!("Unsupported SSE JSON mode: {value}")))
245    }
246
247    fn validate_positive_limit(path: &str, value: usize) -> Result<usize, HttpConfigError> {
248        if value == 0 {
249            return Err(HttpConfigError::invalid_value(path, "Value must be greater than 0"));
250        }
251        Ok(value)
252    }
253
254    /// Same as [`HttpClientOptions::default`].
255    ///
256    /// # Returns
257    /// Fresh options with crate defaults.
258    pub fn new() -> Self {
259        Self::default()
260    }
261
262    /// Parses and sets the base URL used to resolve relative request paths.
263    ///
264    /// # Parameters
265    /// - `base_url`: Absolute base URL string.
266    ///
267    /// # Returns
268    /// `Ok(self)` or [`HttpConfigError`] if the URL is invalid.
269    pub fn set_base_url(&mut self, base_url: &str) -> Result<&mut Self, HttpConfigError> {
270        let parsed = Self::parse_base_url(base_url)?;
271        self.base_url = Some(parsed);
272        Ok(self)
273    }
274
275    /// Validates and adds one client-level default header.
276    ///
277    /// # Parameters
278    /// - `name`: Header name.
279    /// - `value`: Header value.
280    ///
281    /// # Returns
282    /// `Ok(self)` or an error if name/value are invalid.
283    pub fn add_header(&mut self, name: &str, value: &str) -> HttpResult<&mut Self> {
284        let (header_name, header_value) = parse_header(name, value)?;
285        self.default_headers.insert(header_name, header_value);
286        Ok(self)
287    }
288
289    /// Validates and adds many client-level default headers atomically.
290    ///
291    /// If any input pair is invalid, no header from this batch is applied.
292    ///
293    /// # Parameters
294    /// - `headers`: Iterator of `(name, value)` pairs.
295    ///
296    /// # Returns
297    /// `Ok(self)` or an error if any pair is invalid.
298    pub fn add_headers(&mut self, headers: &[(&str, &str)]) -> HttpResult<&mut Self> {
299        let mut parsed_headers = HeaderMap::new();
300        for &(name, value) in headers {
301            let (header_name, header_value) = parse_header(name, value)?;
302            parsed_headers.insert(header_name, header_value);
303        }
304        self.default_headers.extend(parsed_headers);
305        Ok(self)
306    }
307
308    /// Creates [`HttpClientOptions`] from `config` using **relative** keys.
309    ///
310    /// # Parameters
311    /// - `config`: Any [`ConfigReader`] (full [`qubit_config::Config`] or a
312    ///   [`qubit_config::ConfigPrefixView`] from [`ConfigReader::prefix_view`]).
313    ///
314    /// # Returns
315    /// Parsed options or [`HttpConfigError`].
316    pub fn from_config<R>(config: &R) -> Result<Self, HttpConfigError>
317    where
318        R: ConfigReader + ?Sized,
319    {
320        let mut opts = HttpClientOptions::default();
321
322        let root = match Self::read_config(config) {
323            Ok(root) => root,
324            Err(error) => return Err(Self::resolve_config_error(config, HttpConfigError::from(error))),
325        };
326
327        if let Some(s) = root.base_url {
328            if let Err(error) = opts.set_base_url(&s) {
329                return Err(Self::resolve_config_error(config, error));
330            }
331        }
332
333        if let Some(v) = root.ipv4_only {
334            opts.ipv4_only = v;
335        }
336        if let Some(limit) = root.error_response_preview_limit {
337            opts.error_response_preview_limit =
338                match Self::validate_positive_limit("error_response_preview_limit", limit) {
339                    Ok(limit) => limit,
340                    Err(error) => return Err(Self::resolve_config_error(config, error)),
341                };
342        }
343        if let Some(user_agent) = root.user_agent {
344            opts.user_agent = Some(user_agent.trim().to_string());
345        }
346        if let Some(max_redirects) = root.max_redirects {
347            opts.max_redirects = Some(max_redirects);
348        }
349        if let Some(pool_idle_timeout) = root.pool_idle_timeout {
350            opts.pool_idle_timeout = Some(pool_idle_timeout);
351        }
352        if let Some(pool_max_idle_per_host) = root.pool_max_idle_per_host {
353            opts.pool_max_idle_per_host = Some(pool_max_idle_per_host);
354        }
355        if let Some(use_env_proxy) = root.use_env_proxy {
356            opts.use_env_proxy = use_env_proxy;
357        }
358
359        // timeouts
360        if config.contains_prefix("timeouts") {
361            let timeouts_config = config.prefix_view("timeouts");
362            opts.timeouts = match HttpTimeoutOptions::from_config(&timeouts_config) {
363                Ok(timeouts) => timeouts,
364                Err(error) => return Err(Self::resolve_config_error(&timeouts_config, error)),
365            };
366        }
367
368        // proxy
369        if config.contains_prefix("proxy") {
370            let proxy_config = config.prefix_view("proxy");
371            opts.proxy = match ProxyOptions::from_config(&proxy_config) {
372                Ok(proxy) => proxy,
373                Err(error) => return Err(Self::resolve_config_error(&proxy_config, error)),
374            };
375        }
376
377        // logging
378        if config.contains_prefix("logging") {
379            let logging_config = config.prefix_view("logging");
380            opts.logging = match HttpLoggingOptions::from_config(&logging_config) {
381                Ok(logging) => logging,
382                Err(error) => return Err(Self::resolve_config_error(&logging_config, error)),
383            };
384        }
385
386        if config.contains_prefix("retry") {
387            let retry_config = config.prefix_view("retry");
388            opts.retry = match HttpRetryOptions::from_config(&retry_config) {
389                Ok(retry) => retry,
390                Err(error) => return Err(Self::resolve_config_error(&retry_config, error)),
391            };
392        }
393
394        if config.contains_prefix("sse") {
395            let sse_config = config.prefix_view("sse");
396            let sse = match Self::read_sse_config(&sse_config) {
397                Ok(sse) => sse,
398                Err(error) => return Err(Self::resolve_config_error(&sse_config, HttpConfigError::from(error))),
399            };
400            if let Some(mode) = sse.json_mode.as_deref() {
401                opts.sse_json_mode = match Self::parse_sse_json_mode(mode) {
402                    Ok(mode) => mode,
403                    Err(error) => return Err(Self::resolve_config_error(&sse_config, error)),
404                };
405            }
406            if let Some(marker) = sse.done_marker.as_deref() {
407                opts.sse_done_marker_policy = match Self::parse_sse_done_marker_policy(marker) {
408                    Ok(marker) => marker,
409                    Err(error) => return Err(Self::resolve_config_error(&sse_config, error)),
410                };
411            }
412            if let Some(max_line_bytes) = sse.max_line_bytes {
413                opts.sse_max_line_bytes = match Self::validate_positive_limit("max_line_bytes", max_line_bytes) {
414                    Ok(limit) => limit,
415                    Err(error) => return Err(Self::resolve_config_error(&sse_config, error)),
416                };
417            }
418            if let Some(max_frame_bytes) = sse.max_frame_bytes {
419                opts.sse_max_frame_bytes = match Self::validate_positive_limit("max_frame_bytes", max_frame_bytes) {
420                    Ok(limit) => limit,
421                    Err(error) => return Err(Self::resolve_config_error(&sse_config, error)),
422                };
423            }
424        }
425
426        if config.contains_prefix("log_sanitize") {
427            let log_sanitize_config = config.prefix_view("log_sanitize");
428            let log_sanitize = match Self::read_log_sanitize_config(&log_sanitize_config) {
429                Ok(log_sanitize) => log_sanitize,
430                Err(error) => {
431                    return Err(Self::resolve_config_error(
432                        &log_sanitize_config,
433                        HttpConfigError::from(error),
434                    ))
435                }
436            };
437            if let Some(names) = log_sanitize.sensitive_headers {
438                opts.log_sanitize_policy
439                    .sensitive_headers
440                    .extend(names, SensitivityLevel::High);
441            }
442            if let Some(names) = log_sanitize.sensitive_query_params {
443                opts.log_sanitize_policy
444                    .sensitive_query_params
445                    .extend(names, SensitivityLevel::High);
446            }
447            if let Some(names) = log_sanitize.sensitive_body_fields {
448                opts.log_sanitize_policy
449                    .sensitive_body_fields
450                    .extend(names, SensitivityLevel::High);
451            }
452        }
453
454        // default_headers – sub-key form: default_headers.<name> = <value>
455        let headers_prefix = "default_headers";
456        let full_headers_prefix = "default_headers.";
457        let mut header_map: HashMap<String, String> = HashMap::new();
458        for (k, _) in config.iter_prefix(full_headers_prefix) {
459            let header_name = &k[full_headers_prefix.len()..];
460            let value = match config.get_string(k) {
461                Ok(value) => value,
462                Err(error) => return Err(HttpConfigError::config_error(config.resolve_key(k), error.to_string())),
463            };
464            header_map.insert(header_name.to_string(), value);
465        }
466        // Also support JSON map form stored at the exact key `default_headers`.
467        let json_headers = match config.get_optional_string(headers_prefix) {
468            Ok(json_headers) => json_headers,
469            Err(error) => return Err(Self::resolve_config_error(config, HttpConfigError::from(error))),
470        };
471        if !header_map.is_empty() && json_headers.is_some() {
472            return Err(HttpConfigError::invalid_value(
473                config.resolve_key(headers_prefix),
474                "default_headers sub-key form and JSON map form cannot be used at the same time",
475            ));
476        }
477        if let Some(json_str) = json_headers {
478            let parsed: HashMap<String, String> = match serde_json::from_str(&json_str) {
479                Ok(parsed) => parsed,
480                Err(error) => {
481                    return Err(HttpConfigError::type_error(
482                        config.resolve_key(headers_prefix),
483                        format!("Failed to parse default_headers JSON: {error}"),
484                    ))
485                }
486            };
487            header_map = parsed;
488        }
489        if !header_map.is_empty() {
490            opts.default_headers = hashmap_to_headermap(&config.resolve_key(headers_prefix), header_map)?;
491        }
492
493        Ok(opts)
494    }
495
496    /// Runs [`ProxyOptions::validate`], [`HttpLoggingOptions::validate`], retry validation,
497    /// and SSE limit validation.
498    ///
499    /// # Returns
500    /// `Ok(())` or the first sub-validator error.
501    pub fn validate(&self) -> Result<(), HttpConfigError> {
502        self.timeouts
503            .validate()
504            .map_err(|e| e.prepend_path_prefix("timeouts"))?;
505        self.proxy.validate()?;
506        self.logging.validate()?;
507        self.retry.validate().map_err(|e| e.prepend_path_prefix("retry"))?;
508        Self::validate_positive_limit("error_response_preview_limit", self.error_response_preview_limit)?;
509        if let Some(user_agent) = self.user_agent.as_deref() {
510            if user_agent.trim().is_empty() {
511                return Err(HttpConfigError::invalid_value("user_agent", "Value cannot be empty"));
512            }
513            HeaderValue::from_str(user_agent).map_err(|error| {
514                HttpConfigError::invalid_value("user_agent", format!("Invalid header value: {error}"))
515            })?;
516        }
517        Self::validate_positive_limit("sse.max_line_bytes", self.sse_max_line_bytes)?;
518        Self::validate_positive_limit("sse.max_frame_bytes", self.sse_max_frame_bytes)?;
519        Ok(())
520    }
521}