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;
11
12use http::HeaderMap;
13use qubit_config::{ConfigReader, ConfigResult};
14use url::Url;
15
16use super::from_config_helpers::hashmap_to_headermap;
17use super::http_retry_options::HttpRetryOptions;
18use super::logging_options::HttpLoggingOptions;
19use super::proxy_options::ProxyOptions;
20use super::sensitive_headers::SensitiveHeaders;
21use super::timeout_options::TimeoutOptions;
22use super::HttpConfigError;
23use crate::{
24    constants::{DEFAULT_SSE_MAX_FRAME_BYTES, DEFAULT_SSE_MAX_LINE_BYTES},
25    request::parse_header,
26    sse::SseJsonMode,
27    HttpResult,
28};
29
30/// Aggregated settings for [`crate::HttpClient`] and [`crate::HttpClientFactory`].
31#[derive(Debug, Clone)]
32pub struct HttpClientOptions {
33    /// Optional base URL.
34    pub base_url: Option<Url>,
35    /// Default request headers.
36    pub default_headers: HeaderMap,
37    /// Timeout options.
38    pub timeouts: TimeoutOptions,
39    /// Proxy options.
40    pub proxy: ProxyOptions,
41    /// Logging options.
42    pub logging: HttpLoggingOptions,
43    /// Retry options.
44    pub retry: HttpRetryOptions,
45    /// Sensitive headers for masking.
46    pub sensitive_headers: SensitiveHeaders,
47    /// Whether IPv4-only DNS behavior is requested.
48    pub ipv4_only: bool,
49    /// Default JSON handling mode used by [`crate::HttpStreamResponse::decode_json_chunks`].
50    pub sse_json_mode: SseJsonMode,
51    /// Default maximum bytes for one SSE line.
52    pub sse_max_line_bytes: usize,
53    /// Default maximum bytes for one SSE frame.
54    pub sse_max_frame_bytes: usize,
55}
56
57impl Default for HttpClientOptions {
58    /// Default: no base URL, empty headers, default timeouts/proxy/logging,
59    /// default sensitive headers, IPv4-only off, lenient SSE JSON mode with
60    /// crate default SSE line/frame limits.
61    ///
62    /// # Returns
63    /// Default [`HttpClientOptions`].
64    fn default() -> Self {
65        Self {
66            base_url: None,
67            default_headers: HeaderMap::new(),
68            timeouts: TimeoutOptions::default(),
69            proxy: ProxyOptions::default(),
70            logging: HttpLoggingOptions::default(),
71            retry: HttpRetryOptions::default(),
72            sensitive_headers: SensitiveHeaders::default(),
73            ipv4_only: false,
74            sse_json_mode: SseJsonMode::Lenient,
75            sse_max_line_bytes: DEFAULT_SSE_MAX_LINE_BYTES,
76            sse_max_frame_bytes: DEFAULT_SSE_MAX_FRAME_BYTES,
77        }
78    }
79}
80
81/// Top-level scalar keys read before nested sections and `default_headers` iteration.
82struct HttpClientRootConfigInput {
83    base_url: Option<String>,
84    ipv4_only: Option<bool>,
85    sensitive_headers: Option<Vec<String>>,
86}
87
88/// SSE scalar keys read from `sse.*`.
89struct HttpClientSseConfigInput {
90    json_mode: Option<String>,
91    max_line_bytes: Option<usize>,
92    max_frame_bytes: Option<usize>,
93}
94
95impl HttpClientOptions {
96    fn resolve_config_error<R>(config: &R, mut error: HttpConfigError) -> HttpConfigError
97    where
98        R: ConfigReader + ?Sized,
99    {
100        error.path = if error.path.is_empty() {
101            config.resolve_key("")
102        } else {
103            config.resolve_key(&error.path)
104        };
105        error
106    }
107
108    fn read_config<R>(config: &R) -> ConfigResult<HttpClientRootConfigInput>
109    where
110        R: ConfigReader + ?Sized,
111    {
112        Ok(HttpClientRootConfigInput {
113            base_url: config.get_optional_string("base_url")?,
114            ipv4_only: config.get_optional("ipv4_only")?,
115            sensitive_headers: config.get_optional_string_list("sensitive_headers")?,
116        })
117    }
118
119    fn read_sse_config<R>(config: &R) -> ConfigResult<HttpClientSseConfigInput>
120    where
121        R: ConfigReader + ?Sized,
122    {
123        Ok(HttpClientSseConfigInput {
124            json_mode: config.get_optional_string("json_mode")?,
125            max_line_bytes: config.get_optional("max_line_bytes")?,
126            max_frame_bytes: config.get_optional("max_frame_bytes")?,
127        })
128    }
129
130    fn parse_base_url(base_url: &str) -> Result<Url, HttpConfigError> {
131        Url::parse(base_url).map_err(|error| {
132            HttpConfigError::invalid_value("base_url", format!("Invalid URL: {error}"))
133        })
134    }
135
136    fn parse_sse_json_mode(value: &str) -> Result<SseJsonMode, HttpConfigError> {
137        let normalized = value.trim().to_ascii_uppercase().replace('-', "_");
138        match normalized.as_str() {
139            "LENIENT" => Ok(SseJsonMode::Lenient),
140            "STRICT" => Ok(SseJsonMode::Strict),
141            _ => Err(HttpConfigError::invalid_value(
142                "json_mode",
143                format!("Unsupported SSE JSON mode: {value}"),
144            )),
145        }
146    }
147
148    fn validate_sse_limit(path: &str, value: usize) -> Result<usize, HttpConfigError> {
149        if value == 0 {
150            return Err(HttpConfigError::invalid_value(
151                path,
152                "Value must be greater than 0",
153            ));
154        }
155        Ok(value)
156    }
157
158    /// Same as [`HttpClientOptions::default`].
159    ///
160    /// # Returns
161    /// Fresh options with crate defaults.
162    pub fn new() -> Self {
163        Self::default()
164    }
165
166    /// Parses and sets the base URL used to resolve relative request paths.
167    ///
168    /// # Parameters
169    /// - `base_url`: Absolute base URL string.
170    ///
171    /// # Returns
172    /// `Ok(self)` or [`HttpConfigError`] if the URL is invalid.
173    pub fn set_base_url(&mut self, base_url: &str) -> Result<&mut Self, HttpConfigError> {
174        let parsed = Self::parse_base_url(base_url)?;
175        self.base_url = Some(parsed);
176        Ok(self)
177    }
178
179    /// Validates and adds one client-level default header.
180    ///
181    /// # Parameters
182    /// - `name`: Header name.
183    /// - `value`: Header value.
184    ///
185    /// # Returns
186    /// `Ok(self)` or an error if name/value are invalid.
187    pub fn add_header(&mut self, name: &str, value: &str) -> HttpResult<&mut Self> {
188        let (header_name, header_value) = parse_header(name, value)?;
189        self.default_headers.insert(header_name, header_value);
190        Ok(self)
191    }
192
193    /// Validates and adds many client-level default headers atomically.
194    ///
195    /// If any input pair is invalid, no header from this batch is applied.
196    ///
197    /// # Parameters
198    /// - `headers`: Iterator of `(name, value)` pairs.
199    ///
200    /// # Returns
201    /// `Ok(self)` or an error if any pair is invalid.
202    pub fn add_headers<'a, I>(&mut self, headers: I) -> HttpResult<&mut Self>
203    where
204        I: IntoIterator<Item = (&'a str, &'a str)>,
205    {
206        let mut parsed_headers = HeaderMap::new();
207        for (name, value) in headers {
208            let (header_name, header_value) = parse_header(name, value)?;
209            parsed_headers.insert(header_name, header_value);
210        }
211        self.default_headers.extend(parsed_headers);
212        Ok(self)
213    }
214
215    /// Creates [`HttpClientOptions`] from `config` using **relative** keys.
216    ///
217    /// # Parameters
218    /// - `config`: Any [`ConfigReader`] (full [`qubit_config::Config`] or a
219    ///   [`qubit_config::ConfigPrefixView`] from [`ConfigReader::prefix_view`]).
220    ///
221    /// # Returns
222    /// Parsed options or [`HttpConfigError`].
223    pub fn from_config<R>(config: &R) -> Result<Self, HttpConfigError>
224    where
225        R: ConfigReader + ?Sized,
226    {
227        let mut opts = HttpClientOptions::default();
228
229        let root = Self::read_config(config)
230            .map_err(HttpConfigError::from)
231            .map_err(|e| Self::resolve_config_error(config, e))?;
232
233        if let Some(s) = root.base_url {
234            opts.set_base_url(&s)
235                .map_err(|e| Self::resolve_config_error(config, e))?;
236        }
237
238        if let Some(v) = root.ipv4_only {
239            opts.ipv4_only = v;
240        }
241
242        // timeouts
243        if config.contains_prefix("timeouts") {
244            let timeouts_config = config.prefix_view("timeouts");
245            opts.timeouts = TimeoutOptions::from_config(&timeouts_config)
246                .map_err(|e| Self::resolve_config_error(&timeouts_config, e))?;
247        }
248
249        // proxy
250        if config.contains_prefix("proxy") {
251            let proxy_config = config.prefix_view("proxy");
252            opts.proxy = ProxyOptions::from_config(&proxy_config)
253                .map_err(|e| Self::resolve_config_error(&proxy_config, e))?;
254        }
255
256        // logging
257        if config.contains_prefix("logging") {
258            let logging_config = config.prefix_view("logging");
259            opts.logging = HttpLoggingOptions::from_config(&logging_config)
260                .map_err(|e| Self::resolve_config_error(&logging_config, e))?;
261        }
262
263        if config.contains_prefix("retry") {
264            let retry_config = config.prefix_view("retry");
265            opts.retry = HttpRetryOptions::from_config(&retry_config)
266                .map_err(|e| Self::resolve_config_error(&retry_config, e))?;
267        }
268
269        if config.contains_prefix("sse") {
270            let sse_config = config.prefix_view("sse");
271            let sse = Self::read_sse_config(&sse_config)
272                .map_err(HttpConfigError::from)
273                .map_err(|e| Self::resolve_config_error(&sse_config, e))?;
274            if let Some(mode) = sse.json_mode.as_deref() {
275                opts.sse_json_mode = Self::parse_sse_json_mode(mode)
276                    .map_err(|e| Self::resolve_config_error(&sse_config, e))?;
277            }
278            if let Some(max_line_bytes) = sse.max_line_bytes {
279                opts.sse_max_line_bytes =
280                    Self::validate_sse_limit("max_line_bytes", max_line_bytes)
281                        .map_err(|e| Self::resolve_config_error(&sse_config, e))?;
282            }
283            if let Some(max_frame_bytes) = sse.max_frame_bytes {
284                opts.sse_max_frame_bytes =
285                    Self::validate_sse_limit("max_frame_bytes", max_frame_bytes)
286                        .map_err(|e| Self::resolve_config_error(&sse_config, e))?;
287            }
288        }
289
290        // default_headers – sub-key form: default_headers.<name> = <value>
291        let headers_prefix = "default_headers";
292        let full_headers_prefix = "default_headers.";
293        let mut header_map: HashMap<String, String> = HashMap::new();
294        for (k, _) in config.iter_prefix(full_headers_prefix) {
295            let header_name = &k[full_headers_prefix.len()..];
296            let value = config
297                .get_string(k)
298                .map_err(|e| HttpConfigError::config_error(config.resolve_key(k), e.to_string()))?;
299            header_map.insert(header_name.to_string(), value);
300        }
301        // Also support JSON map form stored at the exact key `default_headers`.
302        if header_map.is_empty() {
303            if let Some(json_str) = config
304                .get_optional_string(headers_prefix)
305                .map_err(HttpConfigError::from)
306                .map_err(|e| Self::resolve_config_error(config, e))?
307            {
308                let parsed: HashMap<String, String> =
309                    serde_json::from_str(&json_str).map_err(|e| {
310                        HttpConfigError::type_error(
311                            config.resolve_key(headers_prefix),
312                            format!("Failed to parse default_headers JSON: {e}"),
313                        )
314                    })?;
315                header_map = parsed;
316            }
317        }
318        if !header_map.is_empty() {
319            opts.default_headers = hashmap_to_headermap(headers_prefix, header_map)?;
320        }
321
322        if let Some(names) = root.sensitive_headers {
323            let mut sh = SensitiveHeaders::new();
324            sh.extend(names);
325            opts.sensitive_headers = sh;
326        }
327
328        Ok(opts)
329    }
330
331    /// Runs [`ProxyOptions::validate`], [`HttpLoggingOptions::validate`], retry validation,
332    /// and SSE limit validation.
333    ///
334    /// # Returns
335    /// `Ok(())` or the first sub-validator error.
336    pub fn validate(&self) -> Result<(), HttpConfigError> {
337        self.proxy.validate()?;
338        self.logging.validate()?;
339        self.retry
340            .validate()
341            .map_err(|e| e.prepend_path_prefix("retry"))?;
342        Self::validate_sse_limit("sse.max_line_bytes", self.sse_max_line_bytes)?;
343        Self::validate_sse_limit("sse.max_frame_bytes", self.sse_max_frame_bytes)?;
344        Ok(())
345    }
346}