Skip to main content

rskit_httpclient/
client.rs

1//! HTTP client implementation.
2
3use 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/// Async HTTP client with auth, headers, and error handling.
17///
18/// # Security (RS-ME-16 / #72)
19/// SECURITY(#72): TLS certificate verification must never be disabled in production.
20/// `danger_accept_invalid_certs` is only available behind the `danger-tls` feature flag
21/// and must not be enabled in release builds.
22#[derive(Clone)]
23pub struct HttpClient {
24    client: Client,
25    config: HttpClientConfig,
26}
27
28impl HttpClient {
29    /// Creates a new HTTP client with the given configuration.
30    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    /// Wraps an existing reqwest client with canonical configuration metadata.
54    #[must_use]
55    pub fn from_parts(config: HttpClientConfig, client: Client) -> Self {
56        Self { client, config }
57    }
58
59    /// Gets the configuration.
60    pub fn config(&self) -> &HttpClientConfig {
61        &self.config
62    }
63
64    /// Executes an HTTP request.
65    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    /// Executes a GET request and returns the response.
157    pub async fn get(&self, path: &str) -> AppResult<Response> {
158        self.send(Request::get(path)).await
159    }
160
161    /// Executes a request and converts non-2xx responses into an error.
162    pub async fn send_checked(&self, req: Request) -> AppResult<Response> {
163        self.send(req).await?.error_for_status()
164    }
165
166    /// Executes a GET request and parses the response as JSON.
167    pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> AppResult<T> {
168        self.get(path).await?.checked_json()
169    }
170
171    /// Executes a POST request with a JSON body.
172    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    /// Executes a POST request with a JSON body and parses the response as JSON.
178    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    /// Executes a PUT request with a JSON body.
187    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    /// Executes a PUT request with a JSON body and parses the response as JSON.
193    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    /// Executes a PATCH request with a JSON body.
202    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    /// Executes a PATCH request with a JSON body and parses the response as JSON.
208    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    /// Executes a DELETE request.
217    pub async fn delete(&self, path: &str) -> AppResult<Response> {
218        self.send(Request::delete(path)).await
219    }
220
221    /// Executes a HEAD request.
222    pub async fn head(&self, path: &str) -> AppResult<Response> {
223        self.send(Request::head(path)).await
224    }
225
226    /// Builds the full URL from a path.
227    fn build_url(&self, path: &str) -> AppResult<reqwest::Url> {
228        if let Some(base) = &self.config.base_url {
229            // Handle path to ensure correct joining
230            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}