1use crate::config::HttpClientConfig;
4use crate::request::{Request, RequestBody};
5use crate::response::Response;
6use std::error::Error;
7use std::path::Path;
8
9use reqwest::Client;
10use rskit_errors::{AppError, AppResult, ErrorCode};
11use rskit_fs::sync_io::file;
12use rskit_security::{TlsConfig, TlsVersion};
13use serde::Serialize;
14use serde::de::DeserializeOwned;
15
16#[derive(Clone)]
22pub struct HttpClient {
23 client: Client,
24 config: HttpClientConfig,
25}
26
27impl HttpClient {
28 pub fn new(config: HttpClientConfig) -> AppResult<Self> {
30 let mut builder = Client::builder()
31 .timeout(config.timeout)
32 .connect_timeout(config.connect_timeout)
33 .redirect(redirect_policy(&config));
34
35 if let Some(ua) = &config.user_agent {
36 builder = builder.user_agent(ua.clone());
37 }
38 if let Some(tls) = &config.tls {
39 builder = apply_tls(builder, tls)?;
40 }
41
42 let client = builder.build().map_err(|e| {
43 AppError::new(
44 ErrorCode::Internal,
45 format!("failed to build http client: {}", e),
46 )
47 })?;
48
49 Ok(Self { client, config })
50 }
51
52 #[must_use]
54 pub fn from_parts(config: HttpClientConfig, client: Client) -> Self {
55 Self { client, config }
56 }
57
58 pub fn config(&self) -> &HttpClientConfig {
60 &self.config
61 }
62
63 pub async fn send(&self, req: Request) -> AppResult<Response> {
65 let mut response = self.execute_with_resilience(req).await?;
66
67 let status = response.status();
68 let headers = response
69 .headers()
70 .iter()
71 .map(|(k, v)| {
72 (
73 k.to_string(),
74 v.to_str().unwrap_or("<non-utf8>").to_string(),
75 )
76 })
77 .collect();
78
79 let body = read_response_body(&mut response, self.config.max_response_body_bytes).await?;
80
81 Ok(Response::new(status, headers, body))
82 }
83
84 async fn execute_with_resilience(&self, req: Request) -> AppResult<reqwest::Response> {
85 if let Some(policy) = &self.config.resilience_policy {
86 policy
87 .execute(|| async { self.execute_transport(req.clone()).await })
88 .await
89 } else {
90 self.execute_transport(req).await
91 }
92 }
93
94 async fn execute_transport(&self, req: Request) -> AppResult<reqwest::Response> {
95 self.build_request(&req)?
96 .send()
97 .await
98 .map_err(map_transport_error)
99 }
100
101 fn build_request(&self, req: &Request) -> AppResult<reqwest::RequestBuilder> {
102 let url = self.build_url(&req.path)?;
103 self.config.destination_policy.validate(&url)?;
104 let mut request = match req.method.as_str() {
105 "GET" => self.client.get(url),
106 "POST" => self.client.post(url),
107 "PUT" => self.client.put(url),
108 "PATCH" => self.client.patch(url),
109 "DELETE" => self.client.delete(url),
110 "HEAD" => self.client.head(url),
111 method => {
112 return Err(AppError::new(
113 ErrorCode::InvalidInput,
114 format!("unsupported http method: {}", method),
115 ));
116 }
117 };
118
119 for (name, value) in &self.config.default_headers {
120 let hn = parse_header_name(name)?;
121 let hv = parse_header_value(name, value)?;
122 request = request.header(hn, hv);
123 }
124
125 for (name, value) in &req.headers {
126 let hn = parse_header_name(name)?;
127 let hv = parse_header_value(name, value)?;
128 request = request.header(hn, hv);
129 }
130
131 if let Some(query) = &req.query {
132 request = request.query(query);
133 }
134
135 let auth = req.auth.as_ref().or(self.config.auth.as_ref());
136 if let Some(auth) = auth
137 && let Some((name, value)) = auth.header()?
138 {
139 let hn = parse_header_name(&name)?;
140 let hv = parse_header_value(&name, &value)?;
141 request = request.header(hn, hv);
142 }
143
144 if let Some(body) = &req.body {
145 request = match body {
146 RequestBody::Json(value) => request.json(value),
147 RequestBody::Text(text) => request.body(text.clone()),
148 RequestBody::Bytes(bytes) => request.body(bytes.clone()),
149 };
150 }
151
152 Ok(request)
153 }
154
155 pub async fn get(&self, path: &str) -> AppResult<Response> {
157 self.send(Request::get(path)).await
158 }
159
160 pub async fn send_checked(&self, req: Request) -> AppResult<Response> {
162 self.send(req).await?.error_for_status()
163 }
164
165 pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> AppResult<T> {
167 self.get(path).await?.checked_json()
168 }
169
170 pub async fn post<T: Serialize>(&self, path: &str, body: &T) -> AppResult<Response> {
172 let req = Request::post(path).json_body(body)?;
173 self.send(req).await
174 }
175
176 pub async fn post_json<T: Serialize, R: DeserializeOwned>(
178 &self,
179 path: &str,
180 body: &T,
181 ) -> AppResult<R> {
182 self.post(path, body).await?.checked_json()
183 }
184
185 pub async fn put<T: Serialize>(&self, path: &str, body: &T) -> AppResult<Response> {
187 let req = Request::put(path).json_body(body)?;
188 self.send(req).await
189 }
190
191 pub async fn put_json<T: Serialize, R: DeserializeOwned>(
193 &self,
194 path: &str,
195 body: &T,
196 ) -> AppResult<R> {
197 self.put(path, body).await?.checked_json()
198 }
199
200 pub async fn patch<T: Serialize>(&self, path: &str, body: &T) -> AppResult<Response> {
202 let req = Request::patch(path).json_body(body)?;
203 self.send(req).await
204 }
205
206 pub async fn patch_json<T: Serialize, R: DeserializeOwned>(
208 &self,
209 path: &str,
210 body: &T,
211 ) -> AppResult<R> {
212 self.patch(path, body).await?.checked_json()
213 }
214
215 pub async fn delete(&self, path: &str) -> AppResult<Response> {
217 self.send(Request::delete(path)).await
218 }
219
220 pub async fn head(&self, path: &str) -> AppResult<Response> {
222 self.send(Request::head(path)).await
223 }
224
225 fn build_url(&self, path: &str) -> AppResult<reqwest::Url> {
227 if let Some(base) = &self.config.base_url {
228 let base_ends_slash = base.ends_with('/');
230 let path_starts_slash = path.starts_with('/');
231
232 let url = match (base_ends_slash, path_starts_slash) {
233 (true, true) => format!("{}{}", base.trim_end_matches('/'), path),
234 (true, false) | (false, true) => format!("{}{}", base, path),
235 (false, false) => format!("{}/{}", base, path),
236 };
237
238 url.parse::<reqwest::Url>()
239 .map_err(|e| AppError::new(ErrorCode::InvalidInput, format!("invalid url: {}", e)))
240 } else {
241 path.parse::<reqwest::Url>()
242 .map_err(|e| AppError::new(ErrorCode::InvalidInput, format!("invalid url: {}", e)))
243 }
244 }
245}
246
247impl std::fmt::Debug for HttpClient {
248 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249 f.debug_struct("HttpClient")
250 .field("config", &self.config)
251 .finish()
252 }
253}
254
255fn redirect_policy(config: &HttpClientConfig) -> reqwest::redirect::Policy {
256 if !config.follow_redirects {
257 return reqwest::redirect::Policy::none();
258 }
259
260 let max_redirects = config.max_redirects;
261 let destination_policy = config.destination_policy.clone();
262 reqwest::redirect::Policy::custom(move |attempt| {
263 if attempt.previous().len() > max_redirects {
264 return attempt.error(AppError::invalid_input(
265 "max_redirects",
266 format!("too many HTTP redirects (max {max_redirects})"),
267 ));
268 }
269 if let Err(error) = destination_policy.validate(attempt.url()) {
270 return attempt.error(error);
271 }
272 attempt.follow()
273 })
274}
275
276async fn read_response_body(
277 response: &mut reqwest::Response,
278 max_bytes: usize,
279) -> AppResult<bytes::Bytes> {
280 if response
281 .content_length()
282 .is_some_and(|length| length > max_bytes as u64)
283 {
284 return Err(response_body_too_large(max_bytes));
285 }
286
287 let mut total = 0usize;
288 let mut body = bytes::BytesMut::new();
289 while let Some(chunk) = response.chunk().await.map_err(|error| {
290 AppError::new(
291 ErrorCode::ExternalService,
292 format!("failed to read response body: {error}"),
293 )
294 .with_cause(error)
295 })? {
296 total = total
297 .checked_add(chunk.len())
298 .ok_or_else(|| response_body_too_large(max_bytes))?;
299 if total > max_bytes {
300 return Err(response_body_too_large(max_bytes));
301 }
302 body.extend_from_slice(&chunk);
303 }
304 Ok(body.freeze())
305}
306
307fn response_body_too_large(max_bytes: usize) -> AppError {
308 AppError::invalid_input(
309 "max_response_body_bytes",
310 format!("HTTP response body exceeds configured limit of {max_bytes} bytes"),
311 )
312}
313
314fn map_transport_error(error: reqwest::Error) -> AppError {
315 if let Some(policy_error) = error
316 .source()
317 .and_then(|source| source.downcast_ref::<AppError>())
318 {
319 return AppError::new(policy_error.code(), policy_error.message()).with_cause(error);
320 }
321
322 let code = if error.is_timeout() {
323 ErrorCode::Timeout
324 } else if error.is_connect() {
325 ErrorCode::ConnectionFailed
326 } else {
327 ErrorCode::ExternalService
328 };
329 AppError::new(code, format!("http request failed: {}", error))
330}
331
332fn apply_tls(
333 mut builder: reqwest::ClientBuilder,
334 tls: &TlsConfig,
335) -> AppResult<reqwest::ClientBuilder> {
336 tls.validate()?;
337 if tls.server_name.is_some() {
338 return Err(AppError::invalid_input(
339 "tls.server_name",
340 "HTTP client TLS server_name overrides are not supported by reqwest; omit the override so certificate verification uses the URL host",
341 ));
342 }
343
344 builder = match tls.min_version {
345 TlsVersion::Tls12 => builder.min_tls_version(reqwest::tls::Version::TLS_1_2),
346 TlsVersion::Tls13 => builder.min_tls_version(reqwest::tls::Version::TLS_1_3),
347 _ => builder.min_tls_version(reqwest::tls::Version::TLS_1_3),
348 };
349
350 builder = apply_skip_verify(builder, tls.skip_verify)?;
351
352 if let Some(ca_file) = &tls.ca_file {
353 let pem = file::read(Path::new(ca_file)).map_err(|error| {
354 AppError::new(
355 ErrorCode::InvalidInput,
356 format!("failed to read HTTP CA bundle '{ca_file}': {error}"),
357 )
358 .with_cause(error)
359 })?;
360 let cert = reqwest::Certificate::from_pem(&pem).map_err(|error| {
361 AppError::new(
362 ErrorCode::InvalidInput,
363 format!("invalid HTTP CA bundle '{ca_file}': {error}"),
364 )
365 .with_cause(error)
366 })?;
367 builder = builder.add_root_certificate(cert);
368 }
369
370 if let (Some(cert_file), Some(key_file)) = (&tls.cert_file, &tls.key_file) {
371 let mut pem = file::read(Path::new(cert_file)).map_err(|error| {
372 AppError::new(
373 ErrorCode::InvalidInput,
374 format!("failed to read HTTP client certificate '{cert_file}': {error}"),
375 )
376 .with_cause(error)
377 })?;
378 let mut key = file::read(Path::new(key_file)).map_err(|error| {
379 AppError::new(
380 ErrorCode::InvalidInput,
381 format!("failed to read HTTP client key '{key_file}': {error}"),
382 )
383 .with_cause(error)
384 })?;
385 pem.append(&mut key);
386 let identity = reqwest::Identity::from_pem(&pem).map_err(|error| {
387 AppError::new(
388 ErrorCode::InvalidInput,
389 format!("invalid HTTP client identity '{cert_file}'/'{key_file}': {error}"),
390 )
391 .with_cause(error)
392 })?;
393 builder = builder.identity(identity);
394 }
395
396 Ok(builder)
397}
398
399fn apply_skip_verify(
400 builder: reqwest::ClientBuilder,
401 skip_verify: bool,
402) -> AppResult<reqwest::ClientBuilder> {
403 if !skip_verify {
404 return Ok(builder);
405 }
406
407 #[cfg(all(feature = "danger-tls", debug_assertions))]
408 {
409 tracing::warn!("HTTP client TLS certificate verification disabled by explicit config");
410 Ok(builder.danger_accept_invalid_certs(true))
411 }
412
413 #[cfg(not(all(feature = "danger-tls", debug_assertions)))]
414 {
415 Err(AppError::invalid_input(
416 "tls.skip_verify",
417 "HTTP client TLS certificate verification can only be disabled in debug builds with the danger-tls feature",
418 ))
419 }
420}
421
422fn parse_header_name(name: &str) -> AppResult<reqwest::header::HeaderName> {
423 name.parse::<reqwest::header::HeaderName>()
424 .map_err(|error| {
425 AppError::new(
426 ErrorCode::InvalidInput,
427 format!("invalid HTTP header name '{name}': {error}"),
428 )
429 })
430}
431
432fn parse_header_value(name: &str, value: &str) -> AppResult<reqwest::header::HeaderValue> {
433 value
434 .parse::<reqwest::header::HeaderValue>()
435 .map_err(|error| {
436 AppError::new(
437 ErrorCode::InvalidInput,
438 format!("invalid HTTP header value for '{name}': {error}"),
439 )
440 })
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446
447 #[test]
448 fn test_url_building() {
449 let config = HttpClientConfig::new().with_base_url("https://api.example.com/v1");
450 let client = HttpClient::new(config).unwrap();
451
452 let url = client.build_url("/users").unwrap();
453 assert_eq!(url.as_str(), "https://api.example.com/v1/users");
454
455 let url = client.build_url("users").unwrap();
456 assert_eq!(url.as_str(), "https://api.example.com/v1/users");
457 }
458
459 #[test]
460 fn test_url_building_without_base() {
461 let config = HttpClientConfig::new();
462 let client = HttpClient::new(config).unwrap();
463
464 let url = client.build_url("https://example.com/users").unwrap();
465 assert_eq!(url.as_str(), "https://example.com/users");
466 }
467
468 #[test]
469 fn test_client_creation() {
470 let config = HttpClientConfig::new()
471 .with_base_url("https://api.example.com")
472 .with_user_agent("test-client/1.0");
473
474 let client = HttpClient::new(config).unwrap();
475 assert!(client.config.base_url.is_some());
476 assert_eq!(
477 client.config.user_agent,
478 Some("test-client/1.0".to_string())
479 );
480 }
481
482 #[test]
483 fn from_parts_preserves_config_and_debug_uses_redacted_config() {
484 let config = HttpClientConfig::new()
485 .with_base_url("https://api.example.com")
486 .with_auth(crate::Auth::bearer("secret-token"));
487 let client = HttpClient::from_parts(config, reqwest::Client::new());
488
489 assert_eq!(
490 client.config().base_url.as_deref(),
491 Some("https://api.example.com")
492 );
493 let debug = format!("{client:?}");
494 assert!(debug.contains("HttpClient"));
495 assert!(debug.contains("SecretString(***)"));
496 assert!(!debug.contains("secret-token"));
497 }
498
499 #[test]
500 fn base_url_joining_handles_all_slash_combinations() {
501 let cases = [
502 (
503 "https://api.example.com/v1/",
504 "/users",
505 "https://api.example.com/v1/users",
506 ),
507 (
508 "https://api.example.com/v1/",
509 "users",
510 "https://api.example.com/v1/users",
511 ),
512 (
513 "https://api.example.com/v1",
514 "/users",
515 "https://api.example.com/v1/users",
516 ),
517 (
518 "https://api.example.com/v1",
519 "users",
520 "https://api.example.com/v1/users",
521 ),
522 ];
523
524 for (base, path, expected) in cases {
525 let client = HttpClient::new(HttpClientConfig::new().with_base_url(base)).unwrap();
526
527 assert_eq!(client.build_url(path).unwrap().as_str(), expected);
528 }
529 }
530
531 #[test]
532 fn tls_server_name_override_is_rejected() {
533 let config = HttpClientConfig::new().with_tls(TlsConfig {
534 server_name: Some("api.internal".to_string()),
535 ..Default::default()
536 });
537
538 assert!(HttpClient::new(config).is_err());
539 }
540
541 #[test]
542 fn tls_skip_verify_is_release_guarded() {
543 let config = HttpClientConfig::new().with_tls(TlsConfig {
544 skip_verify: true,
545 ..Default::default()
546 });
547
548 let result = HttpClient::new(config);
549 if cfg!(all(feature = "danger-tls", debug_assertions)) {
550 assert!(result.is_ok());
551 } else {
552 assert!(result.is_err());
553 }
554 }
555
556 #[test]
557 fn tls13_minimum_is_accepted() {
558 let config = HttpClientConfig::new().with_tls(TlsConfig {
559 min_version: TlsVersion::Tls13,
560 ..Default::default()
561 });
562
563 assert!(HttpClient::new(config).is_ok());
564 }
565
566 #[test]
567 fn missing_ca_bundle_is_rejected() {
568 let temp_dir = tempfile::tempdir().unwrap();
569 let ca = temp_dir.path().join("missing-ca.pem");
570 let config = HttpClientConfig::new().with_tls(TlsConfig {
571 ca_file: Some(ca.display().to_string()),
572 ..Default::default()
573 });
574
575 let error = HttpClient::new(config).expect_err("missing CA bundle");
576
577 assert_eq!(error.code(), ErrorCode::InvalidInput);
578 assert!(error.message().contains("failed to read HTTP CA bundle"));
579 }
580
581 #[test]
582 fn missing_client_identity_files_are_rejected() {
583 let temp_dir = tempfile::tempdir().unwrap();
584 let cert = temp_dir.path().join("client.pem");
585 let key = temp_dir.path().join("client.key");
586 let config = HttpClientConfig::new().with_tls(TlsConfig {
587 cert_file: Some(cert.display().to_string()),
588 key_file: Some(key.display().to_string()),
589 ..Default::default()
590 });
591
592 let error = HttpClient::new(config).expect_err("missing identity files");
593
594 assert_eq!(error.code(), ErrorCode::InvalidInput);
595 assert!(
596 error
597 .message()
598 .contains("failed to read HTTP client certificate")
599 );
600 }
601
602 #[test]
603 fn invalid_client_identity_is_rejected() {
604 let cert = tempfile::NamedTempFile::new().unwrap();
605 let key = tempfile::NamedTempFile::new().unwrap();
606 std::fs::write(cert.path(), b"not a cert").unwrap();
607 std::fs::write(key.path(), b"not a key").unwrap();
608 let config = HttpClientConfig::new().with_tls(TlsConfig {
609 cert_file: Some(cert.path().display().to_string()),
610 key_file: Some(key.path().display().to_string()),
611 ..Default::default()
612 });
613
614 let error = HttpClient::new(config).expect_err("invalid identity");
615
616 assert_eq!(error.code(), ErrorCode::InvalidInput);
617 assert!(error.message().contains("invalid HTTP client identity"));
618 }
619
620 #[test]
621 fn destination_policy_rejects_initial_url() {
622 let config = HttpClientConfig::new();
623 let client = HttpClient::new(config).unwrap();
624
625 let result = client.build_request(&Request::get("http://169.254.169.254/latest"));
626
627 assert!(result.is_err());
628 }
629}