pub mod executor;
pub mod matcher;
pub mod state;
use crate::config::Endpoint;
use crate::rules::executor::ResponseExecutor;
use crate::rules::matcher::RuleMatcher;
use crate::rules::state::StateManager;
use jsonschema::Validator;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuleResponse {
pub status: u16,
pub body: Option<String>,
pub headers: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct ExecutionContext {
pub method: String,
pub path: String,
pub query: String,
pub headers: HashMap<String, String>,
pub body: Option<String>,
pub client_ip: String,
pub path_params: HashMap<String, String>,
}
#[derive(Clone)]
pub struct RuleEngine {
matcher: RuleMatcher,
executor: ResponseExecutor,
pub compiled_schemas: HashMap<String, Arc<Validator>>,
}
impl RuleEngine {
#[must_use]
pub fn new(endpoints: &[Endpoint]) -> Self {
let state_manager = Arc::new(StateManager::new());
let matcher = RuleMatcher::new(endpoints.to_owned());
let executor_engine = ResponseExecutor::new(state_manager);
let mut compiled_schemas = HashMap::new();
for endpoint in endpoints {
if let Some(schema_val) = &endpoint.schema {
let validator = jsonschema::validator_for(schema_val).unwrap_or_else(|e| {
panic!("Failed to compile schema for {}: {}", endpoint.name, e);
});
compiled_schemas.insert(endpoint.name.clone(), Arc::new(validator));
}
}
Self {
matcher,
executor: executor_engine,
compiled_schemas,
}
}
pub async fn execute(
&self,
method: &str,
path: &str,
query: &str,
headers: &HashMap<String, String>,
body: Option<&str>,
client_ip: &str,
) -> anyhow::Result<RuleResponse> {
let endpoint = self
.matcher
.find_match_with_context(method, path, headers, query)?;
if let Some(validator) = self.compiled_schemas.get(&endpoint.name) {
if let Some(body_content) = body {
let json_body: serde_json::Value = serde_json::from_str(body_content)
.map_err(|e| anyhow::anyhow!("Invalid JSON body: {e}"))?;
if let Err(e) = validator.validate(&json_body) {
anyhow::bail!("Body validation failed: {e}");
}
} else if endpoint.schema.is_some() {
anyhow::bail!("Body is required for validation but missing");
}
}
let path_params = self.matcher.extract_path_params(&endpoint.path, path);
let context = ExecutionContext {
method: method.to_string(),
path: path.to_string(),
query: query.to_string(),
headers: headers.clone(),
body: body.map(std::string::ToString::to_string),
client_ip: client_ip.to_string(),
path_params,
};
self.executor.execute(endpoint, &context).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Response;
#[test]
fn test_rule_engine_creation() {
let endpoints = vec![Endpoint {
name: "Test".to_string(),
method: "GET".to_string(),
path: "/test".to_string(),
stateful: false,
state_key: None,
responses: vec![Response {
status: 200,
delay: None,
body: Some("OK".to_string()),
headers: HashMap::new(),
condition: None,
probability: None,
default: true,
}],
schema: None,
schema_file: None,
path_regex: None,
headers_regex: None,
query_regex: None,
}];
let _engine = RuleEngine::new(&endpoints);
}
#[tokio::test]
async fn test_execute_no_endpoints() {
let engine = RuleEngine::new(&[]);
let result = engine
.execute("GET", "/test", "", &HashMap::new(), None, "127.0.0.1")
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_execute_schema_validation_success() {
let endpoints = vec![Endpoint {
name: "SchemaTest".to_string(),
method: "POST".to_string(),
path: "/api".to_string(),
stateful: false,
state_key: None,
schema: Some(serde_json::json!({
"type": "object",
"properties": {
"id": { "type": "number" }
},
"required": ["id"]
})),
schema_file: None,
responses: vec![Response {
status: 201,
delay: None,
body: Some("Created".to_string()),
headers: HashMap::new(),
condition: None,
probability: None,
default: true,
}],
path_regex: None,
headers_regex: None,
query_regex: None,
}];
let engine = RuleEngine::new(&endpoints);
let result = engine
.execute(
"POST",
"/api",
"",
&HashMap::new(),
Some(r#"{"id": 123}"#),
"127.0.0.1",
)
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap().status, 201);
}
#[tokio::test]
async fn test_execute_schema_validation_failure() {
let endpoints = vec![Endpoint {
name: "SchemaTest".to_string(),
method: "POST".to_string(),
path: "/api".to_string(),
stateful: false,
state_key: None,
schema: Some(serde_json::json!({
"type": "object",
"properties": {
"id": { "type": "number" }
},
"required": ["id"]
})),
schema_file: None,
responses: vec![Response {
status: 201,
delay: None,
body: Some("Created".to_string()),
headers: HashMap::new(),
condition: None,
probability: None,
default: true,
}],
path_regex: None,
headers_regex: None,
query_regex: None,
}];
let engine = RuleEngine::new(&endpoints);
let result = engine
.execute(
"POST",
"/api",
"",
&HashMap::new(),
Some(r#"{"id": "not-a-number"}"#),
"127.0.0.1",
)
.await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("validation failed"));
}
#[tokio::test]
async fn test_execute_with_regex_matching() {
let endpoint = Endpoint {
name: "RegexTest".to_string(),
method: "POST".to_string(),
path: "/api".to_string(),
stateful: false,
state_key: None,
schema: None,
schema_file: None,
path_regex: None,
headers_regex: Some({
let mut h = HashMap::new();
h.insert("X-Required".to_string(), "^secret$".to_string());
h
}),
query_regex: Some({
let mut q = HashMap::new();
q.insert("v".to_string(), "^1$".to_string());
q
}),
responses: vec![Response {
status: 200,
delay: None,
body: Some("Regex Match".to_string()),
headers: HashMap::new(),
condition: None,
probability: None,
default: true,
}],
};
let engine = RuleEngine::new(&[endpoint]);
let mut headers = HashMap::new();
headers.insert("X-Required".to_string(), "secret".to_string());
let result = engine
.execute("POST", "/api", "v=1", &headers, None, "127.0.0.1")
.await;
assert!(result.is_ok());
let mut bad_headers = HashMap::new();
bad_headers.insert("X-Required".to_string(), "wrong".to_string());
let result = engine
.execute("POST", "/api", "v=1", &bad_headers, None, "127.0.0.1")
.await;
assert!(result.is_err());
let result = engine
.execute("POST", "/api", "v=2", &headers, None, "127.0.0.1")
.await;
assert!(result.is_err());
}
}