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(
134                "error_response_preview_limit",
135                &self.error_response_preview_limit,
136            )
137            .field("user_agent", &self.user_agent)
138            .field("max_redirects", &self.max_redirects)
139            .field("pool_idle_timeout", &self.pool_idle_timeout)
140            .field("pool_max_idle_per_host", &self.pool_max_idle_per_host)
141            .field("use_env_proxy", &self.use_env_proxy)
142            .field("retry", &self.retry)
143            .field("log_sanitize_policy", &self.log_sanitize_policy)
144            .field("ipv4_only", &self.ipv4_only)
145            .field("sse_json_mode", &self.sse_json_mode)
146            .field("sse_done_marker_policy", &self.sse_done_marker_policy)
147            .field("sse_max_line_bytes", &self.sse_max_line_bytes)
148            .field("sse_max_frame_bytes", &self.sse_max_frame_bytes)
149            .finish()
150    }
151}
152
153/// Top-level scalar keys read before nested sections and `default_headers` iteration.
154struct HttpClientRootConfigInput {
155    base_url: Option<String>,
156    ipv4_only: Option<bool>,
157    error_response_preview_limit: Option<usize>,
158    user_agent: Option<String>,
159    max_redirects: Option<usize>,
160    pool_idle_timeout: Option<Duration>,
161    pool_max_idle_per_host: Option<usize>,
162    use_env_proxy: Option<bool>,
163}
164
165/// SSE scalar keys read from `sse.*`.
166struct HttpClientSseConfigInput {
167    json_mode: Option<String>,
168    done_marker: Option<String>,
169    max_line_bytes: Option<usize>,
170    max_frame_bytes: Option<usize>,
171}
172
173/// Log sanitization keys read from `log_sanitize.*` and added to defaults.
174struct HttpClientLogSanitizeConfigInput {
175    sensitive_headers: Option<Vec<String>>,
176    sensitive_query_params: Option<Vec<String>>,
177    sensitive_body_fields: Option<Vec<String>>,
178}
179
180impl HttpClientOptions {
181    fn resolve_config_error<R>(config: &R, mut error: HttpConfigError) -> HttpConfigError
182    where
183        R: ConfigReader + ?Sized,
184    {
185        error.path = if error.path.is_empty() {
186            config.resolve_key("")
187        } else {
188            config.resolve_key(&error.path)
189        };
190        error
191    }
192
193    fn read_config<R>(config: &R) -> ConfigResult<HttpClientRootConfigInput>
194    where
195        R: ConfigReader + ?Sized,
196    {
197        Ok(HttpClientRootConfigInput {
198            base_url: config.get_optional_string("base_url")?,
199            ipv4_only: config.get_optional("ipv4_only")?,
200            error_response_preview_limit: config.get_optional("error_response_preview_limit")?,
201            user_agent: config.get_optional_string("user_agent")?,
202            max_redirects: config.get_optional("max_redirects")?,
203            pool_idle_timeout: config.get_optional("pool_idle_timeout")?,
204            pool_max_idle_per_host: config.get_optional("pool_max_idle_per_host")?,
205            use_env_proxy: config.get_optional("use_env_proxy")?,
206        })
207    }
208
209    fn read_sse_config<R>(config: &R) -> ConfigResult<HttpClientSseConfigInput>
210    where
211        R: ConfigReader + ?Sized,
212    {
213        Ok(HttpClientSseConfigInput {
214            json_mode: config.get_optional_string("json_mode")?,
215            done_marker: config.get_optional_string("done_marker")?,
216            max_line_bytes: config.get_optional("max_line_bytes")?,
217            max_frame_bytes: config.get_optional("max_frame_bytes")?,
218        })
219    }
220
221    fn read_log_sanitize_config<R>(config: &R) -> ConfigResult<HttpClientLogSanitizeConfigInput>
222    where
223        R: ConfigReader + ?Sized,
224    {
225        Ok(HttpClientLogSanitizeConfigInput {
226            sensitive_headers: config.get_optional_string_list("sensitive_headers")?,
227            sensitive_query_params: config.get_optional_string_list("sensitive_query_params")?,
228            sensitive_body_fields: config.get_optional_string_list("sensitive_body_fields")?,
229        })
230    }
231
232    fn parse_sse_done_marker_policy(value: &str) -> Result<DoneMarkerPolicy, HttpConfigError> {
233        let trimmed = value.trim();
234        if trimmed.is_empty() {
235            return Err(HttpConfigError::invalid_value(
236                "done_marker",
237                "Value must not be empty",
238            ));
239        }
240        Ok(DoneMarkerPolicy::from_str(trimmed)
241            .expect("DoneMarkerPolicy::from_str accepts arbitrary custom markers"))
242    }
243
244    fn parse_base_url(base_url: &str) -> Result<Url, HttpConfigError> {
245        Url::parse(base_url).map_err(|error| {
246            HttpConfigError::invalid_value("base_url", format!("Invalid URL: {error}"))
247        })
248    }
249
250    fn parse_sse_json_mode(value: &str) -> Result<SseJsonMode, HttpConfigError> {
251        SseJsonMode::from_str(value.trim()).map_err(|_| {
252            HttpConfigError::invalid_value(
253                "json_mode",
254                format!("Unsupported SSE JSON mode: {value}"),
255            )
256        })
257    }
258
259    fn validate_positive_limit(path: &str, value: usize) -> Result<usize, HttpConfigError> {
260        if value == 0 {
261            return Err(HttpConfigError::invalid_value(
262                path,
263                "Value must be greater than 0",
264            ));
265        }
266        Ok(value)
267    }
268
269    /// Same as [`HttpClientOptions::default`].
270    ///
271    /// # Returns
272    /// Fresh options with crate defaults.
273    pub fn new() -> Self {
274        Self::default()
275    }
276
277    /// Parses and sets the base URL used to resolve relative request paths.
278    ///
279    /// # Parameters
280    /// - `base_url`: Absolute base URL string.
281    ///
282    /// # Returns
283    /// `Ok(self)` or [`HttpConfigError`] if the URL is invalid.
284    pub fn set_base_url(&mut self, base_url: &str) -> Result<&mut Self, HttpConfigError> {
285        let parsed = Self::parse_base_url(base_url)?;
286        self.base_url = Some(parsed);
287        Ok(self)
288    }
289
290    /// Validates and adds one client-level default header.
291    ///
292    /// # Parameters
293    /// - `name`: Header name.
294    /// - `value`: Header value.
295    ///
296    /// # Returns
297    /// `Ok(self)` or an error if name/value are invalid.
298    pub fn add_header(&mut self, name: &str, value: &str) -> HttpResult<&mut Self> {
299        let (header_name, header_value) = parse_header(name, value)?;
300        self.default_headers.insert(header_name, header_value);
301        Ok(self)
302    }
303
304    /// Validates and adds many client-level default headers atomically.
305    ///
306    /// If any input pair is invalid, no header from this batch is applied.
307    ///
308    /// # Parameters
309    /// - `headers`: Iterator of `(name, value)` pairs.
310    ///
311    /// # Returns
312    /// `Ok(self)` or an error if any pair is invalid.
313    pub fn add_headers(&mut self, headers: &[(&str, &str)]) -> HttpResult<&mut Self> {
314        let mut parsed_headers = HeaderMap::new();
315        for &(name, value) in headers {
316            let (header_name, header_value) = parse_header(name, value)?;
317            parsed_headers.insert(header_name, header_value);
318        }
319        self.default_headers.extend(parsed_headers);
320        Ok(self)
321    }
322
323    /// Creates [`HttpClientOptions`] from `config` using **relative** keys.
324    ///
325    /// # Parameters
326    /// - `config`: Any [`ConfigReader`] (full [`qubit_config::Config`] or a
327    ///   [`qubit_config::ConfigPrefixView`] from [`ConfigReader::prefix_view`]).
328    ///
329    /// # Returns
330    /// Parsed options or [`HttpConfigError`].
331    pub fn from_config<R>(config: &R) -> Result<Self, HttpConfigError>
332    where
333        R: ConfigReader + ?Sized,
334    {
335        let mut opts = HttpClientOptions::default();
336
337        let root = match Self::read_config(config) {
338            Ok(root) => root,
339            Err(error) => {
340                return Err(Self::resolve_config_error(
341                    config,
342                    HttpConfigError::from(error),
343                ))
344            }
345        };
346
347        if let Some(s) = root.base_url {
348            if let Err(error) = opts.set_base_url(&s) {
349                return Err(Self::resolve_config_error(config, error));
350            }
351        }
352
353        if let Some(v) = root.ipv4_only {
354            opts.ipv4_only = v;
355        }
356        if let Some(limit) = root.error_response_preview_limit {
357            opts.error_response_preview_limit =
358                match Self::validate_positive_limit("error_response_preview_limit", limit) {
359                    Ok(limit) => limit,
360                    Err(error) => return Err(Self::resolve_config_error(config, error)),
361                };
362        }
363        if let Some(user_agent) = root.user_agent {
364            opts.user_agent = Some(user_agent.trim().to_string());
365        }
366        if let Some(max_redirects) = root.max_redirects {
367            opts.max_redirects = Some(max_redirects);
368        }
369        if let Some(pool_idle_timeout) = root.pool_idle_timeout {
370            opts.pool_idle_timeout = Some(pool_idle_timeout);
371        }
372        if let Some(pool_max_idle_per_host) = root.pool_max_idle_per_host {
373            opts.pool_max_idle_per_host = Some(pool_max_idle_per_host);
374        }
375        if let Some(use_env_proxy) = root.use_env_proxy {
376            opts.use_env_proxy = use_env_proxy;
377        }
378
379        // timeouts
380        if config.contains_prefix("timeouts") {
381            let timeouts_config = config.prefix_view("timeouts");
382            opts.timeouts = match HttpTimeoutOptions::from_config(&timeouts_config) {
383                Ok(timeouts) => timeouts,
384                Err(error) => return Err(Self::resolve_config_error(&timeouts_config, error)),
385            };
386        }
387
388        // proxy
389        if config.contains_prefix("proxy") {
390            let proxy_config = config.prefix_view("proxy");
391            opts.proxy = match ProxyOptions::from_config(&proxy_config) {
392                Ok(proxy) => proxy,
393                Err(error) => return Err(Self::resolve_config_error(&proxy_config, error)),
394            };
395        }
396
397        // logging
398        if config.contains_prefix("logging") {
399            let logging_config = config.prefix_view("logging");
400            opts.logging = match HttpLoggingOptions::from_config(&logging_config) {
401                Ok(logging) => logging,
402                Err(error) => return Err(Self::resolve_config_error(&logging_config, error)),
403            };
404        }
405
406        if config.contains_prefix("retry") {
407            let retry_config = config.prefix_view("retry");
408            opts.retry = match HttpRetryOptions::from_config(&retry_config) {
409                Ok(retry) => retry,
410                Err(error) => return Err(Self::resolve_config_error(&retry_config, error)),
411            };
412        }
413
414        if config.contains_prefix("sse") {
415            let sse_config = config.prefix_view("sse");
416            let sse = match Self::read_sse_config(&sse_config) {
417                Ok(sse) => sse,
418                Err(error) => {
419                    return Err(Self::resolve_config_error(
420                        &sse_config,
421                        HttpConfigError::from(error),
422                    ))
423                }
424            };
425            if let Some(mode) = sse.json_mode.as_deref() {
426                opts.sse_json_mode = match Self::parse_sse_json_mode(mode) {
427                    Ok(mode) => mode,
428                    Err(error) => return Err(Self::resolve_config_error(&sse_config, error)),
429                };
430            }
431            if let Some(marker) = sse.done_marker.as_deref() {
432                opts.sse_done_marker_policy = match Self::parse_sse_done_marker_policy(marker) {
433                    Ok(marker) => marker,
434                    Err(error) => return Err(Self::resolve_config_error(&sse_config, error)),
435                };
436            }
437            if let Some(max_line_bytes) = sse.max_line_bytes {
438                opts.sse_max_line_bytes =
439                    match Self::validate_positive_limit("max_line_bytes", max_line_bytes) {
440                        Ok(limit) => limit,
441                        Err(error) => return Err(Self::resolve_config_error(&sse_config, error)),
442                    };
443            }
444            if let Some(max_frame_bytes) = sse.max_frame_bytes {
445                opts.sse_max_frame_bytes =
446                    match Self::validate_positive_limit("max_frame_bytes", max_frame_bytes) {
447                        Ok(limit) => limit,
448                        Err(error) => return Err(Self::resolve_config_error(&sse_config, error)),
449                    };
450            }
451        }
452
453        if config.contains_prefix("log_sanitize") {
454            let log_sanitize_config = config.prefix_view("log_sanitize");
455            let log_sanitize = match Self::read_log_sanitize_config(&log_sanitize_config) {
456                Ok(log_sanitize) => log_sanitize,
457                Err(error) => {
458                    return Err(Self::resolve_config_error(
459                        &log_sanitize_config,
460                        HttpConfigError::from(error),
461                    ))
462                }
463            };
464            if let Some(names) = log_sanitize.sensitive_headers {
465                opts.log_sanitize_policy
466                    .sensitive_headers
467                    .extend(names, SensitivityLevel::High);
468            }
469            if let Some(names) = log_sanitize.sensitive_query_params {
470                opts.log_sanitize_policy
471                    .sensitive_query_params
472                    .extend(names, SensitivityLevel::High);
473            }
474            if let Some(names) = log_sanitize.sensitive_body_fields {
475                opts.log_sanitize_policy
476                    .sensitive_body_fields
477                    .extend(names, SensitivityLevel::High);
478            }
479        }
480
481        // default_headers – sub-key form: default_headers.<name> = <value>
482        let headers_prefix = "default_headers";
483        let full_headers_prefix = "default_headers.";
484        let mut header_map: HashMap<String, String> = HashMap::new();
485        for (k, _) in config.iter_prefix(full_headers_prefix) {
486            let header_name = &k[full_headers_prefix.len()..];
487            let value = match config.get_string(k) {
488                Ok(value) => value,
489                Err(error) => {
490                    return Err(HttpConfigError::config_error(
491                        config.resolve_key(k),
492                        error.to_string(),
493                    ))
494                }
495            };
496            header_map.insert(header_name.to_string(), value);
497        }
498        // Also support JSON map form stored at the exact key `default_headers`.
499        let json_headers = match config.get_optional_string(headers_prefix) {
500            Ok(json_headers) => json_headers,
501            Err(error) => {
502                return Err(Self::resolve_config_error(
503                    config,
504                    HttpConfigError::from(error),
505                ))
506            }
507        };
508        if !header_map.is_empty() && json_headers.is_some() {
509            return Err(HttpConfigError::invalid_value(
510                config.resolve_key(headers_prefix),
511                "default_headers sub-key form and JSON map form cannot be used at the same time",
512            ));
513        }
514        if let Some(json_str) = json_headers {
515            let parsed: HashMap<String, String> = match serde_json::from_str(&json_str) {
516                Ok(parsed) => parsed,
517                Err(error) => {
518                    return Err(HttpConfigError::type_error(
519                        config.resolve_key(headers_prefix),
520                        format!("Failed to parse default_headers JSON: {error}"),
521                    ))
522                }
523            };
524            header_map = parsed;
525        }
526        if !header_map.is_empty() {
527            opts.default_headers =
528                hashmap_to_headermap(&config.resolve_key(headers_prefix), header_map)?;
529        }
530
531        Ok(opts)
532    }
533
534    /// Runs [`ProxyOptions::validate`], [`HttpLoggingOptions::validate`], retry validation,
535    /// and SSE limit validation.
536    ///
537    /// # Returns
538    /// `Ok(())` or the first sub-validator error.
539    pub fn validate(&self) -> Result<(), HttpConfigError> {
540        self.timeouts
541            .validate()
542            .map_err(|e| e.prepend_path_prefix("timeouts"))?;
543        self.proxy.validate()?;
544        self.logging.validate()?;
545        self.retry
546            .validate()
547            .map_err(|e| e.prepend_path_prefix("retry"))?;
548        Self::validate_positive_limit(
549            "error_response_preview_limit",
550            self.error_response_preview_limit,
551        )?;
552        if let Some(user_agent) = self.user_agent.as_deref() {
553            if user_agent.trim().is_empty() {
554                return Err(HttpConfigError::invalid_value(
555                    "user_agent",
556                    "Value cannot be empty",
557                ));
558            }
559            HeaderValue::from_str(user_agent).map_err(|error| {
560                HttpConfigError::invalid_value(
561                    "user_agent",
562                    format!("Invalid header value: {error}"),
563                )
564            })?;
565        }
566        Self::validate_positive_limit("sse.max_line_bytes", self.sse_max_line_bytes)?;
567        Self::validate_positive_limit("sse.max_frame_bytes", self.sse_max_frame_bytes)?;
568        Ok(())
569    }
570}