Skip to main content

qubit_http/options/
http_client_options.rs

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