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#[async_trait]
29impl HttpClient for NativeHttpClient {
30    async fn send(&self, request: Request) -> Result<Response, ScopeError> {
31        let method: reqwest::Method = request
32            .method
33            .parse()
34            .map_err(|e| ScopeError::Network(format!("invalid HTTP method: {e}")))?;
35
36        let mut builder = self.client.request(method, &request.url);
37
38        for (k, v) in &request.headers {
39            builder = builder.header(k.as_str(), v.as_str());
40        }
41
42        if let Some(body) = request.body {
43            builder = builder.body(body);
44        }
45
46        let resp = builder.send().await?;
47        let status = resp.status().as_u16();
48
49        let mut headers = std::collections::HashMap::new();
50        for (k, v) in resp.headers() {
51            headers.insert(k.to_string(), v.to_str().unwrap_or("").to_string());
52        }
53
54        let body = resp.text().await?;
55
56        Ok(Response {
57            status_code: status,
58            headers,
59            body,
60        })
61    }
62}
63
64// ============================================================================
65// Unit Tests
66// ============================================================================
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn test_native_client_creation() {
74        let client = NativeHttpClient::new();
75        assert!(client.is_ok());
76    }
77
78    #[tokio::test]
79    async fn test_send_get_request() {
80        let mut server = mockito::Server::new_async().await;
81        let mock = server
82            .mock("GET", "/test")
83            .with_status(200)
84            .with_header("x-test", "hello")
85            .with_body(r#"{"ok":true}"#)
86            .create_async()
87            .await;
88
89        let client = NativeHttpClient::new().unwrap();
90        let req = Request::get(&format!("{}/test", server.url()));
91        let resp = client.send(req).await.unwrap();
92
93        assert_eq!(resp.status_code, 200);
94        assert!(resp.is_success());
95        assert!(resp.body.contains("\"ok\":true"));
96        mock.assert_async().await;
97    }
98
99    #[tokio::test]
100    async fn test_send_post_json_request() {
101        let mut server = mockito::Server::new_async().await;
102        let mock = server
103            .mock("POST", "/api")
104            .match_header("content-type", "application/json")
105            .match_body(r#"{"key":"val"}"#)
106            .with_status(201)
107            .with_body(r#"{"created":true}"#)
108            .create_async()
109            .await;
110
111        let client = NativeHttpClient::new().unwrap();
112        let req = Request::post_json(
113            &format!("{}/api", server.url()),
114            r#"{"key":"val"}"#,
115        );
116        let resp = client.send(req).await.unwrap();
117
118        assert_eq!(resp.status_code, 201);
119        assert!(resp.is_success());
120        let parsed: serde_json::Value = resp.json().unwrap();
121        assert_eq!(parsed["created"], true);
122        mock.assert_async().await;
123    }
124
125    #[tokio::test]
126    async fn test_send_with_custom_headers() {
127        let mut server = mockito::Server::new_async().await;
128        let mock = server
129            .mock("GET", "/auth")
130            .match_header("authorization", "Bearer xyz")
131            .match_header("accept", "application/json")
132            .with_status(200)
133            .with_body("{}")
134            .create_async()
135            .await;
136
137        let client = NativeHttpClient::new().unwrap();
138        let req = Request::get(&format!("{}/auth", server.url()))
139            .with_header("Authorization", "Bearer xyz")
140            .with_header("Accept", "application/json");
141        let resp = client.send(req).await.unwrap();
142
143        assert!(resp.is_success());
144        mock.assert_async().await;
145    }
146
147    #[tokio::test]
148    async fn test_send_non_success_status() {
149        let mut server = mockito::Server::new_async().await;
150        let mock = server
151            .mock("GET", "/missing")
152            .with_status(404)
153            .with_body("not found")
154            .create_async()
155            .await;
156
157        let client = NativeHttpClient::new().unwrap();
158        let req = Request::get(&format!("{}/missing", server.url()));
159        let resp = client.send(req).await.unwrap();
160
161        assert_eq!(resp.status_code, 404);
162        assert!(!resp.is_success());
163        assert_eq!(resp.body, "not found");
164        mock.assert_async().await;
165    }
166
167    #[tokio::test]
168    async fn test_send_server_error() {
169        let mut server = mockito::Server::new_async().await;
170        let mock = server
171            .mock("GET", "/err")
172            .with_status(500)
173            .with_body("internal error")
174            .create_async()
175            .await;
176
177        let client = NativeHttpClient::new().unwrap();
178        let req = Request::get(&format!("{}/err", server.url()));
179        let resp = client.send(req).await.unwrap();
180
181        assert_eq!(resp.status_code, 500);
182        assert!(!resp.is_success());
183        mock.assert_async().await;
184    }
185
186    #[tokio::test]
187    async fn test_response_headers_collected() {
188        let mut server = mockito::Server::new_async().await;
189        let mock = server
190            .mock("GET", "/headers")
191            .with_status(200)
192            .with_header("x-custom", "value123")
193            .with_body("")
194            .create_async()
195            .await;
196
197        let client = NativeHttpClient::new().unwrap();
198        let req = Request::get(&format!("{}/headers", server.url()));
199        let resp = client.send(req).await.unwrap();
200
201        assert_eq!(
202            resp.headers.get("x-custom").map(String::as_str),
203            Some("value123")
204        );
205        mock.assert_async().await;
206    }
207
208    #[tokio::test]
209    async fn test_empty_method_returns_error() {
210        let client = NativeHttpClient::new().unwrap();
211        let mut req = Request::get("https://example.com");
212        req.method = String::new();
213        let result = client.send(req).await;
214        assert!(result.is_err());
215    }
216}