1use std::collections::HashMap;
12use std::time::Duration;
13
14use http::HeaderMap;
15use http::HeaderValue;
16use qubit_config::{ConfigReader, ConfigResult};
17use std::str::FromStr;
18use url::Url;
19
20use super::from_config_helpers::hashmap_to_headermap;
21use super::http_logging_options::HttpLoggingOptions;
22use super::http_retry_options::HttpRetryOptions;
23use super::http_timeout_options::HttpTimeoutOptions;
24use super::proxy_options::ProxyOptions;
25use super::sensitive_http_headers::SensitiveHttpHeaders;
26use super::HttpConfigError;
27use crate::{
28 constants::{
29 DEFAULT_ERROR_RESPONSE_PREVIEW_LIMIT_BYTES, DEFAULT_SSE_MAX_FRAME_BYTES,
30 DEFAULT_SSE_MAX_LINE_BYTES,
31 },
32 request::parse_header,
33 sse::{DoneMarkerPolicy, SseJsonMode},
34 HttpResult,
35};
36
37#[derive(Debug, Clone)]
39pub struct HttpClientOptions {
40 pub base_url: Option<Url>,
42 pub default_headers: HeaderMap,
44 pub timeouts: HttpTimeoutOptions,
46 pub proxy: ProxyOptions,
48 pub logging: HttpLoggingOptions,
50 pub error_response_preview_limit: usize,
52 pub user_agent: Option<String>,
54 pub max_redirects: Option<usize>,
56 pub pool_idle_timeout: Option<Duration>,
58 pub pool_max_idle_per_host: Option<usize>,
60 pub use_env_proxy: bool,
63 pub retry: HttpRetryOptions,
65 pub sensitive_headers: SensitiveHttpHeaders,
67 pub ipv4_only: bool,
69 pub sse_json_mode: SseJsonMode,
71 pub sse_done_marker_policy: DoneMarkerPolicy,
73 pub sse_max_line_bytes: usize,
75 pub sse_max_frame_bytes: usize,
77}
78
79impl Default for HttpClientOptions {
80 fn default() -> Self {
87 Self {
88 base_url: None,
89 default_headers: HeaderMap::new(),
90 timeouts: HttpTimeoutOptions::default(),
91 proxy: ProxyOptions::default(),
92 logging: HttpLoggingOptions::default(),
93 error_response_preview_limit: DEFAULT_ERROR_RESPONSE_PREVIEW_LIMIT_BYTES,
94 user_agent: None,
95 max_redirects: None,
96 pool_idle_timeout: None,
97 pool_max_idle_per_host: None,
98 use_env_proxy: false,
99 retry: HttpRetryOptions::default(),
100 sensitive_headers: SensitiveHttpHeaders::default(),
101 ipv4_only: false,
102 sse_json_mode: SseJsonMode::Lenient,
103 sse_done_marker_policy: DoneMarkerPolicy::default(),
104 sse_max_line_bytes: DEFAULT_SSE_MAX_LINE_BYTES,
105 sse_max_frame_bytes: DEFAULT_SSE_MAX_FRAME_BYTES,
106 }
107 }
108}
109
110struct HttpClientRootConfigInput {
112 base_url: Option<String>,
113 ipv4_only: Option<bool>,
114 error_response_preview_limit: Option<usize>,
115 user_agent: Option<String>,
116 max_redirects: Option<usize>,
117 pool_idle_timeout: Option<Duration>,
118 pool_max_idle_per_host: Option<usize>,
119 use_env_proxy: Option<bool>,
120 sensitive_headers: Option<Vec<String>>,
121}
122
123struct HttpClientSseConfigInput {
125 json_mode: Option<String>,
126 done_marker: Option<String>,
127 max_line_bytes: Option<usize>,
128 max_frame_bytes: Option<usize>,
129}
130
131impl HttpClientOptions {
132 fn resolve_config_error<R>(config: &R, mut error: HttpConfigError) -> HttpConfigError
133 where
134 R: ConfigReader + ?Sized,
135 {
136 error.path = if error.path.is_empty() {
137 config.resolve_key("")
138 } else {
139 config.resolve_key(&error.path)
140 };
141 error
142 }
143
144 fn read_config<R>(config: &R) -> ConfigResult<HttpClientRootConfigInput>
145 where
146 R: ConfigReader + ?Sized,
147 {
148 Ok(HttpClientRootConfigInput {
149 base_url: config.get_optional_string("base_url")?,
150 ipv4_only: config.get_optional("ipv4_only")?,
151 error_response_preview_limit: config.get_optional("error_response_preview_limit")?,
152 user_agent: config.get_optional_string("user_agent")?,
153 max_redirects: config.get_optional("max_redirects")?,
154 pool_idle_timeout: config.get_optional("pool_idle_timeout")?,
155 pool_max_idle_per_host: config.get_optional("pool_max_idle_per_host")?,
156 use_env_proxy: config.get_optional("use_env_proxy")?,
157 sensitive_headers: config.get_optional_string_list("sensitive_headers")?,
158 })
159 }
160
161 fn read_sse_config<R>(config: &R) -> ConfigResult<HttpClientSseConfigInput>
162 where
163 R: ConfigReader + ?Sized,
164 {
165 Ok(HttpClientSseConfigInput {
166 json_mode: config.get_optional_string("json_mode")?,
167 done_marker: config.get_optional_string("done_marker")?,
168 max_line_bytes: config.get_optional("max_line_bytes")?,
169 max_frame_bytes: config.get_optional("max_frame_bytes")?,
170 })
171 }
172
173 fn parse_sse_done_marker_policy(value: &str) -> Result<DoneMarkerPolicy, HttpConfigError> {
174 let trimmed = value.trim();
175 if trimmed.is_empty() {
176 return Err(HttpConfigError::invalid_value(
177 "done_marker",
178 "Value must not be empty",
179 ));
180 }
181 Ok(DoneMarkerPolicy::from_str(trimmed)
182 .expect("DoneMarkerPolicy::from_str accepts arbitrary custom markers"))
183 }
184
185 fn parse_base_url(base_url: &str) -> Result<Url, HttpConfigError> {
186 Url::parse(base_url).map_err(|error| {
187 HttpConfigError::invalid_value("base_url", format!("Invalid URL: {error}"))
188 })
189 }
190
191 fn parse_sse_json_mode(value: &str) -> Result<SseJsonMode, HttpConfigError> {
192 SseJsonMode::from_str(value.trim()).map_err(|_| {
193 HttpConfigError::invalid_value(
194 "json_mode",
195 format!("Unsupported SSE JSON mode: {value}"),
196 )
197 })
198 }
199
200 fn validate_positive_limit(path: &str, value: usize) -> Result<usize, HttpConfigError> {
201 if value == 0 {
202 return Err(HttpConfigError::invalid_value(
203 path,
204 "Value must be greater than 0",
205 ));
206 }
207 Ok(value)
208 }
209
210 pub fn new() -> Self {
215 Self::default()
216 }
217
218 pub fn set_base_url(&mut self, base_url: &str) -> Result<&mut Self, HttpConfigError> {
226 let parsed = Self::parse_base_url(base_url)?;
227 self.base_url = Some(parsed);
228 Ok(self)
229 }
230
231 pub fn add_header(&mut self, name: &str, value: &str) -> HttpResult<&mut Self> {
240 let (header_name, header_value) = parse_header(name, value)?;
241 self.default_headers.insert(header_name, header_value);
242 Ok(self)
243 }
244
245 pub fn add_headers(&mut self, headers: &[(&str, &str)]) -> HttpResult<&mut Self> {
255 let mut parsed_headers = HeaderMap::new();
256 for &(name, value) in headers {
257 let (header_name, header_value) = parse_header(name, value)?;
258 parsed_headers.insert(header_name, header_value);
259 }
260 self.default_headers.extend(parsed_headers);
261 Ok(self)
262 }
263
264 pub fn from_config<R>(config: &R) -> Result<Self, HttpConfigError>
273 where
274 R: ConfigReader + ?Sized,
275 {
276 let mut opts = HttpClientOptions::default();
277
278 let root = match Self::read_config(config) {
279 Ok(root) => root,
280 Err(error) => {
281 return Err(Self::resolve_config_error(
282 config,
283 HttpConfigError::from(error),
284 ))
285 }
286 };
287
288 if let Some(s) = root.base_url {
289 if let Err(error) = opts.set_base_url(&s) {
290 return Err(Self::resolve_config_error(config, error));
291 }
292 }
293
294 if let Some(v) = root.ipv4_only {
295 opts.ipv4_only = v;
296 }
297 if let Some(limit) = root.error_response_preview_limit {
298 opts.error_response_preview_limit =
299 match Self::validate_positive_limit("error_response_preview_limit", limit) {
300 Ok(limit) => limit,
301 Err(error) => return Err(Self::resolve_config_error(config, error)),
302 };
303 }
304 if let Some(user_agent) = root.user_agent {
305 opts.user_agent = Some(user_agent.trim().to_string());
306 }
307 if let Some(max_redirects) = root.max_redirects {
308 opts.max_redirects = Some(max_redirects);
309 }
310 if let Some(pool_idle_timeout) = root.pool_idle_timeout {
311 opts.pool_idle_timeout = Some(pool_idle_timeout);
312 }
313 if let Some(pool_max_idle_per_host) = root.pool_max_idle_per_host {
314 opts.pool_max_idle_per_host = Some(pool_max_idle_per_host);
315 }
316 if let Some(use_env_proxy) = root.use_env_proxy {
317 opts.use_env_proxy = use_env_proxy;
318 }
319
320 if config.contains_prefix("timeouts") {
322 let timeouts_config = config.prefix_view("timeouts");
323 opts.timeouts = match HttpTimeoutOptions::from_config(&timeouts_config) {
324 Ok(timeouts) => timeouts,
325 Err(error) => return Err(Self::resolve_config_error(&timeouts_config, error)),
326 };
327 }
328
329 if config.contains_prefix("proxy") {
331 let proxy_config = config.prefix_view("proxy");
332 opts.proxy = match ProxyOptions::from_config(&proxy_config) {
333 Ok(proxy) => proxy,
334 Err(error) => return Err(Self::resolve_config_error(&proxy_config, error)),
335 };
336 }
337
338 if config.contains_prefix("logging") {
340 let logging_config = config.prefix_view("logging");
341 opts.logging = match HttpLoggingOptions::from_config(&logging_config) {
342 Ok(logging) => logging,
343 Err(error) => return Err(Self::resolve_config_error(&logging_config, error)),
344 };
345 }
346
347 if config.contains_prefix("retry") {
348 let retry_config = config.prefix_view("retry");
349 opts.retry = match HttpRetryOptions::from_config(&retry_config) {
350 Ok(retry) => retry,
351 Err(error) => return Err(Self::resolve_config_error(&retry_config, error)),
352 };
353 }
354
355 if config.contains_prefix("sse") {
356 let sse_config = config.prefix_view("sse");
357 let sse = match Self::read_sse_config(&sse_config) {
358 Ok(sse) => sse,
359 Err(error) => {
360 return Err(Self::resolve_config_error(
361 &sse_config,
362 HttpConfigError::from(error),
363 ))
364 }
365 };
366 if let Some(mode) = sse.json_mode.as_deref() {
367 opts.sse_json_mode = match Self::parse_sse_json_mode(mode) {
368 Ok(mode) => mode,
369 Err(error) => return Err(Self::resolve_config_error(&sse_config, error)),
370 };
371 }
372 if let Some(marker) = sse.done_marker.as_deref() {
373 opts.sse_done_marker_policy = match Self::parse_sse_done_marker_policy(marker) {
374 Ok(marker) => marker,
375 Err(error) => return Err(Self::resolve_config_error(&sse_config, error)),
376 };
377 }
378 if let Some(max_line_bytes) = sse.max_line_bytes {
379 opts.sse_max_line_bytes =
380 match Self::validate_positive_limit("max_line_bytes", max_line_bytes) {
381 Ok(limit) => limit,
382 Err(error) => return Err(Self::resolve_config_error(&sse_config, error)),
383 };
384 }
385 if let Some(max_frame_bytes) = sse.max_frame_bytes {
386 opts.sse_max_frame_bytes =
387 match Self::validate_positive_limit("max_frame_bytes", max_frame_bytes) {
388 Ok(limit) => limit,
389 Err(error) => return Err(Self::resolve_config_error(&sse_config, error)),
390 };
391 }
392 }
393
394 let headers_prefix = "default_headers";
396 let full_headers_prefix = "default_headers.";
397 let mut header_map: HashMap<String, String> = HashMap::new();
398 for (k, _) in config.iter_prefix(full_headers_prefix) {
399 let header_name = &k[full_headers_prefix.len()..];
400 let value = match config.get_string(k) {
401 Ok(value) => value,
402 Err(error) => {
403 return Err(HttpConfigError::config_error(
404 config.resolve_key(k),
405 error.to_string(),
406 ))
407 }
408 };
409 header_map.insert(header_name.to_string(), value);
410 }
411 let json_headers = match config.get_optional_string(headers_prefix) {
413 Ok(json_headers) => json_headers,
414 Err(error) => {
415 return Err(Self::resolve_config_error(
416 config,
417 HttpConfigError::from(error),
418 ))
419 }
420 };
421 if !header_map.is_empty() && json_headers.is_some() {
422 return Err(HttpConfigError::invalid_value(
423 config.resolve_key(headers_prefix),
424 "default_headers sub-key form and JSON map form cannot be used at the same time",
425 ));
426 }
427 if let Some(json_str) = json_headers {
428 let parsed: HashMap<String, String> = match serde_json::from_str(&json_str) {
429 Ok(parsed) => parsed,
430 Err(error) => {
431 return Err(HttpConfigError::type_error(
432 config.resolve_key(headers_prefix),
433 format!("Failed to parse default_headers JSON: {error}"),
434 ))
435 }
436 };
437 header_map = parsed;
438 }
439 if !header_map.is_empty() {
440 opts.default_headers =
441 hashmap_to_headermap(&config.resolve_key(headers_prefix), header_map)?;
442 }
443
444 if let Some(names) = root.sensitive_headers {
445 let mut sh = SensitiveHttpHeaders::new();
446 sh.extend(names);
447 opts.sensitive_headers = sh;
448 }
449
450 Ok(opts)
451 }
452
453 pub fn validate(&self) -> Result<(), HttpConfigError> {
459 self.timeouts
460 .validate()
461 .map_err(|e| e.prepend_path_prefix("timeouts"))?;
462 self.proxy.validate()?;
463 self.logging.validate()?;
464 self.retry
465 .validate()
466 .map_err(|e| e.prepend_path_prefix("retry"))?;
467 Self::validate_positive_limit(
468 "error_response_preview_limit",
469 self.error_response_preview_limit,
470 )?;
471 if let Some(user_agent) = self.user_agent.as_deref() {
472 if user_agent.trim().is_empty() {
473 return Err(HttpConfigError::invalid_value(
474 "user_agent",
475 "Value cannot be empty",
476 ));
477 }
478 HeaderValue::from_str(user_agent).map_err(|error| {
479 HttpConfigError::invalid_value(
480 "user_agent",
481 format!("Invalid header value: {error}"),
482 )
483 })?;
484 }
485 Self::validate_positive_limit("sse.max_line_bytes", self.sse_max_line_bytes)?;
486 Self::validate_positive_limit("sse.max_frame_bytes", self.sse_max_frame_bytes)?;
487 Ok(())
488 }
489}
490
491#[cfg(coverage)]
496#[doc(hidden)]
497pub(crate) fn coverage_exercise_http_client_option_paths() -> Vec<String> {
498 let config = qubit_config::Config::new();
499 let scoped_error = HttpClientOptions::resolve_config_error(
500 &config.prefix_view("coverage"),
501 HttpConfigError::invalid_value("", "coverage error"),
502 );
503 let root = HttpClientOptions::read_config(&config.prefix_view("coverage"))
504 .expect("empty root config should read");
505 let sse = HttpClientOptions::read_sse_config(&config.prefix_view("coverage.sse"))
506 .expect("empty SSE config should read");
507 let custom_marker = HttpClientOptions::parse_sse_done_marker_policy("coverage-done")
508 .expect("custom done marker should parse");
509 let mut options = HttpClientOptions {
510 user_agent: Some("bad\nagent".to_string()),
511 ..HttpClientOptions::default()
512 };
513 let invalid_user_agent = options
514 .validate()
515 .expect_err("invalid user agent should fail")
516 .message;
517 options.user_agent = Some("coverage-agent".to_string());
518 options
519 .add_headers(&[("x-coverage-a", "a"), ("x-coverage-b", "b")])
520 .expect("coverage headers should parse");
521
522 vec![
523 scoped_error.path,
524 root.base_url.is_none().to_string(),
525 sse.json_mode.is_none().to_string(),
526 format!("{custom_marker}"),
527 invalid_user_agent,
528 options.default_headers.len().to_string(),
529 ]
530}