1use super::{HttpClient, Request, Response};
7use crate::error::ScopeError;
8use async_trait::async_trait;
9use std::time::Duration;
10
11pub struct NativeHttpClient {
14 client: reqwest::Client,
15}
16
17impl NativeHttpClient {
18 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
28pub const TEST_NETWORK_ENV: &str = "SCOPE_TEST_NETWORK";
31
32#[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#[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#[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 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 #[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 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 #[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; }
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}