1use axum::{
9 extract::{Path, Query, State},
10 http::StatusCode,
11 response::Json,
12 routing::{get, post},
13 Router,
14};
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::sync::Arc;
18
19use crate::management::{ManagementState, MockConfig};
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct MockSnapshot {
24 pub id: String,
26 pub timestamp: i64,
28 pub environment_id: Option<String>,
30 pub persona_id: Option<String>,
32 pub scenario_id: Option<String>,
34 pub reality_level: Option<f64>,
36 pub mocks: Vec<MockSnapshotItem>,
38 pub metadata: HashMap<String, serde_json::Value>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct MockSnapshotItem {
45 pub id: String,
47 pub method: String,
49 pub path: String,
51 pub status_code: u16,
53 pub response_body: serde_json::Value,
55 pub response_headers: Option<HashMap<String, String>>,
57 pub config: serde_json::Value,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct SnapshotDiff {
64 pub left: MockSnapshot,
66 pub right: MockSnapshot,
68 pub differences: Vec<Difference>,
70 pub summary: DiffSummary,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct Difference {
77 pub diff_type: DifferenceType,
79 pub mock_id: Option<String>,
81 pub path: String,
83 pub method: String,
85 pub description: String,
87 pub left_value: Option<serde_json::Value>,
89 pub right_value: Option<serde_json::Value>,
91 pub field_path: Option<String>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum DifferenceType {
99 MissingInRight,
101 MissingInLeft,
103 StatusCodeMismatch,
105 BodyMismatch,
107 HeadersMismatch,
109 ConfigMismatch,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct DiffSummary {
116 pub left_total: usize,
118 pub right_total: usize,
120 pub differences_count: usize,
122 pub only_in_left: usize,
124 pub only_in_right: usize,
126 pub mocks_with_differences: usize,
128}
129
130#[derive(Debug, Deserialize)]
132pub struct CreateSnapshotRequest {
133 pub environment_id: Option<String>,
135 pub persona_id: Option<String>,
137 pub scenario_id: Option<String>,
139 pub reality_level: Option<f64>,
141 #[serde(default)]
143 pub metadata: HashMap<String, serde_json::Value>,
144}
145
146#[derive(Debug, Deserialize)]
148pub struct CompareSnapshotsRequest {
149 pub left_snapshot_id: Option<String>,
151 pub right_snapshot_id: Option<String>,
153 pub left_environment_id: Option<String>,
155 pub right_environment_id: Option<String>,
157 pub left_persona_id: Option<String>,
159 pub right_persona_id: Option<String>,
161 pub left_scenario_id: Option<String>,
163 pub right_scenario_id: Option<String>,
165 pub left_reality_level: Option<f64>,
167 pub right_reality_level: Option<f64>,
169}
170
171type SnapshotStorage = Arc<tokio::sync::RwLock<HashMap<String, MockSnapshot>>>;
173
174fn snapshot_store() -> &'static SnapshotStorage {
176 static STORE: std::sync::OnceLock<SnapshotStorage> = std::sync::OnceLock::new();
177 STORE.get_or_init(|| Arc::new(tokio::sync::RwLock::new(HashMap::new())))
178}
179
180async fn create_snapshot(
182 State(state): State<ManagementState>,
183 Json(request): Json<CreateSnapshotRequest>,
184) -> Result<Json<MockSnapshot>, StatusCode> {
185 let mocks = state.mocks.read().await.clone();
187
188 let snapshot_items: Vec<MockSnapshotItem> = mocks
190 .iter()
191 .map(|mock| MockSnapshotItem {
192 id: mock.id.clone(),
193 method: mock.method.clone(),
194 path: mock.path.clone(),
195 status_code: mock.status_code.unwrap_or(200),
196 response_body: mock.response.body.clone(),
197 response_headers: mock.response.headers.clone(),
198 config: serde_json::to_value(mock).unwrap_or_default(),
199 })
200 .collect();
201
202 let snapshot = MockSnapshot {
204 id: uuid::Uuid::new_v4().to_string(),
205 timestamp: chrono::Utc::now().timestamp(),
206 environment_id: request.environment_id,
207 persona_id: request.persona_id,
208 scenario_id: request.scenario_id,
209 reality_level: request.reality_level,
210 mocks: snapshot_items,
211 metadata: request.metadata,
212 };
213
214 let mut store = snapshot_store().write().await;
216 store.insert(snapshot.id.clone(), snapshot.clone());
217
218 Ok(Json(snapshot))
219}
220
221async fn get_snapshot(
223 Path(snapshot_id): Path<String>,
224 State(_state): State<ManagementState>,
225) -> Result<Json<MockSnapshot>, StatusCode> {
226 let store = snapshot_store().read().await;
227 store.get(&snapshot_id).cloned().map(Json).ok_or(StatusCode::NOT_FOUND)
228}
229
230async fn list_snapshots(
232 Query(params): Query<HashMap<String, String>>,
233 State(_state): State<ManagementState>,
234) -> Result<Json<Vec<MockSnapshot>>, StatusCode> {
235 let store = snapshot_store().read().await;
236 let mut snapshots: Vec<MockSnapshot> = store
237 .values()
238 .filter(|s| {
239 if let Some(env_id) = params.get("environment_id") {
241 if s.environment_id.as_deref() != Some(env_id.as_str()) {
242 return false;
243 }
244 }
245 if let Some(persona_id) = params.get("persona_id") {
247 if s.persona_id.as_deref() != Some(persona_id.as_str()) {
248 return false;
249 }
250 }
251 if let Some(scenario_id) = params.get("scenario_id") {
253 if s.scenario_id.as_deref() != Some(scenario_id.as_str()) {
254 return false;
255 }
256 }
257 true
258 })
259 .cloned()
260 .collect();
261
262 snapshots.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
264
265 Ok(Json(snapshots))
266}
267
268async fn compare_snapshots(
270 State(state): State<ManagementState>,
271 Json(request): Json<CompareSnapshotsRequest>,
272) -> Result<Json<SnapshotDiff>, StatusCode> {
273 let store = snapshot_store().read().await;
274
275 let left_snapshot = if let Some(ref id) = request.left_snapshot_id {
277 store.get(id).cloned().ok_or(StatusCode::NOT_FOUND)?
278 } else {
279 let current_mocks = state.mocks.read().await.clone();
280 create_snapshot_from_mocks(
281 ¤t_mocks,
282 request.left_environment_id.clone(),
283 request.left_persona_id.clone(),
284 request.left_scenario_id.clone(),
285 request.left_reality_level,
286 )
287 };
288
289 let right_snapshot = if let Some(ref id) = request.right_snapshot_id {
291 store.get(id).cloned().ok_or(StatusCode::NOT_FOUND)?
292 } else {
293 let current_mocks = state.mocks.read().await.clone();
294 create_snapshot_from_mocks(
295 ¤t_mocks,
296 request.right_environment_id.clone(),
297 request.right_persona_id.clone(),
298 request.right_scenario_id.clone(),
299 request.right_reality_level,
300 )
301 };
302
303 let diff = compare_snapshot_objects(&left_snapshot, &right_snapshot);
305
306 Ok(Json(diff))
307}
308
309fn create_snapshot_from_mocks(
311 mocks: &[MockConfig],
312 environment_id: Option<String>,
313 persona_id: Option<String>,
314 scenario_id: Option<String>,
315 reality_level: Option<f64>,
316) -> MockSnapshot {
317 let snapshot_items: Vec<MockSnapshotItem> = mocks
318 .iter()
319 .map(|mock| MockSnapshotItem {
320 id: mock.id.clone(),
321 method: mock.method.clone(),
322 path: mock.path.clone(),
323 status_code: mock.status_code.unwrap_or(200),
324 response_body: mock.response.body.clone(),
325 response_headers: mock.response.headers.clone(),
326 config: serde_json::to_value(mock).unwrap_or_default(),
327 })
328 .collect();
329
330 MockSnapshot {
331 id: uuid::Uuid::new_v4().to_string(),
332 timestamp: chrono::Utc::now().timestamp(),
333 environment_id,
334 persona_id,
335 scenario_id,
336 reality_level,
337 mocks: snapshot_items,
338 metadata: HashMap::new(),
339 }
340}
341
342fn compare_snapshot_objects(left: &MockSnapshot, right: &MockSnapshot) -> SnapshotDiff {
344 let mut differences = Vec::new();
345
346 let left_map: HashMap<String, &MockSnapshotItem> =
348 left.mocks.iter().map(|m| (format!("{}:{}", m.method, m.path), m)).collect();
349
350 let right_map: HashMap<String, &MockSnapshotItem> =
351 right.mocks.iter().map(|m| (format!("{}:{}", m.method, m.path), m)).collect();
352
353 for (key, left_mock) in &left_map {
355 if !right_map.contains_key(key) {
356 differences.push(Difference {
357 diff_type: DifferenceType::MissingInRight,
358 mock_id: Some(left_mock.id.clone()),
359 path: left_mock.path.clone(),
360 method: left_mock.method.clone(),
361 description: format!(
362 "Mock {} {} exists in left but not in right",
363 left_mock.method, left_mock.path
364 ),
365 left_value: Some(serde_json::to_value(left_mock).unwrap_or_default()),
366 right_value: None,
367 field_path: None,
368 });
369 }
370 }
371
372 for (key, right_mock) in &right_map {
374 if !left_map.contains_key(key) {
375 differences.push(Difference {
376 diff_type: DifferenceType::MissingInLeft,
377 mock_id: Some(right_mock.id.clone()),
378 path: right_mock.path.clone(),
379 method: right_mock.method.clone(),
380 description: format!(
381 "Mock {} {} exists in right but not in left",
382 right_mock.method, right_mock.path
383 ),
384 left_value: None,
385 right_value: Some(serde_json::to_value(right_mock).unwrap_or_default()),
386 field_path: None,
387 });
388 }
389 }
390
391 for (key, left_mock) in &left_map {
393 if let Some(right_mock) = right_map.get(key) {
394 if left_mock.status_code != right_mock.status_code {
396 differences.push(Difference {
397 diff_type: DifferenceType::StatusCodeMismatch,
398 mock_id: Some(left_mock.id.clone()),
399 path: left_mock.path.clone(),
400 method: left_mock.method.clone(),
401 description: format!(
402 "Status code differs: {} vs {}",
403 left_mock.status_code, right_mock.status_code
404 ),
405 left_value: Some(serde_json::json!(left_mock.status_code)),
406 right_value: Some(serde_json::json!(right_mock.status_code)),
407 field_path: Some("status_code".to_string()),
408 });
409 }
410
411 if left_mock.response_body != right_mock.response_body {
413 differences.push(Difference {
414 diff_type: DifferenceType::BodyMismatch,
415 mock_id: Some(left_mock.id.clone()),
416 path: left_mock.path.clone(),
417 method: left_mock.method.clone(),
418 description: format!(
419 "Response body differs for {} {}",
420 left_mock.method, left_mock.path
421 ),
422 left_value: Some(left_mock.response_body.clone()),
423 right_value: Some(right_mock.response_body.clone()),
424 field_path: Some("response_body".to_string()),
425 });
426 }
427
428 if left_mock.response_headers != right_mock.response_headers {
430 differences.push(Difference {
431 diff_type: DifferenceType::HeadersMismatch,
432 mock_id: Some(left_mock.id.clone()),
433 path: left_mock.path.clone(),
434 method: left_mock.method.clone(),
435 description: format!(
436 "Response headers differ for {} {}",
437 left_mock.method, left_mock.path
438 ),
439 left_value: left_mock
440 .response_headers
441 .as_ref()
442 .map(|h| serde_json::to_value(h).unwrap_or_default()),
443 right_value: right_mock
444 .response_headers
445 .as_ref()
446 .map(|h| serde_json::to_value(h).unwrap_or_default()),
447 field_path: Some("response_headers".to_string()),
448 });
449 }
450 }
451 }
452
453 let only_in_left = differences
455 .iter()
456 .filter(|d| matches!(d.diff_type, DifferenceType::MissingInRight))
457 .count();
458 let only_in_right = differences
459 .iter()
460 .filter(|d| matches!(d.diff_type, DifferenceType::MissingInLeft))
461 .count();
462 let mocks_with_differences: std::collections::HashSet<String> =
463 differences.iter().filter_map(|d| d.mock_id.clone()).collect();
464
465 let summary = DiffSummary {
466 left_total: left.mocks.len(),
467 right_total: right.mocks.len(),
468 differences_count: differences.len(),
469 only_in_left,
470 only_in_right,
471 mocks_with_differences: mocks_with_differences.len(),
472 };
473
474 SnapshotDiff {
475 left: left.clone(),
476 right: right.clone(),
477 differences,
478 summary,
479 }
480}
481
482pub fn snapshot_diff_router(state: ManagementState) -> Router<ManagementState> {
484 Router::new()
485 .route("/snapshots", post(create_snapshot))
486 .route("/snapshots", get(list_snapshots))
487 .route("/snapshots/{id}", get(get_snapshot))
488 .route("/snapshots/compare", post(compare_snapshots))
489 .with_state(state)
490}