use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use crate::management::{ManagementState, MockConfig};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MockSnapshot {
pub id: String,
pub timestamp: i64,
pub environment_id: Option<String>,
pub persona_id: Option<String>,
pub scenario_id: Option<String>,
pub reality_level: Option<f64>,
pub mocks: Vec<MockSnapshotItem>,
pub metadata: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MockSnapshotItem {
pub id: String,
pub method: String,
pub path: String,
pub status_code: u16,
pub response_body: serde_json::Value,
pub response_headers: Option<HashMap<String, String>>,
pub config: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotDiff {
pub left: MockSnapshot,
pub right: MockSnapshot,
pub differences: Vec<Difference>,
pub summary: DiffSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Difference {
pub diff_type: DifferenceType,
pub mock_id: Option<String>,
pub path: String,
pub method: String,
pub description: String,
pub left_value: Option<serde_json::Value>,
pub right_value: Option<serde_json::Value>,
pub field_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DifferenceType {
MissingInRight,
MissingInLeft,
StatusCodeMismatch,
BodyMismatch,
HeadersMismatch,
ConfigMismatch,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffSummary {
pub left_total: usize,
pub right_total: usize,
pub differences_count: usize,
pub only_in_left: usize,
pub only_in_right: usize,
pub mocks_with_differences: usize,
}
#[derive(Debug, Deserialize)]
pub struct CreateSnapshotRequest {
pub environment_id: Option<String>,
pub persona_id: Option<String>,
pub scenario_id: Option<String>,
pub reality_level: Option<f64>,
#[serde(default)]
pub metadata: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub struct CompareSnapshotsRequest {
pub left_snapshot_id: Option<String>,
pub right_snapshot_id: Option<String>,
pub left_environment_id: Option<String>,
pub right_environment_id: Option<String>,
pub left_persona_id: Option<String>,
pub right_persona_id: Option<String>,
pub left_scenario_id: Option<String>,
pub right_scenario_id: Option<String>,
pub left_reality_level: Option<f64>,
pub right_reality_level: Option<f64>,
}
type SnapshotStorage = Arc<tokio::sync::RwLock<HashMap<String, MockSnapshot>>>;
fn snapshot_store() -> &'static SnapshotStorage {
static STORE: std::sync::OnceLock<SnapshotStorage> = std::sync::OnceLock::new();
STORE.get_or_init(|| Arc::new(tokio::sync::RwLock::new(HashMap::new())))
}
async fn create_snapshot(
State(state): State<ManagementState>,
Json(request): Json<CreateSnapshotRequest>,
) -> Result<Json<MockSnapshot>, StatusCode> {
let mocks = state.mocks.read().await.clone();
let snapshot_items: Vec<MockSnapshotItem> = mocks
.iter()
.map(|mock| MockSnapshotItem {
id: mock.id.clone(),
method: mock.method.clone(),
path: mock.path.clone(),
status_code: mock.status_code.unwrap_or(200),
response_body: mock.response.body.clone(),
response_headers: mock.response.headers.clone(),
config: serde_json::to_value(mock).unwrap_or_default(),
})
.collect();
let snapshot = MockSnapshot {
id: uuid::Uuid::new_v4().to_string(),
timestamp: chrono::Utc::now().timestamp(),
environment_id: request.environment_id,
persona_id: request.persona_id,
scenario_id: request.scenario_id,
reality_level: request.reality_level,
mocks: snapshot_items,
metadata: request.metadata,
};
let mut store = snapshot_store().write().await;
store.insert(snapshot.id.clone(), snapshot.clone());
Ok(Json(snapshot))
}
async fn get_snapshot(
Path(snapshot_id): Path<String>,
State(_state): State<ManagementState>,
) -> Result<Json<MockSnapshot>, StatusCode> {
let store = snapshot_store().read().await;
store.get(&snapshot_id).cloned().map(Json).ok_or(StatusCode::NOT_FOUND)
}
async fn list_snapshots(
Query(params): Query<HashMap<String, String>>,
State(_state): State<ManagementState>,
) -> Result<Json<Vec<MockSnapshot>>, StatusCode> {
let store = snapshot_store().read().await;
let mut snapshots: Vec<MockSnapshot> = store
.values()
.filter(|s| {
if let Some(env_id) = params.get("environment_id") {
if s.environment_id.as_deref() != Some(env_id.as_str()) {
return false;
}
}
if let Some(persona_id) = params.get("persona_id") {
if s.persona_id.as_deref() != Some(persona_id.as_str()) {
return false;
}
}
if let Some(scenario_id) = params.get("scenario_id") {
if s.scenario_id.as_deref() != Some(scenario_id.as_str()) {
return false;
}
}
true
})
.cloned()
.collect();
snapshots.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(Json(snapshots))
}
async fn compare_snapshots(
State(state): State<ManagementState>,
Json(request): Json<CompareSnapshotsRequest>,
) -> Result<Json<SnapshotDiff>, StatusCode> {
let store = snapshot_store().read().await;
let left_snapshot = if let Some(ref id) = request.left_snapshot_id {
store.get(id).cloned().ok_or(StatusCode::NOT_FOUND)?
} else {
let current_mocks = state.mocks.read().await.clone();
create_snapshot_from_mocks(
¤t_mocks,
request.left_environment_id.clone(),
request.left_persona_id.clone(),
request.left_scenario_id.clone(),
request.left_reality_level,
)
};
let right_snapshot = if let Some(ref id) = request.right_snapshot_id {
store.get(id).cloned().ok_or(StatusCode::NOT_FOUND)?
} else {
let current_mocks = state.mocks.read().await.clone();
create_snapshot_from_mocks(
¤t_mocks,
request.right_environment_id.clone(),
request.right_persona_id.clone(),
request.right_scenario_id.clone(),
request.right_reality_level,
)
};
let diff = compare_snapshot_objects(&left_snapshot, &right_snapshot);
Ok(Json(diff))
}
fn create_snapshot_from_mocks(
mocks: &[MockConfig],
environment_id: Option<String>,
persona_id: Option<String>,
scenario_id: Option<String>,
reality_level: Option<f64>,
) -> MockSnapshot {
let snapshot_items: Vec<MockSnapshotItem> = mocks
.iter()
.map(|mock| MockSnapshotItem {
id: mock.id.clone(),
method: mock.method.clone(),
path: mock.path.clone(),
status_code: mock.status_code.unwrap_or(200),
response_body: mock.response.body.clone(),
response_headers: mock.response.headers.clone(),
config: serde_json::to_value(mock).unwrap_or_default(),
})
.collect();
MockSnapshot {
id: uuid::Uuid::new_v4().to_string(),
timestamp: chrono::Utc::now().timestamp(),
environment_id,
persona_id,
scenario_id,
reality_level,
mocks: snapshot_items,
metadata: HashMap::new(),
}
}
fn compare_snapshot_objects(left: &MockSnapshot, right: &MockSnapshot) -> SnapshotDiff {
let mut differences = Vec::new();
let left_map: HashMap<String, &MockSnapshotItem> =
left.mocks.iter().map(|m| (format!("{}:{}", m.method, m.path), m)).collect();
let right_map: HashMap<String, &MockSnapshotItem> =
right.mocks.iter().map(|m| (format!("{}:{}", m.method, m.path), m)).collect();
for (key, left_mock) in &left_map {
if !right_map.contains_key(key) {
differences.push(Difference {
diff_type: DifferenceType::MissingInRight,
mock_id: Some(left_mock.id.clone()),
path: left_mock.path.clone(),
method: left_mock.method.clone(),
description: format!(
"Mock {} {} exists in left but not in right",
left_mock.method, left_mock.path
),
left_value: Some(serde_json::to_value(left_mock).unwrap_or_default()),
right_value: None,
field_path: None,
});
}
}
for (key, right_mock) in &right_map {
if !left_map.contains_key(key) {
differences.push(Difference {
diff_type: DifferenceType::MissingInLeft,
mock_id: Some(right_mock.id.clone()),
path: right_mock.path.clone(),
method: right_mock.method.clone(),
description: format!(
"Mock {} {} exists in right but not in left",
right_mock.method, right_mock.path
),
left_value: None,
right_value: Some(serde_json::to_value(right_mock).unwrap_or_default()),
field_path: None,
});
}
}
for (key, left_mock) in &left_map {
if let Some(right_mock) = right_map.get(key) {
if left_mock.status_code != right_mock.status_code {
differences.push(Difference {
diff_type: DifferenceType::StatusCodeMismatch,
mock_id: Some(left_mock.id.clone()),
path: left_mock.path.clone(),
method: left_mock.method.clone(),
description: format!(
"Status code differs: {} vs {}",
left_mock.status_code, right_mock.status_code
),
left_value: Some(serde_json::json!(left_mock.status_code)),
right_value: Some(serde_json::json!(right_mock.status_code)),
field_path: Some("status_code".to_string()),
});
}
if left_mock.response_body != right_mock.response_body {
differences.push(Difference {
diff_type: DifferenceType::BodyMismatch,
mock_id: Some(left_mock.id.clone()),
path: left_mock.path.clone(),
method: left_mock.method.clone(),
description: format!(
"Response body differs for {} {}",
left_mock.method, left_mock.path
),
left_value: Some(left_mock.response_body.clone()),
right_value: Some(right_mock.response_body.clone()),
field_path: Some("response_body".to_string()),
});
}
if left_mock.response_headers != right_mock.response_headers {
differences.push(Difference {
diff_type: DifferenceType::HeadersMismatch,
mock_id: Some(left_mock.id.clone()),
path: left_mock.path.clone(),
method: left_mock.method.clone(),
description: format!(
"Response headers differ for {} {}",
left_mock.method, left_mock.path
),
left_value: left_mock
.response_headers
.as_ref()
.map(|h| serde_json::to_value(h).unwrap_or_default()),
right_value: right_mock
.response_headers
.as_ref()
.map(|h| serde_json::to_value(h).unwrap_or_default()),
field_path: Some("response_headers".to_string()),
});
}
}
}
let only_in_left = differences
.iter()
.filter(|d| matches!(d.diff_type, DifferenceType::MissingInRight))
.count();
let only_in_right = differences
.iter()
.filter(|d| matches!(d.diff_type, DifferenceType::MissingInLeft))
.count();
let mocks_with_differences: std::collections::HashSet<String> =
differences.iter().filter_map(|d| d.mock_id.clone()).collect();
let summary = DiffSummary {
left_total: left.mocks.len(),
right_total: right.mocks.len(),
differences_count: differences.len(),
only_in_left,
only_in_right,
mocks_with_differences: mocks_with_differences.len(),
};
SnapshotDiff {
left: left.clone(),
right: right.clone(),
differences,
summary,
}
}
pub fn snapshot_diff_router(state: ManagementState) -> Router<ManagementState> {
Router::new()
.route("/snapshots", post(create_snapshot))
.route("/snapshots", get(list_snapshots))
.route("/snapshots/{id}", get(get_snapshot))
.route("/snapshots/compare", post(compare_snapshots))
.with_state(state)
}