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