pub mod ghola;
pub mod ghola_install;
pub mod native;
pub mod replay;
pub mod shared;
pub use ghola::GholaHttpClient;
pub use native::NativeHttpClient;
pub use shared::{Built, build};
#[cfg(any(test, feature = "test-support"))]
pub fn test_transport() -> std::sync::Arc<dyn HttpClient> {
std::sync::Arc::new(NativeHttpClient::new().expect("failed to build the test transport"))
}
use async_trait::async_trait;
use std::collections::HashMap;
use crate::error::ScopeError;
#[derive(Debug, Clone)]
pub struct Request {
pub url: String,
pub method: String,
pub headers: HashMap<String, String>,
pub body: Option<String>,
pub timeout: Option<std::time::Duration>,
}
impl Request {
pub fn get(url: &str) -> Self {
Self {
url: url.to_string(),
method: "GET".to_string(),
headers: HashMap::new(),
body: None,
timeout: None,
}
}
pub fn post_json(url: &str, body: impl Into<String>) -> Self {
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
Self {
url: url.to_string(),
method: "POST".to_string(),
headers,
body: Some(body.into()),
timeout: None,
}
}
pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.insert(key.into(), value.into());
self
}
pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
self.timeout = Some(timeout);
self
}
}
#[derive(Debug, Clone)]
pub struct Response {
pub status_code: u16,
pub headers: HashMap<String, String>,
pub body: String,
}
impl Response {
pub fn is_success(&self) -> bool {
(200..300).contains(&self.status_code)
}
pub fn json<T: serde::de::DeserializeOwned>(&self) -> serde_json::Result<T> {
serde_json::from_str(&self.body)
}
}
#[async_trait]
pub trait HttpClient: Send + Sync {
async fn send(&self, request: Request) -> Result<Response, ScopeError>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_request_get() {
let req = Request::get("https://example.com");
assert_eq!(req.method, "GET");
assert_eq!(req.url, "https://example.com");
assert!(req.body.is_none());
assert!(req.headers.is_empty());
}
#[test]
fn test_request_post_json() {
let req = Request::post_json("https://example.com/api", r#"{"key":"value"}"#);
assert_eq!(req.method, "POST");
assert_eq!(req.body.as_deref(), Some(r#"{"key":"value"}"#));
assert_eq!(
req.headers.get("Content-Type").map(String::as_str),
Some("application/json")
);
}
#[test]
fn test_request_timeout_defaults_to_none() {
assert!(Request::get("https://example.com").timeout.is_none());
assert!(
Request::post_json("https://example.com", "{}")
.timeout
.is_none()
);
}
#[test]
fn test_request_with_timeout_sets_it() {
let req =
Request::get("https://example.com").with_timeout(std::time::Duration::from_secs(7));
assert_eq!(req.timeout, Some(std::time::Duration::from_secs(7)));
}
#[test]
fn test_request_with_header() {
let req = Request::get("https://example.com")
.with_header("Authorization", "Bearer token123")
.with_header("Accept", "application/json");
assert_eq!(req.headers.len(), 2);
assert_eq!(
req.headers.get("Authorization").map(String::as_str),
Some("Bearer token123")
);
}
#[test]
fn test_response_is_success() {
assert!(
Response {
status_code: 200,
headers: HashMap::new(),
body: String::new()
}
.is_success()
);
assert!(
Response {
status_code: 299,
headers: HashMap::new(),
body: String::new()
}
.is_success()
);
assert!(
!Response {
status_code: 404,
headers: HashMap::new(),
body: String::new()
}
.is_success()
);
assert!(
!Response {
status_code: 500,
headers: HashMap::new(),
body: String::new()
}
.is_success()
);
}
#[test]
fn test_response_json() {
let resp = Response {
status_code: 200,
headers: HashMap::new(),
body: r#"{"name":"test","value":42}"#.to_string(),
};
let parsed: serde_json::Value = resp.json().unwrap();
assert_eq!(parsed["name"], "test");
assert_eq!(parsed["value"], 42);
}
#[test]
fn test_response_json_error() {
let resp = Response {
status_code: 200,
headers: HashMap::new(),
body: "not json".to_string(),
};
let result: serde_json::Result<serde_json::Value> = resp.json();
assert!(result.is_err());
}
#[test]
fn test_request_debug_formatting() {
let req = Request::get("https://example.com");
let debug = format!("{:?}", req);
assert!(debug.contains("GET"));
assert!(debug.contains("example.com"));
}
#[test]
fn test_request_clone() {
let req =
Request::post_json("https://example.com", r#"{"a":1}"#).with_header("X-Test", "yes");
let cloned = req.clone();
assert_eq!(cloned.method, "POST");
assert_eq!(cloned.url, "https://example.com");
assert_eq!(cloned.body, Some(r#"{"a":1}"#.to_string()));
assert_eq!(
cloned.headers.get("X-Test").map(String::as_str),
Some("yes")
);
assert_eq!(
cloned.headers.get("Content-Type").map(String::as_str),
Some("application/json")
);
}
#[test]
fn test_response_debug_formatting() {
let resp = Response {
status_code: 404,
headers: HashMap::new(),
body: "not found".to_string(),
};
let debug = format!("{:?}", resp);
assert!(debug.contains("404"));
assert!(debug.contains("not found"));
}
#[test]
fn test_response_clone() {
let mut headers = HashMap::new();
headers.insert("content-type".to_string(), "text/plain".to_string());
let resp = Response {
status_code: 200,
headers,
body: "hello".to_string(),
};
let cloned = resp.clone();
assert_eq!(cloned.status_code, 200);
assert_eq!(cloned.body, "hello");
assert_eq!(
cloned.headers.get("content-type").map(String::as_str),
Some("text/plain")
);
}
#[test]
fn test_response_boundary_success() {
assert!(
!Response {
status_code: 199,
headers: HashMap::new(),
body: String::new()
}
.is_success()
);
assert!(
Response {
status_code: 200,
headers: HashMap::new(),
body: String::new()
}
.is_success()
);
assert!(
!Response {
status_code: 300,
headers: HashMap::new(),
body: String::new()
}
.is_success()
);
}
#[test]
fn test_request_header_overwrite() {
let req = Request::get("https://example.com")
.with_header("X-Key", "first")
.with_header("X-Key", "second");
assert_eq!(req.headers.get("X-Key").map(String::as_str), Some("second"));
}
#[test]
fn test_response_json_typed() {
#[derive(serde::Deserialize, Debug, PartialEq)]
struct TestData {
name: String,
count: u32,
}
let resp = Response {
status_code: 200,
headers: HashMap::new(),
body: r#"{"name":"test","count":5}"#.to_string(),
};
let parsed: TestData = resp.json().unwrap();
assert_eq!(
parsed,
TestData {
name: "test".to_string(),
count: 5
}
);
}
#[test]
fn test_post_json_empty_body() {
let req = Request::post_json("https://example.com", "");
assert_eq!(req.body, Some(String::new()));
assert_eq!(req.method, "POST");
}
}