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