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