Skip to main content

sui_store/
http.rs

1//! HTTP client abstraction for binary cache access.
2//!
3//! Defines the [`HttpClient`] trait so `BinaryCacheStore` can be tested
4//! without making real network requests.
5
6/// HTTP response returned by [`HttpClient`] methods.
7#[derive(Debug, Clone)]
8#[must_use]
9pub struct HttpResponse {
10    /// HTTP status code (e.g., 200, 404, 500).
11    pub status: u16,
12    /// Response body decoded as UTF-8 text.
13    pub body: String,
14}
15
16/// HTTP client errors.
17#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
18#[non_exhaustive]
19pub enum HttpError {
20    /// The HTTP request could not be sent (network error, DNS failure, etc.).
21    #[error("request failed: {0}")]
22    Request(String),
23    /// The response body could not be decoded.
24    #[error("decode error: {0}")]
25    Decode(String),
26}
27
28/// Async HTTP client trait — abstracts over reqwest for testability.
29#[async_trait::async_trait]
30pub trait HttpClient: Send + Sync {
31    /// Send a GET request with custom headers and return the text response.
32    async fn get(&self, url: &str, headers: &[(&str, &str)]) -> Result<HttpResponse, HttpError>;
33    /// Send a GET request and return the raw response bytes.
34    async fn get_bytes(&self, url: &str) -> Result<Vec<u8>, HttpError>;
35}
36
37impl HttpResponse {
38    /// Returns `true` if the status code is in the 2xx range.
39    #[must_use]
40    pub fn is_success(&self) -> bool {
41        (200..300).contains(&self.status)
42    }
43}
44
45/// Default [`HttpClient`] backed by reqwest.
46pub struct ReqwestHttpClient {
47    inner: reqwest::Client,
48}
49
50impl ReqwestHttpClient {
51    /// Create a new HTTP client with default settings.
52    #[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    // ── MockHttpClient ────────────────────────────────────
133
134    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    // ── HttpResponse::is_success boundary checks ─────────────
316
317    #[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    // ── HttpError equality ──────────────────────────────────
399
400    #[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    // ── HttpResponse with different headers ─────────────────
421
422    #[tokio::test]
423    async fn mock_client_ignores_request_headers() {
424        // MockHttpClient doesn't differentiate by headers — verify that
425        // requests with different headers all hit the same mocked URL.
426        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    // ── MockHttpClient: malformed JSON body ─────────────────
448
449    #[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        // The mock client doesn't validate JSON — it returns the body as-is.
460        // Consumers must validate.
461        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    // ── MockHttpClient: get_bytes for binary-ish content ────
481
482    #[tokio::test]
483    async fn mock_client_get_bytes_with_pseudo_binary() {
484        // MockHttpClient stores the body as String, so true binary
485        // (non-UTF8) data isn't representable. Use UTF-8 escape sequences.
486        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    // ── HttpResponse Default-ish patterns ───────────────────
501
502    #[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    // ── ReqwestHttpClient construction methods ──────────────
513
514    #[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    // ── Trait object dispatch ───────────────────────────────
532
533    #[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    // ── HttpError variant coverage ──────────────────────────
560
561    #[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    // ── HttpResponse field mutation ─────────────────────────
576
577    #[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}