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 crate::tls::apply_tls;
7use crate::transport::{
8    map_transport_error, parse_header_name, parse_header_value, read_response_body, redirect_policy,
9};
10
11use reqwest::Client;
12use rskit_errors::{AppError, AppResult, ErrorCode};
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.
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            .with_cause(e)
49        })?;
50
51        Ok(Self { client, config })
52    }
53
54    /// Wraps an existing reqwest client with canonical configuration metadata.
55    #[must_use]
56    pub fn from_parts(config: HttpClientConfig, client: Client) -> Self {
57        Self { client, config }
58    }
59
60    /// Gets the configuration.
61    pub fn config(&self) -> &HttpClientConfig {
62        &self.config
63    }
64
65    /// Executes an HTTP request.
66    pub async fn send(&self, req: Request) -> AppResult<Response> {
67        let mut response = self.execute_with_resilience(req).await?;
68
69        let status = response.status();
70        let headers = response
71            .headers()
72            .iter()
73            .map(|(k, v)| {
74                (
75                    k.to_string(),
76                    v.to_str().unwrap_or("<non-utf8>").to_string(),
77                )
78            })
79            .collect();
80
81        let body = read_response_body(&mut response, self.config.max_response_body_bytes).await?;
82
83        Ok(Response::new(status, headers, body))
84    }
85
86    async fn execute_with_resilience(&self, req: Request) -> AppResult<reqwest::Response> {
87        if let Some(policy) = &self.config.resilience_policy {
88            policy
89                .execute(|| async { self.execute_transport(req.clone()).await })
90                .await
91        } else {
92            self.execute_transport(req).await
93        }
94    }
95
96    async fn execute_transport(&self, req: Request) -> AppResult<reqwest::Response> {
97        self.build_request(&req)?
98            .send()
99            .await
100            .map_err(map_transport_error)
101    }
102
103    fn build_request(&self, req: &Request) -> AppResult<reqwest::RequestBuilder> {
104        let url = self.build_url(&req.path)?;
105        self.config.destination_policy.validate(&url)?;
106        let mut request = match req.method.as_str() {
107            "GET" => self.client.get(url),
108            "POST" => self.client.post(url),
109            "PUT" => self.client.put(url),
110            "PATCH" => self.client.patch(url),
111            "DELETE" => self.client.delete(url),
112            "HEAD" => self.client.head(url),
113            method => {
114                return Err(AppError::new(
115                    ErrorCode::InvalidInput,
116                    format!("unsupported http method: {}", method),
117                ));
118            }
119        };
120
121        for (name, value) in &self.config.default_headers {
122            let hn = parse_header_name(name)?;
123            let hv = parse_header_value(name, value)?;
124            request = request.header(hn, hv);
125        }
126
127        for (name, value) in &req.headers {
128            let hn = parse_header_name(name)?;
129            let hv = parse_header_value(name, value)?;
130            request = request.header(hn, hv);
131        }
132
133        if let Some(query) = &req.query {
134            request = request.query(query);
135        }
136
137        let auth = req.auth.as_ref().or(self.config.auth.as_ref());
138        if let Some(auth) = auth
139            && let Some((name, value)) = auth.header()?
140        {
141            let hn = parse_header_name(&name)?;
142            let hv = parse_header_value(&name, &value)?;
143            request = request.header(hn, hv);
144        }
145
146        if let Some(body) = &req.body {
147            request = match body {
148                RequestBody::Json(value) => request.json(value),
149                RequestBody::Text(text) => request.body(text.clone()),
150                RequestBody::Bytes(bytes) => request.body(bytes.clone()),
151            };
152        }
153
154        Ok(request)
155    }
156
157    /// Executes a GET request and returns the response.
158    pub async fn get(&self, path: &str) -> AppResult<Response> {
159        self.send(Request::get(path)).await
160    }
161
162    /// Executes a request and converts non-2xx responses into an error.
163    pub async fn send_checked(&self, req: Request) -> AppResult<Response> {
164        self.send(req).await?.error_for_status()
165    }
166
167    /// Executes a GET request and parses the response as JSON.
168    pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> AppResult<T> {
169        self.get(path).await?.checked_json()
170    }
171
172    /// Executes a POST request with a JSON body.
173    pub async fn post<T: Serialize>(&self, path: &str, body: &T) -> AppResult<Response> {
174        let req = Request::post(path).json_body(body)?;
175        self.send(req).await
176    }
177
178    /// Executes a POST request with a JSON body and parses the response as JSON.
179    pub async fn post_json<T: Serialize, R: DeserializeOwned>(
180        &self,
181        path: &str,
182        body: &T,
183    ) -> AppResult<R> {
184        self.post(path, body).await?.checked_json()
185    }
186
187    /// Executes a PUT request with a JSON body.
188    pub async fn put<T: Serialize>(&self, path: &str, body: &T) -> AppResult<Response> {
189        let req = Request::put(path).json_body(body)?;
190        self.send(req).await
191    }
192
193    /// Executes a PUT request with a JSON body and parses the response as JSON.
194    pub async fn put_json<T: Serialize, R: DeserializeOwned>(
195        &self,
196        path: &str,
197        body: &T,
198    ) -> AppResult<R> {
199        self.put(path, body).await?.checked_json()
200    }
201
202    /// Executes a PATCH request with a JSON body.
203    pub async fn patch<T: Serialize>(&self, path: &str, body: &T) -> AppResult<Response> {
204        let req = Request::patch(path).json_body(body)?;
205        self.send(req).await
206    }
207
208    /// Executes a PATCH request with a JSON body and parses the response as JSON.
209    pub async fn patch_json<T: Serialize, R: DeserializeOwned>(
210        &self,
211        path: &str,
212        body: &T,
213    ) -> AppResult<R> {
214        self.patch(path, body).await?.checked_json()
215    }
216
217    /// Executes a DELETE request.
218    pub async fn delete(&self, path: &str) -> AppResult<Response> {
219        self.send(Request::delete(path)).await
220    }
221
222    /// Executes a HEAD request.
223    pub async fn head(&self, path: &str) -> AppResult<Response> {
224        self.send(Request::head(path)).await
225    }
226
227    /// Builds the full URL from a path.
228    fn build_url(&self, path: &str) -> AppResult<reqwest::Url> {
229        if let Some(base) = &self.config.base_url {
230            // Handle path to ensure correct joining
231            let base_ends_slash = base.ends_with('/');
232            let path_starts_slash = path.starts_with('/');
233
234            let url = match (base_ends_slash, path_starts_slash) {
235                (true, true) => format!("{}{}", base.trim_end_matches('/'), path),
236                (true, false) | (false, true) => format!("{}{}", base, path),
237                (false, false) => format!("{}/{}", base, path),
238            };
239
240            url.parse::<reqwest::Url>().map_err(|e| {
241                AppError::new(ErrorCode::InvalidInput, format!("invalid url: {e}")).with_cause(e)
242            })
243        } else {
244            path.parse::<reqwest::Url>().map_err(|e| {
245                AppError::new(ErrorCode::InvalidInput, format!("invalid url: {e}")).with_cause(e)
246            })
247        }
248    }
249}
250
251impl std::fmt::Debug for HttpClient {
252    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253        f.debug_struct("HttpClient")
254            .field("config", &self.config)
255            .finish()
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn test_url_building() {
265        let config = HttpClientConfig::new().with_base_url("https://api.example.com/v1");
266        let client = HttpClient::new(config).unwrap();
267
268        let url = client.build_url("/users").unwrap();
269        assert_eq!(url.as_str(), "https://api.example.com/v1/users");
270
271        let url = client.build_url("users").unwrap();
272        assert_eq!(url.as_str(), "https://api.example.com/v1/users");
273    }
274
275    #[test]
276    fn test_url_building_without_base() {
277        let config = HttpClientConfig::new();
278        let client = HttpClient::new(config).unwrap();
279
280        let url = client.build_url("https://example.com/users").unwrap();
281        assert_eq!(url.as_str(), "https://example.com/users");
282    }
283
284    #[test]
285    fn test_client_creation() {
286        let config = HttpClientConfig::new()
287            .with_base_url("https://api.example.com")
288            .with_user_agent("test-client/1.0");
289
290        let client = HttpClient::new(config).unwrap();
291        assert!(client.config.base_url.is_some());
292        assert_eq!(
293            client.config.user_agent,
294            Some("test-client/1.0".to_string())
295        );
296    }
297
298    #[test]
299    fn from_parts_preserves_config_and_debug_uses_redacted_config() {
300        let config = HttpClientConfig::new()
301            .with_base_url("https://api.example.com")
302            .with_auth(crate::Auth::bearer("secret-token"));
303        let client = HttpClient::from_parts(config, reqwest::Client::new());
304
305        assert_eq!(
306            client.config().base_url.as_deref(),
307            Some("https://api.example.com")
308        );
309        let debug = format!("{client:?}");
310        assert!(debug.contains("HttpClient"));
311        assert!(debug.contains("SecretString(***)"));
312        assert!(!debug.contains("secret-token"));
313    }
314
315    #[test]
316    fn base_url_joining_handles_all_slash_combinations() {
317        let cases = [
318            (
319                "https://api.example.com/v1/",
320                "/users",
321                "https://api.example.com/v1/users",
322            ),
323            (
324                "https://api.example.com/v1/",
325                "users",
326                "https://api.example.com/v1/users",
327            ),
328            (
329                "https://api.example.com/v1",
330                "/users",
331                "https://api.example.com/v1/users",
332            ),
333            (
334                "https://api.example.com/v1",
335                "users",
336                "https://api.example.com/v1/users",
337            ),
338        ];
339
340        for (base, path, expected) in cases {
341            let client = HttpClient::new(HttpClientConfig::new().with_base_url(base)).unwrap();
342
343            assert_eq!(client.build_url(path).unwrap().as_str(), expected);
344        }
345    }
346
347    #[test]
348    fn destination_policy_rejects_initial_url() {
349        let config = HttpClientConfig::new();
350        let client = HttpClient::new(config).unwrap();
351
352        let result = client.build_request(&Request::get("http://169.254.169.254/latest"));
353
354        assert!(result.is_err());
355    }
356}