1#[derive(Debug, Clone)]
8#[must_use]
9pub struct HttpResponse {
10 pub status: u16,
12 pub body: String,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
18#[non_exhaustive]
19pub enum HttpError {
20 #[error("request failed: {0}")]
22 Request(String),
23 #[error("decode error: {0}")]
25 Decode(String),
26}
27
28#[async_trait::async_trait]
30pub trait HttpClient: Send + Sync {
31 async fn get(&self, url: &str, headers: &[(&str, &str)]) -> Result<HttpResponse, HttpError>;
33 async fn get_bytes(&self, url: &str) -> Result<Vec<u8>, HttpError>;
35}
36
37impl HttpResponse {
38 #[must_use]
40 pub fn is_success(&self) -> bool {
41 (200..300).contains(&self.status)
42 }
43}
44
45pub struct ReqwestHttpClient {
47 inner: reqwest::Client,
48}
49
50impl ReqwestHttpClient {
51 #[must_use]
53 pub fn new() -> Self {
54 Self {
55 inner: reqwest::Client::new(),
56 }
57 }
58}
59
60impl Default for ReqwestHttpClient {
61 fn default() -> Self {
62 Self::new()
63 }
64}
65
66#[async_trait::async_trait]
67impl HttpClient for ReqwestHttpClient {
68 async fn get(&self, url: &str, headers: &[(&str, &str)]) -> Result<HttpResponse, HttpError> {
69 let mut req = self.inner.get(url);
70 for &(key, value) in headers {
71 req = req.header(key, value);
72 }
73
74 let response = req
75 .send()
76 .await
77 .map_err(|e| HttpError::Request(e.to_string()))?;
78
79 let status = response.status().as_u16();
80 let body = response
81 .text()
82 .await
83 .map_err(|e| HttpError::Decode(e.to_string()))?;
84
85 Ok(HttpResponse { status, body })
86 }
87
88 async fn get_bytes(&self, url: &str) -> Result<Vec<u8>, HttpError> {
89 let response = self
90 .inner
91 .get(url)
92 .send()
93 .await
94 .map_err(|e| HttpError::Request(e.to_string()))?;
95
96 if !response.status().is_success() {
97 return Err(HttpError::Request(format!(
98 "HTTP {}: {url}",
99 response.status()
100 )));
101 }
102
103 response
104 .bytes()
105 .await
106 .map(|b| b.to_vec())
107 .map_err(|e| HttpError::Decode(e.to_string()))
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn http_error_display() {
117 let e = HttpError::Request("connection refused".to_string());
118 assert!(e.to_string().contains("connection refused"));
119 }
120
121 #[test]
122 fn reqwest_default() {
123 let _client = ReqwestHttpClient::default();
124 }
125
126 #[test]
127 fn object_safe() {
128 fn assert_obj_safe(_: &dyn HttpClient) {}
129 assert_obj_safe(&ReqwestHttpClient::new());
130 }
131
132 pub(crate) struct MockHttpClient {
135 responses: std::collections::HashMap<String, HttpResponse>,
136 }
137
138 impl MockHttpClient {
139 pub fn new() -> Self { Self { responses: std::collections::HashMap::new() } }
140 pub fn with_response(mut self, url: &str, resp: HttpResponse) -> Self {
141 self.responses.insert(url.to_string(), resp);
142 self
143 }
144 }
145
146 #[async_trait::async_trait]
147 impl HttpClient for MockHttpClient {
148 async fn get(&self, url: &str, _h: &[(&str, &str)]) -> Result<HttpResponse, HttpError> {
149 self.responses.get(url).cloned().ok_or_else(|| HttpError::Request(format!("no mock: {url}")))
150 }
151 async fn get_bytes(&self, url: &str) -> Result<Vec<u8>, HttpError> {
152 Ok(self.get(url, &[]).await?.body.into_bytes())
153 }
154 }
155
156 #[tokio::test]
157 async fn mock_client_returns_canned() {
158 let client = MockHttpClient::new()
159 .with_response("http://test/foo", HttpResponse { status: 200, body: "hello".to_string() });
160 let resp = client.get("http://test/foo", &[]).await.unwrap();
161 assert_eq!(resp.status, 200);
162 assert_eq!(resp.body, "hello");
163 }
164
165 #[tokio::test]
166 async fn mock_client_missing_url() {
167 let client = MockHttpClient::new();
168 assert!(client.get("http://missing", &[]).await.is_err());
169 }
170
171 #[tokio::test]
172 async fn mock_client_get_bytes() {
173 let client = MockHttpClient::new().with_response(
174 "http://test/data",
175 HttpResponse {
176 status: 200,
177 body: "binary-ish content".to_string(),
178 },
179 );
180 let bytes = client.get_bytes("http://test/data").await.unwrap();
181 assert_eq!(bytes, b"binary-ish content");
182 }
183
184 #[tokio::test]
185 async fn mock_client_get_bytes_missing() {
186 let client = MockHttpClient::new();
187 assert!(client.get_bytes("http://missing").await.is_err());
188 }
189
190 #[test]
191 fn http_error_decode_display() {
192 let e = HttpError::Decode("invalid utf-8".to_string());
193 assert!(e.to_string().contains("invalid utf-8"));
194 }
195
196 #[test]
197 fn http_response_clone() {
198 let resp = HttpResponse {
199 status: 200,
200 body: "ok".to_string(),
201 };
202 let cloned = resp.clone();
203 assert_eq!(cloned.status, 200);
204 assert_eq!(cloned.body, "ok");
205 }
206
207 #[test]
208 fn http_response_debug() {
209 let resp = HttpResponse {
210 status: 404,
211 body: "not found".to_string(),
212 };
213 let debug = format!("{resp:?}");
214 assert!(debug.contains("404"));
215 assert!(debug.contains("not found"));
216 }
217
218 #[test]
219 fn http_error_request_debug() {
220 let e = HttpError::Request("timeout".to_string());
221 let debug = format!("{e:?}");
222 assert!(debug.contains("Request"));
223 assert!(debug.contains("timeout"));
224 }
225
226 #[tokio::test]
227 async fn mock_client_multiple_urls() {
228 let client = MockHttpClient::new()
229 .with_response(
230 "http://test/a",
231 HttpResponse { status: 200, body: "alpha".to_string() },
232 )
233 .with_response(
234 "http://test/b",
235 HttpResponse { status: 201, body: "beta".to_string() },
236 );
237 let a = client.get("http://test/a", &[]).await.unwrap();
238 let b = client.get("http://test/b", &[]).await.unwrap();
239 assert_eq!(a.body, "alpha");
240 assert_eq!(b.status, 201);
241 assert_eq!(b.body, "beta");
242 }
243
244 #[tokio::test]
245 async fn mock_client_status_codes() {
246 for status in [200, 301, 404, 500, 503] {
247 let client = MockHttpClient::new().with_response(
248 "http://test/status",
249 HttpResponse {
250 status,
251 body: String::new(),
252 },
253 );
254 let resp = client.get("http://test/status", &[]).await.unwrap();
255 assert_eq!(resp.status, status);
256 }
257 }
258
259 #[tokio::test]
260 async fn mock_client_empty_body() {
261 let client = MockHttpClient::new().with_response(
262 "http://test/empty",
263 HttpResponse {
264 status: 200,
265 body: String::new(),
266 },
267 );
268 let resp = client.get("http://test/empty", &[]).await.unwrap();
269 assert!(resp.body.is_empty());
270 }
271
272 #[tokio::test]
273 async fn mock_client_large_body() {
274 let large_body = "x".repeat(1_000_000);
275 let client = MockHttpClient::new().with_response(
276 "http://test/large",
277 HttpResponse {
278 status: 200,
279 body: large_body.clone(),
280 },
281 );
282 let resp = client.get("http://test/large", &[]).await.unwrap();
283 assert_eq!(resp.body.len(), 1_000_000);
284 }
285
286 #[tokio::test]
287 async fn mock_client_get_bytes_returns_utf8_bytes() {
288 let client = MockHttpClient::new().with_response(
289 "http://test/utf8",
290 HttpResponse {
291 status: 200,
292 body: "héllo wörld".to_string(),
293 },
294 );
295 let bytes = client.get_bytes("http://test/utf8").await.unwrap();
296 assert_eq!(String::from_utf8(bytes).unwrap(), "héllo wörld");
297 }
298
299 #[tokio::test]
300 async fn mock_client_overwrite_response() {
301 let client = MockHttpClient::new()
302 .with_response(
303 "http://test/x",
304 HttpResponse { status: 200, body: "first".to_string() },
305 )
306 .with_response(
307 "http://test/x",
308 HttpResponse { status: 201, body: "second".to_string() },
309 );
310 let resp = client.get("http://test/x", &[]).await.unwrap();
311 assert_eq!(resp.status, 201);
312 assert_eq!(resp.body, "second");
313 }
314
315 #[test]
318 fn is_success_200() {
319 let r = HttpResponse {
320 status: 200,
321 body: String::new(),
322 };
323 assert!(r.is_success());
324 }
325
326 #[test]
327 fn is_success_299_inclusive() {
328 let r = HttpResponse {
329 status: 299,
330 body: String::new(),
331 };
332 assert!(r.is_success());
333 }
334
335 #[test]
336 fn is_success_300_exclusive() {
337 let r = HttpResponse {
338 status: 300,
339 body: String::new(),
340 };
341 assert!(!r.is_success());
342 }
343
344 #[test]
345 fn is_success_199_below_range() {
346 let r = HttpResponse {
347 status: 199,
348 body: String::new(),
349 };
350 assert!(!r.is_success());
351 }
352
353 #[test]
354 fn is_success_404() {
355 let r = HttpResponse {
356 status: 404,
357 body: String::new(),
358 };
359 assert!(!r.is_success());
360 }
361
362 #[test]
363 fn is_success_500() {
364 let r = HttpResponse {
365 status: 500,
366 body: String::new(),
367 };
368 assert!(!r.is_success());
369 }
370
371 #[test]
372 fn is_success_201_created() {
373 let r = HttpResponse {
374 status: 201,
375 body: String::new(),
376 };
377 assert!(r.is_success());
378 }
379
380 #[test]
381 fn is_success_204_no_content() {
382 let r = HttpResponse {
383 status: 204,
384 body: String::new(),
385 };
386 assert!(r.is_success());
387 }
388
389 #[test]
390 fn is_success_zero_status() {
391 let r = HttpResponse {
392 status: 0,
393 body: String::new(),
394 };
395 assert!(!r.is_success());
396 }
397
398 #[test]
401 fn http_error_equality() {
402 let a = HttpError::Request("foo".to_string());
403 let b = HttpError::Request("foo".to_string());
404 assert_eq!(a, b);
405
406 let c = HttpError::Request("bar".to_string());
407 assert_ne!(a, c);
408
409 let d = HttpError::Decode("foo".to_string());
410 assert_ne!(a, d);
411 }
412
413 #[test]
414 fn http_error_clone() {
415 let a = HttpError::Request("network down".to_string());
416 let cloned = a.clone();
417 assert_eq!(a, cloned);
418 }
419
420 #[tokio::test]
423 async fn mock_client_ignores_request_headers() {
424 let client = MockHttpClient::new().with_response(
427 "http://test/x",
428 HttpResponse {
429 status: 200,
430 body: "ok".to_string(),
431 },
432 );
433 let r1 = client
434 .get("http://test/x", &[("Accept", "text/plain")])
435 .await
436 .unwrap();
437 let r2 = client
438 .get("http://test/x", &[("Accept", "application/json")])
439 .await
440 .unwrap();
441 let r3 = client.get("http://test/x", &[]).await.unwrap();
442 assert_eq!(r1.body, "ok");
443 assert_eq!(r2.body, "ok");
444 assert_eq!(r3.body, "ok");
445 }
446
447 #[tokio::test]
450 async fn mock_client_malformed_json_body() {
451 let client = MockHttpClient::new().with_response(
452 "http://test/api",
453 HttpResponse {
454 status: 200,
455 body: "{not valid json".to_string(),
456 },
457 );
458 let resp = client.get("http://test/api", &[]).await.unwrap();
459 assert_eq!(resp.body, "{not valid json");
462 assert!(serde_json::from_str::<serde_json::Value>(&resp.body).is_err());
463 }
464
465 #[tokio::test]
466 async fn mock_client_valid_json_body() {
467 let client = MockHttpClient::new().with_response(
468 "http://test/api",
469 HttpResponse {
470 status: 200,
471 body: r#"{"key":"value","n":42}"#.to_string(),
472 },
473 );
474 let resp = client.get("http://test/api", &[]).await.unwrap();
475 let parsed: serde_json::Value = serde_json::from_str(&resp.body).unwrap();
476 assert_eq!(parsed["key"], "value");
477 assert_eq!(parsed["n"], 42);
478 }
479
480 #[tokio::test]
483 async fn mock_client_get_bytes_with_pseudo_binary() {
484 let client = MockHttpClient::new().with_response(
487 "http://test/bin",
488 HttpResponse {
489 status: 200,
490 body: "\u{0001}\u{0002}\u{0003}".to_string(),
491 },
492 );
493 let bytes = client.get_bytes("http://test/bin").await.unwrap();
494 assert_eq!(bytes.len(), 3);
495 assert_eq!(bytes[0], 0x01);
496 assert_eq!(bytes[1], 0x02);
497 assert_eq!(bytes[2], 0x03);
498 }
499
500 #[test]
503 fn http_response_zero_status_construction() {
504 let r = HttpResponse {
505 status: 0,
506 body: String::new(),
507 };
508 assert_eq!(r.status, 0);
509 assert!(r.body.is_empty());
510 }
511
512 #[test]
515 fn reqwest_new_does_not_panic() {
516 let _client = ReqwestHttpClient::new();
517 }
518
519 #[test]
520 fn reqwest_default_equivalent_to_new() {
521 let _a = ReqwestHttpClient::new();
522 let _b = ReqwestHttpClient::default();
523 }
524
525 #[test]
526 fn reqwest_is_send_sync() {
527 fn assert_send_sync<T: Send + Sync>() {}
528 assert_send_sync::<ReqwestHttpClient>();
529 }
530
531 #[tokio::test]
534 async fn dyn_http_client_get_via_trait_object() {
535 let client: Box<dyn HttpClient> = Box::new(MockHttpClient::new().with_response(
536 "http://test/dyn",
537 HttpResponse {
538 status: 200,
539 body: "via dyn".to_string(),
540 },
541 ));
542 let resp = client.get("http://test/dyn", &[]).await.unwrap();
543 assert_eq!(resp.body, "via dyn");
544 }
545
546 #[tokio::test]
547 async fn dyn_http_client_get_bytes_via_trait_object() {
548 let client: Box<dyn HttpClient> = Box::new(MockHttpClient::new().with_response(
549 "http://test/dyn",
550 HttpResponse {
551 status: 200,
552 body: "bytes via dyn".to_string(),
553 },
554 ));
555 let bytes = client.get_bytes("http://test/dyn").await.unwrap();
556 assert_eq!(bytes, b"bytes via dyn");
557 }
558
559 #[test]
562 fn http_error_request_variant_message() {
563 let e = HttpError::Request("ENETUNREACH".to_string());
564 assert!(e.to_string().contains("request failed"));
565 assert!(e.to_string().contains("ENETUNREACH"));
566 }
567
568 #[test]
569 fn http_error_decode_variant_message() {
570 let e = HttpError::Decode("invalid utf-8 sequence".to_string());
571 assert!(e.to_string().contains("decode error"));
572 assert!(e.to_string().contains("invalid utf-8"));
573 }
574
575 #[test]
578 fn http_response_mutable_fields() {
579 let mut r = HttpResponse {
580 status: 200,
581 body: "old".to_string(),
582 };
583 r.status = 404;
584 r.body = "new".to_string();
585 assert_eq!(r.status, 404);
586 assert_eq!(r.body, "new");
587 }
588}