1use 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#[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 .with_cause(e)
49 })?;
50
51 Ok(Self { client, config })
52 }
53
54 #[must_use]
56 pub fn from_parts(config: HttpClientConfig, client: Client) -> Self {
57 Self { client, config }
58 }
59
60 pub fn config(&self) -> &HttpClientConfig {
62 &self.config
63 }
64
65 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 pub async fn get(&self, path: &str) -> AppResult<Response> {
159 self.send(Request::get(path)).await
160 }
161
162 pub async fn send_checked(&self, req: Request) -> AppResult<Response> {
164 self.send(req).await?.error_for_status()
165 }
166
167 pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> AppResult<T> {
169 self.get(path).await?.checked_json()
170 }
171
172 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 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 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 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 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 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 pub async fn delete(&self, path: &str) -> AppResult<Response> {
219 self.send(Request::delete(path)).await
220 }
221
222 pub async fn head(&self, path: &str) -> AppResult<Response> {
224 self.send(Request::head(path)).await
225 }
226
227 fn build_url(&self, path: &str) -> AppResult<reqwest::Url> {
229 if let Some(base) = &self.config.base_url {
230 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}