use std::cell::RefCell;
use std::collections::HashMap;
use std::time::{Duration, Instant};
use reqwest::blocking::{Client, Response};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use super::spec::{EnvConfig, TestSpec};
use super::RunnerError;
pub struct TestExecutor {
client: Client,
base_url: String,
default_timeout: Duration,
variables: RefCell<HashMap<String, String>>,
}
#[derive(Debug, Clone)]
pub struct RequestResult {
pub status: u16,
pub body: serde_json::Value,
pub duration_ms: u64,
pub headers: HashMap<String, String>,
}
impl TestExecutor {
pub fn new(env: &EnvConfig) -> Result<Self, RunnerError> {
let base_url = env
.base_url
.clone()
.or_else(|| std::env::var("BASE_URL").ok())
.unwrap_or_else(|| "http://localhost:8080".to_string());
let base_url = expand_env_vars(&base_url);
let timeout_ms = env.timeout_ms.unwrap_or(30000);
let default_timeout = Duration::from_millis(timeout_ms);
let client = Client::builder()
.timeout(default_timeout)
.build()
.map_err(|e| RunnerError::HttpError(e))?;
Ok(Self {
client,
base_url,
default_timeout,
variables: RefCell::new(HashMap::new()),
})
}
pub fn execute(
&self,
spec: &TestSpec,
request_name: &str,
endpoint: &str,
method: &str,
timeout_override: Option<u64>,
) -> Result<RequestResult, RunnerError> {
let request = spec.resolve_request(request_name)?;
let url = format!("{}{}", self.base_url, self.expand_all(endpoint));
let timeout = timeout_override
.map(Duration::from_millis)
.unwrap_or(self.default_timeout);
let mut headers = HeaderMap::new();
for (key, value) in &request.headers {
let expanded = self.expand_all(value);
let header_name = HeaderName::try_from(key.as_str())
.map_err(|e| RunnerError::ParseError(format!("Invalid header name '{}': {}", key, e)))?;
let header_value = HeaderValue::try_from(expanded.as_str())
.map_err(|e| RunnerError::ParseError(format!("Invalid header value '{}': {}", value, e)))?;
headers.insert(header_name, header_value);
}
if request.body.is_some() && !headers.contains_key("content-type") {
headers.insert(
HeaderName::from_static("content-type"),
HeaderValue::from_static("application/json"),
);
}
let start = Instant::now();
let mut req_builder = match method.to_uppercase().as_str() {
"GET" => self.client.get(&url),
"POST" => self.client.post(&url),
"PUT" => self.client.put(&url),
"PATCH" => self.client.patch(&url),
"DELETE" => self.client.delete(&url),
_ => return Err(RunnerError::ParseError(format!("Unknown HTTP method: {}", method))),
};
req_builder = req_builder.headers(headers).timeout(timeout);
if !request.query_params.is_empty() {
let params: Vec<(String, String)> = request
.query_params
.iter()
.map(|(k, v)| (self.expand_all(k), self.expand_all(v)))
.collect();
req_builder = req_builder.query(¶ms);
}
if let Some(ref body) = request.body {
req_builder = req_builder.json(&self.expand_all_in_json(body));
}
let response = req_builder.send()?;
let duration_ms = start.elapsed().as_millis() as u64;
self.process_response(response, duration_ms)
}
fn expand_all(&self, input: &str) -> String {
let env_expanded = expand_env_vars(input);
expand_variables(&env_expanded, &self.variables.borrow())
}
fn expand_all_in_json(&self, value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::String(s) => serde_json::Value::String(self.expand_all(s)),
serde_json::Value::Object(map) => {
let expanded: serde_json::Map<String, serde_json::Value> = map
.iter()
.map(|(k, v)| (self.expand_all(k), self.expand_all_in_json(v)))
.collect();
serde_json::Value::Object(expanded)
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(|v| self.expand_all_in_json(v)).collect())
}
other => other.clone(),
}
}
pub fn extract_variables(
&self,
extractions: &HashMap<String, String>,
response_body: &serde_json::Value,
) -> Result<(), RunnerError> {
use clove_lang::{Evaluator, Lexer, Parser};
let clove_data = clove_lang::json_to_clove(response_body.clone());
let mut vars = self.variables.borrow_mut();
for (var_name, query) in extractions {
let lexer = Lexer::new(query);
let mut parser = Parser::new(lexer)
.map_err(|e| RunnerError::CloveError(format!("Extract '{}': {}", var_name, e)))?;
let parsed = parser.parse()
.map_err(|e| RunnerError::CloveError(format!("Extract '{}': {}", var_name, e)))?;
let result = Evaluator::new()
.eval_expression(&parsed, clove_data.clone())
.map_err(|e| RunnerError::CloveError(format!("Extract '{}': {}", var_name, e)))?;
let json_val = clove_lang::clove_to_json(result);
let string_val = match &json_val {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
vars.insert(var_name.clone(), string_val);
}
Ok(())
}
fn process_response(&self, response: Response, duration_ms: u64) -> Result<RequestResult, RunnerError> {
let status = response.status().as_u16();
let headers: HashMap<String, String> = response
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let body: serde_json::Value = response.json().unwrap_or(serde_json::Value::Null);
Ok(RequestResult {
status,
body,
duration_ms,
headers,
})
}
}
pub(crate) fn expand_env_vars(input: &str) -> String {
let mut result = input.to_string();
let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}").unwrap();
for cap in re.captures_iter(input) {
let full_match = cap.get(0).unwrap().as_str();
let var_name = cap.get(1).unwrap().as_str();
let default = cap.get(2).map(|m| m.as_str());
let value = std::env::var(var_name)
.ok()
.or_else(|| default.map(|s| s.to_string()))
.unwrap_or_default();
result = result.replace(full_match, &value);
}
result
}
fn expand_variables(input: &str, variables: &HashMap<String, String>) -> String {
let re = regex::Regex::new(r"\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}").unwrap();
let mut result = input.to_string();
for cap in re.captures_iter(input) {
let full_match = cap.get(0).unwrap().as_str();
let var_name = cap.get(1).unwrap().as_str();
if let Some(value) = variables.get(var_name) {
result = result.replace(full_match, value);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_expand_env_vars_simple() {
unsafe {
std::env::set_var("TEST_VAR", "test_value");
}
assert_eq!(expand_env_vars("${TEST_VAR}"), "test_value");
unsafe {
std::env::remove_var("TEST_VAR");
}
}
#[test]
fn test_expand_env_vars_with_default() {
unsafe {
std::env::remove_var("NONEXISTENT_VAR");
}
assert_eq!(expand_env_vars("${NONEXISTENT_VAR:-default}"), "default");
}
#[test]
fn test_expand_env_vars_default_not_used() {
unsafe {
std::env::set_var("EXISTS_VAR", "actual");
}
assert_eq!(expand_env_vars("${EXISTS_VAR:-default}"), "actual");
unsafe {
std::env::remove_var("EXISTS_VAR");
}
}
#[test]
fn test_expand_variables_basic() {
let mut vars = HashMap::new();
vars.insert("token".to_string(), "abc123".to_string());
assert_eq!(expand_variables("Bearer {{token}}", &vars), "Bearer abc123");
}
#[test]
fn test_expand_variables_missing() {
let vars = HashMap::new();
assert_eq!(expand_variables("{{missing}}", &vars), "{{missing}}");
}
#[test]
fn test_expand_variables_multiple() {
let mut vars = HashMap::new();
vars.insert("host".to_string(), "api.example.com".to_string());
vars.insert("id".to_string(), "42".to_string());
assert_eq!(
expand_variables("https://{{host}}/users/{{id}}", &vars),
"https://api.example.com/users/42"
);
}
#[test]
fn test_extract_variables_from_json() {
let executor = make_test_executor();
let body = serde_json::json!({
"token": "secret-jwt-token",
"user": { "id": 42 }
});
let mut extractions = HashMap::new();
extractions.insert("auth_token".to_string(), "$[token]".to_string());
extractions.insert("user_id".to_string(), "$[user][id]".to_string());
executor.extract_variables(&extractions, &body).unwrap();
let vars = executor.variables.borrow();
assert_eq!(vars.get("auth_token").unwrap(), "secret-jwt-token");
assert_eq!(vars.get("user_id").unwrap(), "42");
}
fn make_test_executor() -> TestExecutor {
let _ = rustls::crypto::ring::default_provider().install_default();
let env = EnvConfig { base_url: Some("http://localhost".to_string()), timeout_ms: None };
TestExecutor::new(&env).unwrap()
}
#[test]
fn test_expand_all_in_json_string_values() {
let executor = make_test_executor();
unsafe {
std::env::set_var("JSON_USER", "alice");
}
let input = serde_json::json!({
"username": "${JSON_USER}",
"static": "unchanged"
});
let result = executor.expand_all_in_json(&input);
assert_eq!(result["username"], "alice");
assert_eq!(result["static"], "unchanged");
unsafe {
std::env::remove_var("JSON_USER");
}
}
#[test]
fn test_expand_all_in_json_nested() {
let executor = make_test_executor();
unsafe {
std::env::set_var("NESTED_VAL", "deep");
}
let input = serde_json::json!({
"outer": {
"inner": "${NESTED_VAL}",
"list": ["${NESTED_VAL}", "static"]
}
});
let result = executor.expand_all_in_json(&input);
assert_eq!(result["outer"]["inner"], "deep");
assert_eq!(result["outer"]["list"][0], "deep");
assert_eq!(result["outer"]["list"][1], "static");
unsafe {
std::env::remove_var("NESTED_VAL");
}
}
#[test]
fn test_expand_all_in_json_non_string() {
let executor = make_test_executor();
let input = serde_json::json!({
"count": 42,
"enabled": true,
"nothing": null,
"price": 9.99
});
let result = executor.expand_all_in_json(&input);
assert_eq!(result, input);
}
#[test]
fn test_expand_env_vars_in_url() {
unsafe {
std::env::set_var("BASE_HOST", "localhost");
std::env::set_var("PORT", "8080");
}
assert_eq!(
expand_env_vars("http://${BASE_HOST}:${PORT}/api"),
"http://localhost:8080/api"
);
unsafe {
std::env::remove_var("BASE_HOST");
std::env::remove_var("PORT");
}
}
}