#![allow(
clippy::missing_errors_doc,
clippy::must_use_candidate,
clippy::return_self_not_must_use
)]
use crate::{Error, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub struct AdminClient {
base_url: String,
client: Client,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MockConfig {
#[serde(skip_serializing_if = "String::is_empty")]
pub id: String,
pub name: String,
pub method: String,
pub path: String,
pub response: MockResponse,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub latency_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_code: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_match: Option<RequestMatchCriteria>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scenario: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required_scenario_state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_scenario_state: Option<String>,
}
const fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MockResponse {
pub body: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub headers: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RequestMatchCriteria {
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, String>,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub query_params: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body_pattern: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub json_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub xpath: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_matcher: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerStats {
pub uptime_seconds: u64,
pub total_requests: u64,
pub active_mocks: usize,
pub enabled_mocks: usize,
pub registered_routes: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub version: String,
pub port: u16,
pub has_openapi_spec: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub spec_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MockList {
pub mocks: Vec<MockConfig>,
pub total: usize,
pub enabled: usize,
}
impl AdminClient {
pub fn new(base_url: impl Into<String>) -> Self {
let mut url = base_url.into();
while url.ends_with('/') {
url.pop();
}
Self {
base_url: url,
client: Client::new(),
}
}
pub async fn list_mocks(&self) -> Result<MockList> {
let url = format!("{}/api/mocks", self.base_url);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| Error::General(format!("Failed to list mocks: {e}")))?;
if !response.status().is_success() {
return Err(Error::General(format!(
"Failed to list mocks: HTTP {}",
response.status()
)));
}
response
.json()
.await
.map_err(|e| Error::General(format!("Failed to parse response: {e}")))
}
pub async fn get_mock(&self, id: &str) -> Result<MockConfig> {
let url = format!("{}/api/mocks/{}", self.base_url, id);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| Error::General(format!("Failed to get mock: {e}")))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(Error::General(format!("Mock not found: {id}")));
}
if !response.status().is_success() {
return Err(Error::General(format!("Failed to get mock: HTTP {}", response.status())));
}
response
.json()
.await
.map_err(|e| Error::General(format!("Failed to parse response: {e}")))
}
pub async fn create_mock(&self, mock: MockConfig) -> Result<MockConfig> {
let url = format!("{}/api/mocks", self.base_url);
let response = self
.client
.post(&url)
.json(&mock)
.send()
.await
.map_err(|e| Error::General(format!("Failed to create mock: {e}")))?;
if response.status() == reqwest::StatusCode::CONFLICT {
return Err(Error::General(format!("Mock with ID {} already exists", mock.id)));
}
if !response.status().is_success() {
return Err(Error::General(format!(
"Failed to create mock: HTTP {}",
response.status()
)));
}
response
.json()
.await
.map_err(|e| Error::General(format!("Failed to parse response: {e}")))
}
pub async fn update_mock(&self, id: &str, mock: MockConfig) -> Result<MockConfig> {
let url = format!("{}/api/mocks/{}", self.base_url, id);
let response = self
.client
.put(&url)
.json(&mock)
.send()
.await
.map_err(|e| Error::General(format!("Failed to update mock: {e}")))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(Error::General(format!("Mock not found: {id}")));
}
if !response.status().is_success() {
return Err(Error::General(format!(
"Failed to update mock: HTTP {}",
response.status()
)));
}
response
.json()
.await
.map_err(|e| Error::General(format!("Failed to parse response: {e}")))
}
pub async fn delete_mock(&self, id: &str) -> Result<()> {
let url = format!("{}/api/mocks/{}", self.base_url, id);
let response = self
.client
.delete(&url)
.send()
.await
.map_err(|e| Error::General(format!("Failed to delete mock: {e}")))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(Error::General(format!("Mock not found: {id}")));
}
if !response.status().is_success() {
return Err(Error::General(format!(
"Failed to delete mock: HTTP {}",
response.status()
)));
}
Ok(())
}
pub async fn get_stats(&self) -> Result<ServerStats> {
let url = format!("{}/api/stats", self.base_url);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| Error::General(format!("Failed to get stats: {e}")))?;
if !response.status().is_success() {
return Err(Error::General(format!("Failed to get stats: HTTP {}", response.status())));
}
response
.json()
.await
.map_err(|e| Error::General(format!("Failed to parse response: {e}")))
}
pub async fn get_config(&self) -> Result<ServerConfig> {
let url = format!("{}/api/config", self.base_url);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| Error::General(format!("Failed to get config: {e}")))?;
if !response.status().is_success() {
return Err(Error::General(format!(
"Failed to get config: HTTP {}",
response.status()
)));
}
response
.json()
.await
.map_err(|e| Error::General(format!("Failed to parse response: {e}")))
}
pub async fn reset(&self) -> Result<()> {
let url = format!("{}/api/reset", self.base_url);
let response = self
.client
.post(&url)
.send()
.await
.map_err(|e| Error::General(format!("Failed to reset mocks: {e}")))?;
if !response.status().is_success() {
return Err(Error::General(format!(
"Failed to reset mocks: HTTP {}",
response.status()
)));
}
Ok(())
}
}
pub struct MockConfigBuilder {
config: MockConfig,
}
impl MockConfigBuilder {
pub fn new(method: impl Into<String>, path: impl Into<String>) -> Self {
Self {
config: MockConfig {
id: String::new(),
name: String::new(),
method: method.into().to_uppercase(),
path: path.into(),
response: MockResponse {
body: serde_json::json!({}),
headers: None,
},
enabled: true,
latency_ms: None,
status_code: None,
request_match: None,
priority: None,
scenario: None,
required_scenario_state: None,
new_scenario_state: None,
},
}
}
pub fn id(mut self, id: impl Into<String>) -> Self {
self.config.id = id.into();
self
}
pub fn name(mut self, name: impl Into<String>) -> Self {
self.config.name = name.into();
self
}
#[must_use]
pub fn body(mut self, body: serde_json::Value) -> Self {
self.config.response.body = body;
self
}
#[must_use]
pub const fn status(mut self, status: u16) -> Self {
self.config.status_code = Some(status);
self
}
#[must_use]
pub fn headers(mut self, headers: HashMap<String, String>) -> Self {
self.config.response.headers = Some(headers);
self
}
pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
let headers = self.config.response.headers.get_or_insert_with(HashMap::new);
headers.insert(key.into(), value.into());
self
}
#[must_use]
pub const fn latency_ms(mut self, ms: u64) -> Self {
self.config.latency_ms = Some(ms);
self
}
#[must_use]
pub const fn enabled(mut self, enabled: bool) -> Self {
self.config.enabled = enabled;
self
}
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
let match_criteria =
self.config.request_match.get_or_insert_with(RequestMatchCriteria::default);
match_criteria.headers.insert(name.into(), value.into());
self
}
pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
let match_criteria =
self.config.request_match.get_or_insert_with(RequestMatchCriteria::default);
match_criteria.headers.extend(headers);
self
}
pub fn with_query_param(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
let match_criteria =
self.config.request_match.get_or_insert_with(RequestMatchCriteria::default);
match_criteria.query_params.insert(name.into(), value.into());
self
}
pub fn with_query_params(mut self, params: HashMap<String, String>) -> Self {
let match_criteria =
self.config.request_match.get_or_insert_with(RequestMatchCriteria::default);
match_criteria.query_params.extend(params);
self
}
pub fn with_body_pattern(mut self, pattern: impl Into<String>) -> Self {
let match_criteria =
self.config.request_match.get_or_insert_with(RequestMatchCriteria::default);
match_criteria.body_pattern = Some(pattern.into());
self
}
pub fn with_json_path(mut self, json_path: impl Into<String>) -> Self {
let match_criteria =
self.config.request_match.get_or_insert_with(RequestMatchCriteria::default);
match_criteria.json_path = Some(json_path.into());
self
}
pub fn with_xpath(mut self, xpath: impl Into<String>) -> Self {
let match_criteria =
self.config.request_match.get_or_insert_with(RequestMatchCriteria::default);
match_criteria.xpath = Some(xpath.into());
self
}
pub fn with_custom_matcher(mut self, expression: impl Into<String>) -> Self {
let match_criteria =
self.config.request_match.get_or_insert_with(RequestMatchCriteria::default);
match_criteria.custom_matcher = Some(expression.into());
self
}
#[must_use]
pub const fn priority(mut self, priority: i32) -> Self {
self.config.priority = Some(priority);
self
}
pub fn scenario(mut self, scenario: impl Into<String>) -> Self {
self.config.scenario = Some(scenario.into());
self
}
pub fn when_scenario_state(mut self, state: impl Into<String>) -> Self {
self.config.required_scenario_state = Some(state.into());
self
}
pub fn will_set_scenario_state(mut self, state: impl Into<String>) -> Self {
self.config.new_scenario_state = Some(state.into());
self
}
#[must_use]
pub fn build(self) -> MockConfig {
self.config
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mock_config_builder_basic() {
let mock = MockConfigBuilder::new("GET", "/api/users")
.name("Get Users")
.status(200)
.body(serde_json::json!([{"id": 1, "name": "Alice"}]))
.latency_ms(100)
.header("Content-Type", "application/json")
.build();
assert_eq!(mock.method, "GET");
assert_eq!(mock.path, "/api/users");
assert_eq!(mock.name, "Get Users");
assert_eq!(mock.status_code, Some(200));
assert_eq!(mock.latency_ms, Some(100));
assert!(mock.enabled);
}
#[test]
fn test_mock_config_builder_with_matching() {
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer.*".to_string());
let mut query_params = HashMap::new();
query_params.insert("role".to_string(), "admin".to_string());
let mock = MockConfigBuilder::new("POST", "/api/users")
.name("Create User")
.with_headers(headers.clone())
.with_query_params(query_params.clone())
.with_body_pattern(r#"{"name":".*"}"#)
.status(201)
.body(serde_json::json!({"id": 123, "created": true}))
.priority(10)
.build();
assert_eq!(mock.method, "POST");
assert!(mock.request_match.is_some());
let match_criteria = mock.request_match.unwrap();
assert_eq!(match_criteria.headers.get("Authorization"), Some(&"Bearer.*".to_string()));
assert_eq!(match_criteria.query_params.get("role"), Some(&"admin".to_string()));
assert_eq!(match_criteria.body_pattern, Some(r#"{"name":".*"}"#.to_string()));
assert_eq!(mock.priority, Some(10));
}
#[test]
fn test_mock_config_builder_with_scenario() {
let mock = MockConfigBuilder::new("GET", "/api/checkout")
.name("Checkout Step 1")
.scenario("checkout-flow")
.when_scenario_state("started")
.will_set_scenario_state("payment")
.status(200)
.body(serde_json::json!({"step": 1}))
.build();
assert_eq!(mock.scenario, Some("checkout-flow".to_string()));
assert_eq!(mock.required_scenario_state, Some("started".to_string()));
assert_eq!(mock.new_scenario_state, Some("payment".to_string()));
}
#[test]
fn test_mock_config_builder_fluent_chaining() {
let mock = MockConfigBuilder::new("GET", "/api/users/{id}")
.id("user-get-123")
.name("Get User by ID")
.with_header("Accept", "application/json")
.with_query_param("include", "profile")
.with_json_path("$.id")
.status(200)
.body(serde_json::json!({"id": "{{request.path.id}}", "name": "Alice"}))
.header("X-Request-ID", "{{uuid}}")
.latency_ms(50)
.priority(5)
.enabled(true)
.build();
assert_eq!(mock.id, "user-get-123");
assert_eq!(mock.name, "Get User by ID");
assert!(mock.request_match.is_some());
let match_criteria = mock.request_match.unwrap();
assert!(match_criteria.headers.contains_key("Accept"));
assert!(match_criteria.query_params.contains_key("include"));
assert_eq!(match_criteria.json_path, Some("$.id".to_string()));
assert_eq!(mock.priority, Some(5));
}
}