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