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