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