1pub 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#[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#[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 pub timeout: Option<std::time::Duration>,
57}
58
59impl Request {
60 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 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 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 pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
92 self.timeout = Some(timeout);
93 self
94 }
95}
96
97#[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 pub fn is_success(&self) -> bool {
108 (200..300).contains(&self.status_code)
109 }
110
111 pub fn json<T: serde::de::DeserializeOwned>(&self) -> serde_json::Result<T> {
113 serde_json::from_str(&self.body)
114 }
115}
116
117#[async_trait]
121pub trait HttpClient: Send + Sync {
122 async fn send(&self, request: Request) -> Result<Response, ScopeError>;
124}
125
126#[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}