1use 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#[derive(Debug, Clone)]
38pub struct HttpClientOptions {
39 pub base_url: Option<Url>,
41 pub default_headers: HeaderMap,
43 pub timeouts: HttpTimeoutOptions,
45 pub proxy: ProxyOptions,
47 pub logging: HttpLoggingOptions,
49 pub error_response_preview_limit: usize,
51 pub user_agent: Option<String>,
53 pub max_redirects: Option<usize>,
55 pub pool_idle_timeout: Option<Duration>,
57 pub pool_max_idle_per_host: Option<usize>,
59 pub use_env_proxy: bool,
62 pub retry: HttpRetryOptions,
64 pub sensitive_headers: SensitiveHttpHeaders,
66 pub ipv4_only: bool,
68 pub sse_json_mode: SseJsonMode,
70 pub sse_done_marker_policy: DoneMarkerPolicy,
72 pub sse_max_line_bytes: usize,
74 pub sse_max_frame_bytes: usize,
76}
77
78impl Default for HttpClientOptions {
79 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
109struct 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
122struct 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 pub fn new() -> Self {
214 Self::default()
215 }
216
217 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 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 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 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 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 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 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 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 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 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#[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}