Skip to main content

scope/http/
mod.rs

1//! # HTTP Transport Abstraction
2//!
3//! Provides a trait-based abstraction over HTTP clients, allowing
4//! transparent switching between direct `reqwest` calls and the
5//! [Ghola](https://github.com/robot-accomplice/ghola) sidecar proxy.
6//!
7//! ## Usage
8//!
9//! ```rust,no_run
10//! use scope::http::{HttpClient, NativeHttpClient, Request};
11//!
12//! # async fn example() -> anyhow::Result<()> {
13//! let client = NativeHttpClient::new()?;
14//! let resp = client.send(Request::get("https://api.example.com/data")).await?;
15//! println!("status={}, body_len={}", resp.status_code, resp.body.len());
16//! # Ok(())
17//! # }
18//! ```
19//!
20//! When the Ghola sidecar is enabled via `config.yaml`, the same
21//! `HttpClient` interface routes requests through `127.0.0.1:18789`,
22//! adding temporal drift, ghost signing, and chain-aware headers.
23
24pub mod ghola;
25pub mod native;
26
27pub use ghola::GholaHttpClient;
28pub use native::NativeHttpClient;
29
30use async_trait::async_trait;
31use std::collections::HashMap;
32
33use crate::error::ScopeError;
34
35/// Generic HTTP request passed through the transport abstraction.
36#[derive(Debug, Clone)]
37pub struct Request {
38    pub url: String,
39    pub method: String,
40    pub headers: HashMap<String, String>,
41    pub body: Option<String>,
42}
43
44impl Request {
45    /// Convenience constructor for a GET request.
46    pub fn get(url: &str) -> Self {
47        Self {
48            url: url.to_string(),
49            method: "GET".to_string(),
50            headers: HashMap::new(),
51            body: None,
52        }
53    }
54
55    /// Convenience constructor for a POST request with a JSON body.
56    pub fn post_json(url: &str, body: impl Into<String>) -> Self {
57        let mut headers = HashMap::new();
58        headers.insert("Content-Type".to_string(), "application/json".to_string());
59        Self {
60            url: url.to_string(),
61            method: "POST".to_string(),
62            headers,
63            body: Some(body.into()),
64        }
65    }
66
67    /// Adds a header to the request.
68    pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
69        self.headers.insert(key.into(), value.into());
70        self
71    }
72}
73
74/// Generic HTTP response returned by any transport implementation.
75#[derive(Debug, Clone)]
76pub struct Response {
77    pub status_code: u16,
78    pub headers: HashMap<String, String>,
79    pub body: String,
80}
81
82impl Response {
83    /// Returns `true` if the response status is in the 2xx range.
84    pub fn is_success(&self) -> bool {
85        (200..300).contains(&self.status_code)
86    }
87
88    /// Deserializes the body as JSON.
89    pub fn json<T: serde::de::DeserializeOwned>(&self) -> serde_json::Result<T> {
90        serde_json::from_str(&self.body)
91    }
92}
93
94/// Trait implemented by both the native reqwest client and the ghola
95/// sidecar client. Scope selects the implementation at startup based
96/// on the `ghola.enabled` flag in `config.yaml`.
97#[async_trait]
98pub trait HttpClient: Send + Sync {
99    /// Sends an HTTP request and returns the response.
100    async fn send(&self, request: Request) -> Result<Response, ScopeError>;
101}
102
103// ============================================================================
104// Unit Tests
105// ============================================================================
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn test_request_get() {
113        let req = Request::get("https://example.com");
114        assert_eq!(req.method, "GET");
115        assert_eq!(req.url, "https://example.com");
116        assert!(req.body.is_none());
117        assert!(req.headers.is_empty());
118    }
119
120    #[test]
121    fn test_request_post_json() {
122        let req = Request::post_json("https://example.com/api", r#"{"key":"value"}"#);
123        assert_eq!(req.method, "POST");
124        assert_eq!(req.body.as_deref(), Some(r#"{"key":"value"}"#));
125        assert_eq!(
126            req.headers.get("Content-Type").map(String::as_str),
127            Some("application/json")
128        );
129    }
130
131    #[test]
132    fn test_request_with_header() {
133        let req = Request::get("https://example.com")
134            .with_header("Authorization", "Bearer token123")
135            .with_header("Accept", "application/json");
136        assert_eq!(req.headers.len(), 2);
137        assert_eq!(
138            req.headers.get("Authorization").map(String::as_str),
139            Some("Bearer token123")
140        );
141    }
142
143    #[test]
144    fn test_response_is_success() {
145        assert!(
146            Response {
147                status_code: 200,
148                headers: HashMap::new(),
149                body: String::new()
150            }
151            .is_success()
152        );
153        assert!(
154            Response {
155                status_code: 299,
156                headers: HashMap::new(),
157                body: String::new()
158            }
159            .is_success()
160        );
161        assert!(
162            !Response {
163                status_code: 404,
164                headers: HashMap::new(),
165                body: String::new()
166            }
167            .is_success()
168        );
169        assert!(
170            !Response {
171                status_code: 500,
172                headers: HashMap::new(),
173                body: String::new()
174            }
175            .is_success()
176        );
177    }
178
179    #[test]
180    fn test_response_json() {
181        let resp = Response {
182            status_code: 200,
183            headers: HashMap::new(),
184            body: r#"{"name":"test","value":42}"#.to_string(),
185        };
186        let parsed: serde_json::Value = resp.json().unwrap();
187        assert_eq!(parsed["name"], "test");
188        assert_eq!(parsed["value"], 42);
189    }
190
191    #[test]
192    fn test_response_json_error() {
193        let resp = Response {
194            status_code: 200,
195            headers: HashMap::new(),
196            body: "not json".to_string(),
197        };
198        let result: serde_json::Result<serde_json::Value> = resp.json();
199        assert!(result.is_err());
200    }
201
202    #[test]
203    fn test_request_debug_formatting() {
204        let req = Request::get("https://example.com");
205        let debug = format!("{:?}", req);
206        assert!(debug.contains("GET"));
207        assert!(debug.contains("example.com"));
208    }
209
210    #[test]
211    fn test_request_clone() {
212        let req = Request::post_json("https://example.com", r#"{"a":1}"#)
213            .with_header("X-Test", "yes");
214        let cloned = req.clone();
215        assert_eq!(cloned.method, "POST");
216        assert_eq!(cloned.url, "https://example.com");
217        assert_eq!(cloned.body, Some(r#"{"a":1}"#.to_string()));
218        assert_eq!(
219            cloned.headers.get("X-Test").map(String::as_str),
220            Some("yes")
221        );
222        assert_eq!(
223            cloned.headers.get("Content-Type").map(String::as_str),
224            Some("application/json")
225        );
226    }
227
228    #[test]
229    fn test_response_debug_formatting() {
230        let resp = Response {
231            status_code: 404,
232            headers: HashMap::new(),
233            body: "not found".to_string(),
234        };
235        let debug = format!("{:?}", resp);
236        assert!(debug.contains("404"));
237        assert!(debug.contains("not found"));
238    }
239
240    #[test]
241    fn test_response_clone() {
242        let mut headers = HashMap::new();
243        headers.insert("content-type".to_string(), "text/plain".to_string());
244        let resp = Response {
245            status_code: 200,
246            headers,
247            body: "hello".to_string(),
248        };
249        let cloned = resp.clone();
250        assert_eq!(cloned.status_code, 200);
251        assert_eq!(cloned.body, "hello");
252        assert_eq!(
253            cloned.headers.get("content-type").map(String::as_str),
254            Some("text/plain")
255        );
256    }
257
258    #[test]
259    fn test_response_boundary_success() {
260        assert!(
261            !Response {
262                status_code: 199,
263                headers: HashMap::new(),
264                body: String::new()
265            }
266            .is_success()
267        );
268        assert!(
269            Response {
270                status_code: 200,
271                headers: HashMap::new(),
272                body: String::new()
273            }
274            .is_success()
275        );
276        assert!(
277            !Response {
278                status_code: 300,
279                headers: HashMap::new(),
280                body: String::new()
281            }
282            .is_success()
283        );
284    }
285
286    #[test]
287    fn test_request_header_overwrite() {
288        let req = Request::get("https://example.com")
289            .with_header("X-Key", "first")
290            .with_header("X-Key", "second");
291        assert_eq!(
292            req.headers.get("X-Key").map(String::as_str),
293            Some("second")
294        );
295    }
296
297    #[test]
298    fn test_response_json_typed() {
299        #[derive(serde::Deserialize, Debug, PartialEq)]
300        struct TestData {
301            name: String,
302            count: u32,
303        }
304
305        let resp = Response {
306            status_code: 200,
307            headers: HashMap::new(),
308            body: r#"{"name":"test","count":5}"#.to_string(),
309        };
310        let parsed: TestData = resp.json().unwrap();
311        assert_eq!(
312            parsed,
313            TestData {
314                name: "test".to_string(),
315                count: 5
316            }
317        );
318    }
319
320    #[test]
321    fn test_post_json_empty_body() {
322        let req = Request::post_json("https://example.com", "");
323        assert_eq!(req.body, Some(String::new()));
324        assert_eq!(req.method, "POST");
325    }
326}