Skip to main content

scope/http/
native.rs

1//! Native HTTP client using `reqwest` directly.
2//!
3//! This is the default transport when the Ghola sidecar is not configured
4//! or unavailable. All requests go directly to the target endpoint.
5
6use super::{HttpClient, Request, Response};
7use crate::error::ScopeError;
8use async_trait::async_trait;
9use std::time::Duration;
10
11/// Standard `reqwest`-based HTTP client used when the Ghola sidecar is
12/// disabled or unavailable.
13pub struct NativeHttpClient {
14    client: reqwest::Client,
15}
16
17impl NativeHttpClient {
18    /// Creates a new native HTTP client with a 30-second timeout.
19    pub fn new() -> Result<Self, ScopeError> {
20        let client = reqwest::Client::builder()
21            .timeout(Duration::from_secs(30))
22            .build()
23            .map_err(|e| ScopeError::Network(format!("failed to build HTTP client: {e}")))?;
24        Ok(Self { client })
25    }
26}
27
28/// Set to let a test build reach live hosts. Test builds otherwise send
29/// only to loopback (#39).
30pub const TEST_NETWORK_ENV: &str = "SCOPE_TEST_NETWORK";
31
32/// In a test build, a request to any host but loopback fails at once, so a
33/// test gives the same result, and the same coverage, with the network on
34/// or off (#39). Local mock servers (mockito) still answer.
35/// [`TEST_NETWORK_ENV`] allows live hosts for a manual run.
36#[cfg(any(test, feature = "test-support"))]
37fn refuse_live_host_in_tests(url: &str) -> Result<(), ScopeError> {
38    live_host_check(url, std::env::var_os(TEST_NETWORK_ENV).is_some())
39}
40
41/// The guard's decision: `url` may be sent when it names loopback or when
42/// the network is `allowed`.
43#[cfg(any(test, feature = "test-support"))]
44fn live_host_check(url: &str, allowed: bool) -> Result<(), ScopeError> {
45    let host = reqwest::Url::parse(url)
46        .ok()
47        .and_then(|u| u.host_str().map(str::to_owned));
48    let loopback = matches!(host.as_deref(), Some("localhost" | "127.0.0.1" | "[::1]"));
49    if loopback || allowed {
50        return Ok(());
51    }
52    Err(ScopeError::Network(format!(
53        "live network is off in tests: {url} (set {TEST_NETWORK_ENV}=1 to allow it)"
54    )))
55}
56
57#[cfg(not(any(test, feature = "test-support")))]
58fn refuse_live_host_in_tests(_url: &str) -> Result<(), ScopeError> {
59    Ok(())
60}
61
62#[async_trait]
63impl HttpClient for NativeHttpClient {
64    async fn send(&self, request: Request) -> Result<Response, ScopeError> {
65        refuse_live_host_in_tests(&request.url)?;
66        let method: reqwest::Method = request
67            .method
68            .parse()
69            .map_err(|e| ScopeError::Network(format!("invalid HTTP method: {e}")))?;
70
71        let mut builder = self.client.request(method, &request.url);
72
73        for (k, v) in &request.headers {
74            builder = builder.header(k.as_str(), v.as_str());
75        }
76
77        if let Some(t) = request.timeout {
78            builder = builder.timeout(t);
79        }
80
81        if let Some(body) = request.body {
82            builder = builder.body(body);
83        }
84
85        let resp = builder.send().await?;
86        let status = resp.status().as_u16();
87
88        let mut headers = std::collections::HashMap::new();
89        for (k, v) in resp.headers() {
90            headers.insert(k.to_string(), v.to_str().unwrap_or("").to_string());
91        }
92
93        let body = resp.text().await?;
94
95        Ok(Response {
96            status_code: status,
97            headers,
98            body,
99        })
100    }
101}
102
103// ============================================================================
104// Unit Tests
105// ============================================================================
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn test_native_client_creation() {
113        let client = NativeHttpClient::new();
114        assert!(client.is_ok());
115    }
116
117    #[tokio::test]
118    async fn test_send_get_request() {
119        let mut server = mockito::Server::new_async().await;
120        let mock = server
121            .mock("GET", "/test")
122            .with_status(200)
123            .with_header("x-test", "hello")
124            .with_body(r#"{"ok":true}"#)
125            .create_async()
126            .await;
127
128        let client = NativeHttpClient::new().unwrap();
129        let req = Request::get(&format!("{}/test", server.url()));
130        let resp = client.send(req).await.unwrap();
131
132        assert_eq!(resp.status_code, 200);
133        assert!(resp.is_success());
134        assert!(resp.body.contains("\"ok\":true"));
135        mock.assert_async().await;
136    }
137
138    #[tokio::test]
139    async fn test_send_post_json_request() {
140        let mut server = mockito::Server::new_async().await;
141        let mock = server
142            .mock("POST", "/api")
143            .match_header("content-type", "application/json")
144            .match_body(r#"{"key":"val"}"#)
145            .with_status(201)
146            .with_body(r#"{"created":true}"#)
147            .create_async()
148            .await;
149
150        let client = NativeHttpClient::new().unwrap();
151        let req = Request::post_json(&format!("{}/api", server.url()), r#"{"key":"val"}"#);
152        let resp = client.send(req).await.unwrap();
153
154        assert_eq!(resp.status_code, 201);
155        assert!(resp.is_success());
156        let parsed: serde_json::Value = resp.json().unwrap();
157        assert_eq!(parsed["created"], true);
158        mock.assert_async().await;
159    }
160
161    #[tokio::test]
162    async fn test_send_with_custom_headers() {
163        let mut server = mockito::Server::new_async().await;
164        let mock = server
165            .mock("GET", "/auth")
166            .match_header("authorization", "Bearer xyz")
167            .match_header("accept", "application/json")
168            .with_status(200)
169            .with_body("{}")
170            .create_async()
171            .await;
172
173        let client = NativeHttpClient::new().unwrap();
174        let req = Request::get(&format!("{}/auth", server.url()))
175            .with_header("Authorization", "Bearer xyz")
176            .with_header("Accept", "application/json");
177        let resp = client.send(req).await.unwrap();
178
179        assert!(resp.is_success());
180        mock.assert_async().await;
181    }
182
183    #[tokio::test]
184    async fn test_send_non_success_status() {
185        let mut server = mockito::Server::new_async().await;
186        let mock = server
187            .mock("GET", "/missing")
188            .with_status(404)
189            .with_body("not found")
190            .create_async()
191            .await;
192
193        let client = NativeHttpClient::new().unwrap();
194        let req = Request::get(&format!("{}/missing", server.url()));
195        let resp = client.send(req).await.unwrap();
196
197        assert_eq!(resp.status_code, 404);
198        assert!(!resp.is_success());
199        assert_eq!(resp.body, "not found");
200        mock.assert_async().await;
201    }
202
203    #[tokio::test]
204    async fn test_send_server_error() {
205        let mut server = mockito::Server::new_async().await;
206        let mock = server
207            .mock("GET", "/err")
208            .with_status(500)
209            .with_body("internal error")
210            .create_async()
211            .await;
212
213        let client = NativeHttpClient::new().unwrap();
214        let req = Request::get(&format!("{}/err", server.url()));
215        let resp = client.send(req).await.unwrap();
216
217        assert_eq!(resp.status_code, 500);
218        assert!(!resp.is_success());
219        mock.assert_async().await;
220    }
221
222    #[tokio::test]
223    async fn test_response_headers_collected() {
224        let mut server = mockito::Server::new_async().await;
225        let mock = server
226            .mock("GET", "/headers")
227            .with_status(200)
228            .with_header("x-custom", "value123")
229            .with_body("")
230            .create_async()
231            .await;
232
233        let client = NativeHttpClient::new().unwrap();
234        let req = Request::get(&format!("{}/headers", server.url()));
235        let resp = client.send(req).await.unwrap();
236
237        assert_eq!(
238            resp.headers.get("x-custom").map(String::as_str),
239            Some("value123")
240        );
241        mock.assert_async().await;
242    }
243
244    #[tokio::test]
245    async fn test_native_applies_request_timeout() {
246        // A server that answers after 2 s must fail a 200 ms request.
247        let mut server = mockito::Server::new_async().await;
248        let _m = server
249            .mock("GET", "/slow")
250            .with_chunked_body(|w| {
251                std::thread::sleep(Duration::from_secs(2));
252                w.write_all(b"late")
253            })
254            .create_async()
255            .await;
256
257        let client = NativeHttpClient::new().unwrap();
258        let req = Request::get(&format!("{}/slow", server.url()))
259            .with_timeout(Duration::from_millis(200));
260        assert!(client.send(req).await.is_err());
261    }
262
263    #[tokio::test]
264    async fn test_empty_method_returns_error() {
265        let client = NativeHttpClient::new().unwrap();
266        let mut req = Request::get("https://example.com");
267        req.method = String::new();
268        let result = client.send(req).await;
269        assert!(result.is_err());
270    }
271}
272
273#[cfg(test)]
274mod live_guard_tests {
275    use super::*;
276
277    // #39: a unit test must not reach a live API, or its result and the
278    // coverage change with the network.
279    #[test]
280    fn test_a_test_build_refuses_live_hosts() {
281        let err = live_host_check("https://api.dexscreener.com/latest", false).unwrap_err();
282        assert!(
283            err.to_string().contains("live network is off in tests"),
284            "{err}"
285        );
286        // The opt-in for a manual run lets it through.
287        assert!(live_host_check("https://api.dexscreener.com/latest", true).is_ok());
288        for local in [
289            "http://127.0.0.1:1234/x",
290            "http://localhost/x",
291            "http://[::1]:80/x",
292        ] {
293            assert!(live_host_check(local, false).is_ok(), "{local}");
294        }
295    }
296
297    // send() applies the check before anything goes out.
298    #[tokio::test]
299    async fn test_send_applies_the_live_host_check() {
300        if std::env::var_os(TEST_NETWORK_ENV).is_some() {
301            return; // a manual run with the network allowed
302        }
303        let client = NativeHttpClient::new().unwrap();
304        let err = client
305            .send(Request::get("https://api.dexscreener.com/latest"))
306            .await
307            .unwrap_err();
308        assert!(
309            err.to_string().contains("live network is off in tests"),
310            "{err}"
311        );
312    }
313
314    #[tokio::test]
315    async fn test_a_test_build_still_reaches_a_local_mock() {
316        let mut server = mockito::Server::new_async().await;
317        let mock = server
318            .mock("GET", "/ok")
319            .with_body("yes")
320            .create_async()
321            .await;
322        let client = NativeHttpClient::new().unwrap();
323        let resp = client
324            .send(Request::get(&format!("{}/ok", server.url())))
325            .await
326            .unwrap();
327        assert_eq!(resp.body, "yes");
328        mock.assert_async().await;
329    }
330}