Skip to main content

mockforge_http/handlers/
snapshot_diff.rs

1//! Snapshot Diff API Handlers
2//!
3//! Provides endpoints for comparing mock server snapshots across different
4//! environments, personas, scenarios, or "realities" (reality continuum levels).
5//!
6//! This enables side-by-side visualization for demos and debugging.
7
8use 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/// Snapshot of mock server state at a point in time
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct MockSnapshot {
24    /// Snapshot ID
25    pub id: String,
26    /// Timestamp when snapshot was taken
27    pub timestamp: i64,
28    /// Environment ID (if applicable)
29    pub environment_id: Option<String>,
30    /// Persona ID (if applicable)
31    pub persona_id: Option<String>,
32    /// Scenario ID (if applicable)
33    pub scenario_id: Option<String>,
34    /// Reality level (0.0-1.0, if applicable)
35    pub reality_level: Option<f64>,
36    /// All mocks in this snapshot
37    pub mocks: Vec<MockSnapshotItem>,
38    /// Metadata about the snapshot
39    pub metadata: HashMap<String, serde_json::Value>,
40}
41
42/// Individual mock item in a snapshot
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct MockSnapshotItem {
45    /// Mock ID
46    pub id: String,
47    /// HTTP method
48    pub method: String,
49    /// Path pattern
50    pub path: String,
51    /// Response status code
52    pub status_code: u16,
53    /// Response body
54    pub response_body: serde_json::Value,
55    /// Response headers
56    pub response_headers: Option<HashMap<String, String>>,
57    /// Mock configuration
58    pub config: serde_json::Value,
59}
60
61/// Comparison between two snapshots
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct SnapshotDiff {
64    /// Left snapshot (baseline)
65    pub left: MockSnapshot,
66    /// Right snapshot (comparison)
67    pub right: MockSnapshot,
68    /// Differences found
69    pub differences: Vec<Difference>,
70    /// Summary statistics
71    pub summary: DiffSummary,
72}
73
74/// Individual difference between snapshots
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct Difference {
77    /// Type of difference
78    pub diff_type: DifferenceType,
79    /// Mock ID (if applicable)
80    pub mock_id: Option<String>,
81    /// Path of the mock
82    pub path: String,
83    /// Method of the mock
84    pub method: String,
85    /// Description of the difference
86    pub description: String,
87    /// Left value (if applicable)
88    pub left_value: Option<serde_json::Value>,
89    /// Right value (if applicable)
90    pub right_value: Option<serde_json::Value>,
91    /// Field path where difference occurs (for nested differences)
92    pub field_path: Option<String>,
93}
94
95/// Type of difference
96#[derive(Debug, Clone, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum DifferenceType {
99    /// Mock exists in left but not right
100    MissingInRight,
101    /// Mock exists in right but not left
102    MissingInLeft,
103    /// Status code differs
104    StatusCodeMismatch,
105    /// Response body differs
106    BodyMismatch,
107    /// Response headers differ
108    HeadersMismatch,
109    /// Configuration differs
110    ConfigMismatch,
111}
112
113/// Summary statistics for a diff
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct DiffSummary {
116    /// Total mocks in left snapshot
117    pub left_total: usize,
118    /// Total mocks in right snapshot
119    pub right_total: usize,
120    /// Number of differences found
121    pub differences_count: usize,
122    /// Number of mocks only in left
123    pub only_in_left: usize,
124    /// Number of mocks only in right
125    pub only_in_right: usize,
126    /// Number of mocks with differences
127    pub mocks_with_differences: usize,
128}
129
130/// Request to create a snapshot
131#[derive(Debug, Deserialize)]
132pub struct CreateSnapshotRequest {
133    /// Environment ID (optional)
134    pub environment_id: Option<String>,
135    /// Persona ID (optional)
136    pub persona_id: Option<String>,
137    /// Scenario ID (optional)
138    pub scenario_id: Option<String>,
139    /// Reality level (optional, 0.0-1.0)
140    pub reality_level: Option<f64>,
141    /// Metadata to attach
142    #[serde(default)]
143    pub metadata: HashMap<String, serde_json::Value>,
144}
145
146/// Request to compare snapshots
147#[derive(Debug, Deserialize)]
148pub struct CompareSnapshotsRequest {
149    /// Left snapshot ID (or use environment/persona/scenario)
150    pub left_snapshot_id: Option<String>,
151    /// Right snapshot ID (or use environment/persona/scenario)
152    pub right_snapshot_id: Option<String>,
153    /// Left environment ID (alternative to snapshot_id)
154    pub left_environment_id: Option<String>,
155    /// Right environment ID (alternative to snapshot_id)
156    pub right_environment_id: Option<String>,
157    /// Left persona ID (alternative to snapshot_id)
158    pub left_persona_id: Option<String>,
159    /// Right persona ID (alternative to snapshot_id)
160    pub right_persona_id: Option<String>,
161    /// Left scenario ID (alternative to snapshot_id)
162    pub left_scenario_id: Option<String>,
163    /// Right scenario ID (alternative to snapshot_id)
164    pub right_scenario_id: Option<String>,
165    /// Left reality level (alternative to snapshot_id)
166    pub left_reality_level: Option<f64>,
167    /// Right reality level (alternative to snapshot_id)
168    pub right_reality_level: Option<f64>,
169}
170
171/// In-memory storage for snapshots
172type SnapshotStorage = Arc<tokio::sync::RwLock<HashMap<String, MockSnapshot>>>;
173
174/// Global snapshot storage
175fn 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
180/// Create a snapshot of current mock server state
181async fn create_snapshot(
182    State(state): State<ManagementState>,
183    Json(request): Json<CreateSnapshotRequest>,
184) -> Result<Json<MockSnapshot>, StatusCode> {
185    // Get current mocks from the state
186    let mocks = state.mocks.read().await.clone();
187
188    // Convert mocks to snapshot items
189    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    // Create snapshot
203    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    // Store snapshot in memory
215    let mut store = snapshot_store().write().await;
216    store.insert(snapshot.id.clone(), snapshot.clone());
217
218    Ok(Json(snapshot))
219}
220
221/// Get a snapshot by ID
222async 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
230/// List all snapshots
231async 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            // Filter by environment_id if provided
240            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            // Filter by persona_id if provided
246            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            // Filter by scenario_id if provided
252            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    // Sort by timestamp descending (most recent first)
263    snapshots.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
264
265    Ok(Json(snapshots))
266}
267
268/// Compare two snapshots
269async 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    // Load left snapshot: by ID if provided, otherwise create from current mocks
276    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            &current_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    // Load right snapshot: by ID if provided, otherwise create from current mocks
290    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            &current_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    // Compare snapshots
304    let diff = compare_snapshot_objects(&left_snapshot, &right_snapshot);
305
306    Ok(Json(diff))
307}
308
309/// Helper to create a snapshot from mocks
310fn 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
342/// Compare two snapshot objects
343fn compare_snapshot_objects(left: &MockSnapshot, right: &MockSnapshot) -> SnapshotDiff {
344    let mut differences = Vec::new();
345
346    // Create maps for quick lookup
347    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    // Find mocks only in left
354    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    // Find mocks only in right
373    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    // Compare common mocks
392    for (key, left_mock) in &left_map {
393        if let Some(right_mock) = right_map.get(key) {
394            // Compare status codes
395            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            // Compare response bodies (deep comparison)
412            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            // Compare headers
429            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    // Calculate summary
454    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
482/// Build the snapshot diff router
483pub 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}