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 (in production, use a database)
172type SnapshotStorage = Arc<tokio::sync::RwLock<HashMap<String, MockSnapshot>>>;
173
174/// Create a snapshot of current mock server state
175async fn create_snapshot(
176    State(state): State<ManagementState>,
177    Json(request): Json<CreateSnapshotRequest>,
178) -> Result<Json<MockSnapshot>, StatusCode> {
179    // Get current mocks from the state
180    let mocks = state.mocks.read().await.clone();
181
182    // Convert mocks to snapshot items
183    let snapshot_items: Vec<MockSnapshotItem> = mocks
184        .iter()
185        .map(|mock| MockSnapshotItem {
186            id: mock.id.clone(),
187            method: mock.method.clone(),
188            path: mock.path.clone(),
189            status_code: mock.status_code.unwrap_or(200),
190            response_body: mock.response.body.clone(),
191            response_headers: mock.response.headers.clone(),
192            config: serde_json::to_value(mock).unwrap_or_default(),
193        })
194        .collect();
195
196    // Create snapshot
197    let snapshot = MockSnapshot {
198        id: uuid::Uuid::new_v4().to_string(),
199        timestamp: chrono::Utc::now().timestamp(),
200        environment_id: request.environment_id,
201        persona_id: request.persona_id,
202        scenario_id: request.scenario_id,
203        reality_level: request.reality_level,
204        mocks: snapshot_items,
205        metadata: request.metadata,
206    };
207
208    // Store snapshot (in production, persist to database)
209    // For now, we'll use a simple in-memory storage
210    // In a real implementation, this would be stored in the ManagementState
211
212    Ok(Json(snapshot))
213}
214
215/// Get a snapshot by ID
216async fn get_snapshot(
217    Path(_snapshot_id): Path<String>,
218    State(_state): State<ManagementState>,
219) -> Result<Json<MockSnapshot>, StatusCode> {
220    // In production, retrieve from database
221    // For now, return error (snapshots need to be stored)
222    Err(StatusCode::NOT_IMPLEMENTED)
223}
224
225/// List all snapshots
226async fn list_snapshots(
227    Query(params): Query<HashMap<String, String>>,
228    State(_state): State<ManagementState>,
229) -> Result<Json<Vec<MockSnapshot>>, StatusCode> {
230    // In production, retrieve from database with filters
231    // For now, return empty list
232    Ok(Json(vec![]))
233}
234
235/// Compare two snapshots
236async fn compare_snapshots(
237    State(state): State<ManagementState>,
238    Json(request): Json<CompareSnapshotsRequest>,
239) -> Result<Json<SnapshotDiff>, StatusCode> {
240    // Get current mocks as baseline
241    let current_mocks = state.mocks.read().await.clone();
242
243    // For now, we'll create snapshots on-the-fly for comparison
244    // In production, you'd load from stored snapshots
245
246    let left_snapshot = create_snapshot_from_mocks(
247        &current_mocks,
248        request.left_environment_id.clone(),
249        request.left_persona_id.clone(),
250        request.left_scenario_id.clone(),
251        request.left_reality_level,
252    );
253
254    let right_snapshot = create_snapshot_from_mocks(
255        &current_mocks,
256        request.right_environment_id.clone(),
257        request.right_persona_id.clone(),
258        request.right_scenario_id.clone(),
259        request.right_reality_level,
260    );
261
262    // Compare snapshots
263    let diff = compare_snapshot_objects(&left_snapshot, &right_snapshot);
264
265    Ok(Json(diff))
266}
267
268/// Helper to create a snapshot from mocks
269fn create_snapshot_from_mocks(
270    mocks: &[MockConfig],
271    environment_id: Option<String>,
272    persona_id: Option<String>,
273    scenario_id: Option<String>,
274    reality_level: Option<f64>,
275) -> MockSnapshot {
276    let snapshot_items: Vec<MockSnapshotItem> = mocks
277        .iter()
278        .map(|mock| MockSnapshotItem {
279            id: mock.id.clone(),
280            method: mock.method.clone(),
281            path: mock.path.clone(),
282            status_code: mock.status_code.unwrap_or(200),
283            response_body: mock.response.body.clone(),
284            response_headers: mock.response.headers.clone(),
285            config: serde_json::to_value(mock).unwrap_or_default(),
286        })
287        .collect();
288
289    MockSnapshot {
290        id: uuid::Uuid::new_v4().to_string(),
291        timestamp: chrono::Utc::now().timestamp(),
292        environment_id,
293        persona_id,
294        scenario_id,
295        reality_level,
296        mocks: snapshot_items,
297        metadata: HashMap::new(),
298    }
299}
300
301/// Compare two snapshot objects
302fn compare_snapshot_objects(left: &MockSnapshot, right: &MockSnapshot) -> SnapshotDiff {
303    let mut differences = Vec::new();
304
305    // Create maps for quick lookup
306    let left_map: HashMap<String, &MockSnapshotItem> =
307        left.mocks.iter().map(|m| (format!("{}:{}", m.method, m.path), m)).collect();
308
309    let right_map: HashMap<String, &MockSnapshotItem> =
310        right.mocks.iter().map(|m| (format!("{}:{}", m.method, m.path), m)).collect();
311
312    // Find mocks only in left
313    for (key, left_mock) in &left_map {
314        if !right_map.contains_key(key) {
315            differences.push(Difference {
316                diff_type: DifferenceType::MissingInRight,
317                mock_id: Some(left_mock.id.clone()),
318                path: left_mock.path.clone(),
319                method: left_mock.method.clone(),
320                description: format!(
321                    "Mock {} {} exists in left but not in right",
322                    left_mock.method, left_mock.path
323                ),
324                left_value: Some(serde_json::to_value(left_mock).unwrap_or_default()),
325                right_value: None,
326                field_path: None,
327            });
328        }
329    }
330
331    // Find mocks only in right
332    for (key, right_mock) in &right_map {
333        if !left_map.contains_key(key) {
334            differences.push(Difference {
335                diff_type: DifferenceType::MissingInLeft,
336                mock_id: Some(right_mock.id.clone()),
337                path: right_mock.path.clone(),
338                method: right_mock.method.clone(),
339                description: format!(
340                    "Mock {} {} exists in right but not in left",
341                    right_mock.method, right_mock.path
342                ),
343                left_value: None,
344                right_value: Some(serde_json::to_value(right_mock).unwrap_or_default()),
345                field_path: None,
346            });
347        }
348    }
349
350    // Compare common mocks
351    for (key, left_mock) in &left_map {
352        if let Some(right_mock) = right_map.get(key) {
353            // Compare status codes
354            if left_mock.status_code != right_mock.status_code {
355                differences.push(Difference {
356                    diff_type: DifferenceType::StatusCodeMismatch,
357                    mock_id: Some(left_mock.id.clone()),
358                    path: left_mock.path.clone(),
359                    method: left_mock.method.clone(),
360                    description: format!(
361                        "Status code differs: {} vs {}",
362                        left_mock.status_code, right_mock.status_code
363                    ),
364                    left_value: Some(serde_json::json!(left_mock.status_code)),
365                    right_value: Some(serde_json::json!(right_mock.status_code)),
366                    field_path: Some("status_code".to_string()),
367                });
368            }
369
370            // Compare response bodies (deep comparison)
371            if left_mock.response_body != right_mock.response_body {
372                differences.push(Difference {
373                    diff_type: DifferenceType::BodyMismatch,
374                    mock_id: Some(left_mock.id.clone()),
375                    path: left_mock.path.clone(),
376                    method: left_mock.method.clone(),
377                    description: format!(
378                        "Response body differs for {} {}",
379                        left_mock.method, left_mock.path
380                    ),
381                    left_value: Some(left_mock.response_body.clone()),
382                    right_value: Some(right_mock.response_body.clone()),
383                    field_path: Some("response_body".to_string()),
384                });
385            }
386
387            // Compare headers
388            if left_mock.response_headers != right_mock.response_headers {
389                differences.push(Difference {
390                    diff_type: DifferenceType::HeadersMismatch,
391                    mock_id: Some(left_mock.id.clone()),
392                    path: left_mock.path.clone(),
393                    method: left_mock.method.clone(),
394                    description: format!(
395                        "Response headers differ for {} {}",
396                        left_mock.method, left_mock.path
397                    ),
398                    left_value: left_mock
399                        .response_headers
400                        .as_ref()
401                        .map(|h| serde_json::to_value(h).unwrap_or_default()),
402                    right_value: right_mock
403                        .response_headers
404                        .as_ref()
405                        .map(|h| serde_json::to_value(h).unwrap_or_default()),
406                    field_path: Some("response_headers".to_string()),
407                });
408            }
409        }
410    }
411
412    // Calculate summary
413    let only_in_left = differences
414        .iter()
415        .filter(|d| matches!(d.diff_type, DifferenceType::MissingInRight))
416        .count();
417    let only_in_right = differences
418        .iter()
419        .filter(|d| matches!(d.diff_type, DifferenceType::MissingInLeft))
420        .count();
421    let mocks_with_differences: std::collections::HashSet<String> =
422        differences.iter().filter_map(|d| d.mock_id.clone()).collect();
423
424    let summary = DiffSummary {
425        left_total: left.mocks.len(),
426        right_total: right.mocks.len(),
427        differences_count: differences.len(),
428        only_in_left,
429        only_in_right,
430        mocks_with_differences: mocks_with_differences.len(),
431    };
432
433    SnapshotDiff {
434        left: left.clone(),
435        right: right.clone(),
436        differences,
437        summary,
438    }
439}
440
441/// Build the snapshot diff router
442pub fn snapshot_diff_router(state: ManagementState) -> Router<ManagementState> {
443    Router::new()
444        .route("/snapshots", post(create_snapshot))
445        .route("/snapshots", get(list_snapshots))
446        .route("/snapshots/{id}", get(get_snapshot))
447        .route("/snapshots/compare", post(compare_snapshots))
448        .with_state(state)
449}