use crate::{Error, RequestFingerprint, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::fs;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomFixture {
pub method: String,
pub path: String,
pub status: u16,
#[serde(default)]
pub response: Value,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub delay_ms: u64,
}
#[derive(Debug, Deserialize)]
pub struct NestedFixture {
pub request: Option<NestedRequest>,
pub response: Option<NestedResponse>,
}
#[derive(Debug, Deserialize)]
pub struct NestedRequest {
pub method: String,
pub path: String,
}
#[derive(Debug, Deserialize)]
pub struct NestedResponse {
pub status: u16,
#[serde(default)]
pub headers: HashMap<String, String>,
pub body: Value,
}
#[derive(Debug)]
enum LoadResult {
Loaded,
Skipped,
}
pub struct CustomFixtureLoader {
fixtures_dir: PathBuf,
enabled: bool,
fixtures: HashMap<String, HashMap<String, CustomFixture>>,
stats: LoadStats,
}
#[derive(Debug, Default)]
struct LoadStats {
loaded: usize,
failed: usize,
skipped: usize,
}
impl CustomFixtureLoader {
pub fn new(fixtures_dir: PathBuf, enabled: bool) -> Self {
Self {
fixtures_dir,
enabled,
fixtures: HashMap::new(),
stats: LoadStats::default(),
}
}
pub fn normalize_path(path: &str) -> String {
let mut normalized = path.trim().to_string();
if let Some(query_start) = normalized.find('?') {
normalized = normalized[..query_start].to_string();
}
while normalized.contains("//") {
normalized = normalized.replace("//", "/");
}
if normalized.len() > 1 && normalized.ends_with('/') {
normalized.pop();
}
if !normalized.starts_with('/') {
normalized = format!("/{}", normalized);
}
normalized
}
pub fn should_skip_file(content: &str) -> bool {
if content.contains("\"_comment\"") || content.contains("\"_usage\"") {
return true;
}
if content.contains("\"scenario\"") || content.contains("\"presentation_mode\"") {
return true;
}
false
}
pub fn convert_nested_to_flat(nested: NestedFixture) -> Result<CustomFixture> {
let request = nested
.request
.ok_or_else(|| Error::config("Nested fixture missing 'request' object"))?;
let response = nested
.response
.ok_or_else(|| Error::config("Nested fixture missing 'response' object"))?;
Ok(CustomFixture {
method: request.method,
path: Self::normalize_path(&request.path),
status: response.status,
response: response.body,
headers: response.headers,
delay_ms: 0,
})
}
pub fn validate_fixture(fixture: &CustomFixture, file_path: &Path) -> Result<()> {
if fixture.method.is_empty() {
return Err(Error::validation(format!(
"Invalid fixture in {}: method is required and cannot be empty",
file_path.display()
)));
}
if fixture.path.is_empty() {
return Err(Error::validation(format!(
"Invalid fixture in {}: path is required and cannot be empty",
file_path.display()
)));
}
let method_upper = fixture.method.to_uppercase();
let valid_methods = [
"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE",
];
if !valid_methods.contains(&method_upper.as_str()) {
tracing::warn!(
"Fixture {} uses non-standard HTTP method: {}",
file_path.display(),
fixture.method
);
}
if fixture.status < 100 || fixture.status >= 600 {
return Err(Error::validation(format!(
"Invalid fixture in {}: status code {} is not a valid HTTP status code (100-599)",
file_path.display(),
fixture.status
)));
}
Ok(())
}
pub async fn load_fixtures(&mut self) -> Result<()> {
if !self.enabled {
return Ok(());
}
if !self.fixtures_dir.exists() {
tracing::debug!(
"Custom fixtures directory does not exist: {}",
self.fixtures_dir.display()
);
return Ok(());
}
self.stats = LoadStats::default();
let mut entries = fs::read_dir(&self.fixtures_dir).await.map_err(|e| {
Error::io_with_context(
format!("reading fixtures directory {}", self.fixtures_dir.display()),
e.to_string(),
)
})?;
while let Some(entry) = entries
.next_entry()
.await
.map_err(|e| Error::io_with_context("reading directory entry", e.to_string()))?
{
let path = entry.path();
if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
match self.load_fixture_file(&path).await {
Ok(LoadResult::Loaded) => {
self.stats.loaded += 1;
}
Ok(LoadResult::Skipped) => {
self.stats.skipped += 1;
}
Err(e) => {
self.stats.failed += 1;
tracing::warn!("Failed to load fixture file {}: {}", path.display(), e);
}
}
}
}
tracing::info!(
"Fixture loading complete: {} loaded, {} failed, {} skipped from {}",
self.stats.loaded,
self.stats.failed,
self.stats.skipped,
self.fixtures_dir.display()
);
Ok(())
}
async fn load_fixture_file(&mut self, path: &Path) -> Result<LoadResult> {
let content = fs::read_to_string(path).await.map_err(|e| {
Error::io_with_context(
format!("reading fixture file {}", path.display()),
e.to_string(),
)
})?;
if Self::should_skip_file(&content) {
tracing::debug!("Skipping template file: {}", path.display());
return Ok(LoadResult::Skipped);
}
let fixture = match serde_json::from_str::<CustomFixture>(&content) {
Ok(mut fixture) => {
fixture.path = Self::normalize_path(&fixture.path);
fixture
}
Err(_) => {
let nested: NestedFixture = serde_json::from_str(&content).map_err(|e| {
Error::config(format!(
"Failed to parse fixture file {}: not a valid flat or nested format. Error: {}",
path.display(),
e
))
})?;
Self::convert_nested_to_flat(nested)?
}
};
Self::validate_fixture(&fixture, path)?;
let method = fixture.method.to_uppercase();
let fixtures_by_method = self.fixtures.entry(method.clone()).or_default();
if fixtures_by_method.contains_key(&fixture.path) {
tracing::warn!(
"Duplicate fixture path '{}' for method '{}' in file {} (overwriting previous)",
fixture.path,
method,
path.display()
);
}
fixtures_by_method.insert(fixture.path.clone(), fixture);
Ok(LoadResult::Loaded)
}
pub fn has_fixture(&self, fingerprint: &RequestFingerprint) -> bool {
if !self.enabled {
return false;
}
self.find_matching_fixture(fingerprint).is_some()
}
pub fn load_fixture(&self, fingerprint: &RequestFingerprint) -> Option<CustomFixture> {
if !self.enabled {
return None;
}
self.find_matching_fixture(fingerprint).cloned()
}
fn find_matching_fixture(&self, fingerprint: &RequestFingerprint) -> Option<&CustomFixture> {
let method = fingerprint.method.to_uppercase();
let fixtures_by_method = self.fixtures.get(&method)?;
let request_path = Self::normalize_path(&fingerprint.path);
tracing::debug!(
"Fixture matching: method={}, fingerprint.path='{}', normalized='{}', available fixtures: {:?}",
method,
fingerprint.path,
request_path,
fixtures_by_method.keys().collect::<Vec<_>>()
);
if let Some(fixture) = fixtures_by_method.get(&request_path) {
tracing::debug!("Found exact fixture match: {} {}", method, request_path);
return Some(fixture);
}
for (pattern, fixture) in fixtures_by_method.iter() {
if self.path_matches(pattern, &request_path) {
tracing::debug!(
"Found pattern fixture match: {} {} (pattern: {})",
method,
request_path,
pattern
);
return Some(fixture);
}
}
tracing::debug!("No fixture match found for: {} {}", method, request_path);
None
}
fn path_matches(&self, pattern: &str, request_path: &str) -> bool {
let normalized_pattern = Self::normalize_path(pattern);
let normalized_request = Self::normalize_path(request_path);
let pattern_segments: Vec<&str> =
normalized_pattern.split('/').filter(|s| !s.is_empty()).collect();
let request_segments: Vec<&str> =
normalized_request.split('/').filter(|s| !s.is_empty()).collect();
if pattern_segments.len() != request_segments.len() {
return false;
}
for (pattern_seg, request_seg) in pattern_segments.iter().zip(request_segments.iter()) {
if pattern_seg.starts_with('{') && pattern_seg.ends_with('}') {
continue;
}
if pattern_seg != request_seg {
return false;
}
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{HeaderMap, Method, Uri};
use tempfile::TempDir;
fn create_test_fingerprint(method: &str, path: &str) -> RequestFingerprint {
let method = Method::from_bytes(method.as_bytes()).unwrap();
let uri: Uri = path.parse().unwrap();
RequestFingerprint::new(method, &uri, &HeaderMap::new(), None)
}
#[test]
fn test_path_matching_exact() {
let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
assert!(loader.path_matches("/api/v1/apiaries", "/api/v1/apiaries"));
assert!(!loader.path_matches("/api/v1/apiaries", "/api/v1/hives"));
}
#[test]
fn test_path_matching_with_parameters() {
let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001"));
assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/123"));
assert!(
!loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001/inspections")
);
}
#[test]
fn test_path_matching_multiple_parameters() {
let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
assert!(loader.path_matches(
"/api/v1/apiaries/{apiaryId}/hives/{hiveId}",
"/api/v1/apiaries/apiary_001/hives/hive_001"
));
}
#[tokio::test]
async fn test_load_fixture() {
let temp_dir = TempDir::new().unwrap();
let fixtures_dir = temp_dir.path().to_path_buf();
let fixture_content = r#"{
"method": "GET",
"path": "/api/v1/apiaries",
"status": 200,
"response": {
"success": true,
"data": []
}
}"#;
let fixture_file = fixtures_dir.join("apiaries-list.json");
fs::write(&fixture_file, fixture_content).await.unwrap();
let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
loader.load_fixtures().await.unwrap();
let fingerprint = create_test_fingerprint("GET", "/api/v1/apiaries");
assert!(loader.has_fixture(&fingerprint));
let fixture = loader.load_fixture(&fingerprint).unwrap();
assert_eq!(fixture.method, "GET");
assert_eq!(fixture.path, "/api/v1/apiaries");
assert_eq!(fixture.status, 200);
}
#[tokio::test]
async fn test_load_fixture_with_path_parameter() {
let temp_dir = TempDir::new().unwrap();
let fixtures_dir = temp_dir.path().to_path_buf();
let fixture_content = r#"{
"method": "GET",
"path": "/api/v1/hives/{hiveId}",
"status": 200,
"response": {
"success": true,
"data": {
"id": "hive_001"
}
}
}"#;
let fixture_file = fixtures_dir.join("hive-detail.json");
fs::write(&fixture_file, fixture_content).await.unwrap();
let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
loader.load_fixtures().await.unwrap();
let fingerprint = create_test_fingerprint("GET", "/api/v1/hives/hive_001");
assert!(loader.has_fixture(&fingerprint));
let fixture = loader.load_fixture(&fingerprint).unwrap();
assert_eq!(fixture.path, "/api/v1/hives/{hiveId}");
}
#[tokio::test]
async fn test_load_multiple_fixtures() {
let temp_dir = TempDir::new().unwrap();
let fixtures_dir = temp_dir.path().to_path_buf();
let fixtures = vec![
(
"apiaries-list.json",
r#"{
"method": "GET",
"path": "/api/v1/apiaries",
"status": 200,
"response": {"items": []}
}"#,
),
(
"hive-detail.json",
r#"{
"method": "GET",
"path": "/api/v1/hives/{hiveId}",
"status": 200,
"response": {"id": "hive_001"}
}"#,
),
(
"user-profile.json",
r#"{
"method": "GET",
"path": "/api/v1/users/me",
"status": 200,
"response": {"id": "user_001"}
}"#,
),
];
for (filename, content) in fixtures {
let fixture_file = fixtures_dir.join(filename);
fs::write(&fixture_file, content).await.unwrap();
}
let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
loader.load_fixtures().await.unwrap();
assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/apiaries")));
assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/hives/hive_001")));
assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/users/me")));
}
#[tokio::test]
async fn test_fixture_with_headers() {
let temp_dir = TempDir::new().unwrap();
let fixtures_dir = temp_dir.path().to_path_buf();
let fixture_content = r#"{
"method": "GET",
"path": "/api/v1/test",
"status": 201,
"response": {"result": "ok"},
"headers": {
"content-type": "application/json",
"x-custom-header": "test-value"
}
}"#;
let fixture_file = fixtures_dir.join("test.json");
fs::write(&fixture_file, fixture_content).await.unwrap();
let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
loader.load_fixtures().await.unwrap();
let fixture = loader.load_fixture(&create_test_fingerprint("GET", "/api/v1/test")).unwrap();
assert_eq!(fixture.status, 201);
assert_eq!(fixture.headers.get("content-type"), Some(&"application/json".to_string()));
assert_eq!(fixture.headers.get("x-custom-header"), Some(&"test-value".to_string()));
}
#[tokio::test]
async fn test_fixture_disabled() {
let temp_dir = TempDir::new().unwrap();
let fixtures_dir = temp_dir.path().to_path_buf();
let fixture_file = fixtures_dir.join("test.json");
fs::write(
&fixture_file,
r#"{"method": "GET", "path": "/test", "status": 200, "response": {}}"#,
)
.await
.unwrap();
let mut loader = CustomFixtureLoader::new(fixtures_dir, false);
loader.load_fixtures().await.unwrap();
assert!(!loader.has_fixture(&create_test_fingerprint("GET", "/test")));
assert!(loader.load_fixture(&create_test_fingerprint("GET", "/test")).is_none());
}
#[tokio::test]
async fn test_load_nested_format() {
let temp_dir = TempDir::new().unwrap();
let fixtures_dir = temp_dir.path().to_path_buf();
let fixture_content = r#"{
"request": {
"method": "POST",
"path": "/api/auth/login"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": {
"access_token": "test_token",
"user": {
"id": "user_001"
}
}
}
}"#;
let fixture_file = fixtures_dir.join("auth-login.json");
fs::write(&fixture_file, fixture_content).await.unwrap();
let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
loader.load_fixtures().await.unwrap();
let fingerprint = create_test_fingerprint("POST", "/api/auth/login");
assert!(loader.has_fixture(&fingerprint));
let fixture = loader.load_fixture(&fingerprint).unwrap();
assert_eq!(fixture.method, "POST");
assert_eq!(fixture.path, "/api/auth/login");
assert_eq!(fixture.status, 200);
assert_eq!(fixture.headers.get("Content-Type"), Some(&"application/json".to_string()));
assert!(fixture.response.get("access_token").is_some());
}
#[test]
fn test_path_normalization() {
let _loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
assert_eq!(CustomFixtureLoader::normalize_path("/api/v1/test/"), "/api/v1/test");
assert_eq!(CustomFixtureLoader::normalize_path("/api/v1/test"), "/api/v1/test");
assert_eq!(CustomFixtureLoader::normalize_path("/"), "/");
assert_eq!(CustomFixtureLoader::normalize_path("/api//v1///test"), "/api/v1/test");
assert_eq!(CustomFixtureLoader::normalize_path("api/v1/test"), "/api/v1/test");
assert_eq!(CustomFixtureLoader::normalize_path(" /api/v1/test "), "/api/v1/test");
}
#[test]
fn test_path_matching_with_normalization() {
let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
assert!(loader.path_matches("/api/v1/test", "/api/v1/test/"));
assert!(loader.path_matches("/api/v1/test/", "/api/v1/test"));
assert!(loader.path_matches("/api/v1/test", "/api//v1///test"));
assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001/"));
}
#[tokio::test]
async fn test_skip_template_files() {
let temp_dir = TempDir::new().unwrap();
let fixtures_dir = temp_dir.path().to_path_buf();
let template_content = r#"{
"_comment": "This is a template",
"_usage": "Use this for errors",
"error": {
"code": "ERROR_CODE"
}
}"#;
let template_file = fixtures_dir.join("error-template.json");
fs::write(&template_file, template_content).await.unwrap();
let valid_fixture = r#"{
"method": "GET",
"path": "/api/test",
"status": 200,
"response": {}
}"#;
let valid_file = fixtures_dir.join("valid.json");
fs::write(&valid_file, valid_fixture).await.unwrap();
let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
loader.load_fixtures().await.unwrap();
assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/test")));
}
#[tokio::test]
async fn test_skip_scenario_files() {
let temp_dir = TempDir::new().unwrap();
let fixtures_dir = temp_dir.path().to_path_buf();
let scenario_content = r#"{
"scenario": "demo",
"presentation_mode": true,
"apiaries": []
}"#;
let scenario_file = fixtures_dir.join("demo-scenario.json");
fs::write(&scenario_file, scenario_content).await.unwrap();
let valid_fixture = r#"{
"method": "GET",
"path": "/api/test",
"status": 200,
"response": {}
}"#;
let valid_file = fixtures_dir.join("valid.json");
fs::write(&valid_file, valid_fixture).await.unwrap();
let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
loader.load_fixtures().await.unwrap();
assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/test")));
}
#[tokio::test]
async fn test_mixed_format_fixtures() {
let temp_dir = TempDir::new().unwrap();
let fixtures_dir = temp_dir.path().to_path_buf();
let flat_fixture = r#"{
"method": "GET",
"path": "/api/v1/flat",
"status": 200,
"response": {"type": "flat"}
}"#;
let nested_fixture = r#"{
"request": {
"method": "GET",
"path": "/api/v1/nested"
},
"response": {
"status": 200,
"body": {"type": "nested"}
}
}"#;
fs::write(fixtures_dir.join("flat.json"), flat_fixture).await.unwrap();
fs::write(fixtures_dir.join("nested.json"), nested_fixture).await.unwrap();
let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
loader.load_fixtures().await.unwrap();
assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/flat")));
assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/nested")));
let flat = loader.load_fixture(&create_test_fingerprint("GET", "/api/v1/flat")).unwrap();
assert_eq!(flat.response.get("type").and_then(|v| v.as_str()), Some("flat"));
let nested =
loader.load_fixture(&create_test_fingerprint("GET", "/api/v1/nested")).unwrap();
assert_eq!(nested.response.get("type").and_then(|v| v.as_str()), Some("nested"));
}
#[tokio::test]
async fn test_validation_errors() {
let temp_dir = TempDir::new().unwrap();
let fixtures_dir = temp_dir.path().to_path_buf();
let no_method = r#"{
"path": "/api/test",
"status": 200,
"response": {}
}"#;
fs::write(fixtures_dir.join("no-method.json"), no_method).await.unwrap();
let invalid_status = r#"{
"method": "GET",
"path": "/api/test",
"status": 999,
"response": {}
}"#;
fs::write(fixtures_dir.join("invalid-status.json"), invalid_status)
.await
.unwrap();
let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
let result = loader.load_fixtures().await;
assert!(result.is_ok());
}
}