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