1use 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#[derive(Clone)]
51pub struct HttpClientOptions {
52 pub base_url: Option<Url>,
54 pub default_headers: HeaderMap,
56 pub timeouts: HttpTimeoutOptions,
58 pub proxy: ProxyOptions,
60 pub logging: HttpLoggingOptions,
62 pub error_response_preview_limit: usize,
64 pub user_agent: Option<String>,
66 pub max_redirects: Option<usize>,
68 pub pool_idle_timeout: Option<Duration>,
70 pub pool_max_idle_per_host: Option<usize>,
72 pub use_env_proxy: bool,
75 pub retry: HttpRetryOptions,
77 pub log_sanitize_policy: LogSanitizePolicy,
79 pub ipv4_only: bool,
81 pub sse_json_mode: SseJsonMode,
83 pub sse_done_marker_policy: DoneMarkerPolicy,
85 pub sse_max_line_bytes: usize,
87 pub sse_max_frame_bytes: usize,
89}
90
91impl Default for HttpClientOptions {
92 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
153struct 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
165struct 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
173struct 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 pub fn new() -> Self {
274 Self::default()
275 }
276
277 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 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 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 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 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 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 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 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 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 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}