mockforge_core/
schema_diff.rs

1//! JSON schema diff utilities for 422 responses.
2//!
3//! This module provides comprehensive schema validation diffing capabilities
4//! for generating informative 422 error responses that help developers understand
5//! exactly what schema validation issues exist in their API requests.
6
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ValidationError {
12    /// JSON path to the field with validation issue
13    pub path: String,
14    /// Expected schema constraint or value type
15    pub expected: String,
16    /// What was actually found in the request
17    pub found: String,
18    /// Human-readable error message
19    pub message: Option<String>,
20    /// Error classification for client handling
21    pub error_type: String,
22    /// Additional context about the expected schema
23    pub schema_info: Option<SchemaInfo>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SchemaInfo {
28    /// Expected data type
29    pub data_type: String,
30    /// Required constraint
31    pub required: Option<bool>,
32    /// Format constraint (e.g., "email", "uuid")
33    pub format: Option<String>,
34    /// Minimum value constraint
35    pub minimum: Option<f64>,
36    /// Maximum value constraint
37    pub maximum: Option<f64>,
38    /// Minimum length for strings/arrays
39    pub min_length: Option<usize>,
40    /// Maximum length for strings/arrays
41    pub max_length: Option<usize>,
42    /// Regex pattern for strings
43    pub pattern: Option<String>,
44    /// Enum values if applicable
45    pub enum_values: Option<Vec<Value>>,
46    /// Whether this field accepts additional properties
47    pub additional_properties: Option<bool>,
48}
49
50impl ValidationError {
51    pub fn new(path: String, expected: String, found: String, error_type: &str) -> Self {
52        Self {
53            path,
54            expected,
55            found,
56            message: None,
57            error_type: error_type.to_string(),
58            schema_info: None,
59        }
60    }
61
62    pub fn with_message(mut self, message: String) -> Self {
63        self.message = Some(message);
64        self
65    }
66
67    pub fn with_schema_info(mut self, schema_info: SchemaInfo) -> Self {
68        self.schema_info = Some(schema_info);
69        self
70    }
71}
72
73// Keep the old FieldError for backward compatibility
74#[derive(Debug, Clone)]
75pub struct FieldError {
76    pub path: String,
77    pub expected: String,
78    pub found: String,
79    pub message: Option<String>,
80}
81
82impl From<ValidationError> for FieldError {
83    fn from(error: ValidationError) -> Self {
84        Self {
85            path: error.path,
86            expected: error.expected,
87            found: error.found,
88            message: error.message,
89        }
90    }
91}
92
93pub fn diff(expected_schema: &Value, actual: &Value) -> Vec<FieldError> {
94    let mut out = Vec::new();
95    walk(expected_schema, actual, "", &mut out);
96    out
97}
98
99fn walk(expected: &Value, actual: &Value, path: &str, out: &mut Vec<FieldError>) {
100    match (expected, actual) {
101        (Value::Object(eo), Value::Object(ao)) => {
102            for (k, ev) in eo {
103                let np = format!("{}/{}", path, k);
104                if let Some(av) = ao.get(k) {
105                    walk(ev, av, &np, out);
106                } else {
107                    out.push(FieldError {
108                        path: np,
109                        expected: type_of(ev),
110                        found: "missing".into(),
111                        message: Some("required".into()),
112                    });
113                }
114            }
115        }
116        (Value::Array(ea), Value::Array(aa)) => {
117            if let Some(esample) = ea.first() {
118                for (i, av) in aa.iter().enumerate() {
119                    let np = format!("{}/{}", path, i);
120                    walk(esample, av, &np, out);
121                }
122            }
123        }
124        (e, a) => {
125            let et = type_of(e);
126            let at = type_of(a);
127            if et != at {
128                out.push(FieldError {
129                    path: path.into(),
130                    expected: et,
131                    found: at,
132                    message: None,
133                });
134            }
135        }
136    }
137}
138
139fn type_of(v: &Value) -> String {
140    match v {
141        Value::Null => "null".to_string(),
142        Value::Bool(_) => "bool".to_string(),
143        Value::Number(n) => if n.is_i64() { "integer" } else { "number" }.to_string(),
144        Value::String(_) => "string".to_string(),
145        Value::Array(_) => "array".to_string(),
146        Value::Object(_) => "object".to_string(),
147    }
148}
149
150pub fn to_422_json(errors: Vec<FieldError>) -> Value {
151    json!({
152        "error": "Schema validation failed",
153        "details": errors.into_iter().map(|e| json!({
154            "path": e.path,
155            "expected": e.expected,
156            "found": e.found,
157            "message": e.message
158        })).collect::<Vec<_>>()
159    })
160}
161
162/// Enhanced validation diff with comprehensive error analysis
163/// This function performs detailed validation between expected and actual JSON
164/// and provides rich schema context for better error reporting
165pub fn validation_diff(expected_schema: &Value, actual: &Value) -> Vec<ValidationError> {
166    let mut out = Vec::new();
167    validation_walk(expected_schema, actual, "", &mut out);
168    out
169}
170
171fn validation_walk(expected: &Value, actual: &Value, path: &str, out: &mut Vec<ValidationError>) {
172    match (expected, actual) {
173        (Value::Object(eo), Value::Object(ao)) => {
174            // Check for missing required fields
175            for (k, ev) in eo {
176                let np = format!("{}/{}", path, k);
177                if let Some(av) = ao.get(k) {
178                    // Field exists, validate its value
179                    validation_walk(ev, av, &np, out);
180                } else {
181                    // Missing required field
182                    let schema_info = SchemaInfo {
183                        data_type: type_of(ev).clone(),
184                        required: Some(true),
185                        format: None,
186                        minimum: None,
187                        maximum: None,
188                        min_length: None,
189                        max_length: None,
190                        pattern: None,
191                        enum_values: None,
192                        additional_properties: None,
193                    };
194
195                    let error_msg =
196                        format!("Missing required field '{}' of type {}", k, schema_info.data_type);
197
198                    out.push(
199                        ValidationError::new(
200                            path.to_string(),
201                            schema_info.data_type.clone(),
202                            "missing".to_string(),
203                            "missing_required",
204                        )
205                        .with_message(error_msg)
206                        .with_schema_info(schema_info),
207                    );
208                }
209            }
210
211            // Check for unexpected additional fields
212            for k in ao.keys() {
213                if !eo.contains_key(k) {
214                    let np = format!("{}/{}", path, k);
215                    let error_msg = format!("Unexpected additional field '{}' found", k);
216
217                    out.push(
218                        ValidationError::new(
219                            np,
220                            "not_allowed".to_string(),
221                            type_of(&ao[k]).clone(),
222                            "additional_property",
223                        )
224                        .with_message(error_msg),
225                    );
226                }
227            }
228        }
229        (Value::Array(ea), Value::Array(aa)) => {
230            // Validate array items
231            if let Some(esample) = ea.first() {
232                for (i, av) in aa.iter().enumerate() {
233                    let np = format!("{}/{}", path, i);
234                    validation_walk(esample, av, &np, out);
235                }
236
237                // Check array length constraints if the expected specifies them
238                if let Some(arr_size) = esample.as_array().map(|a| a.len()) {
239                    if aa.len() != arr_size {
240                        let schema_info = SchemaInfo {
241                            data_type: "array".to_string(),
242                            required: None,
243                            format: None,
244                            minimum: None,
245                            maximum: None,
246                            min_length: Some(arr_size),
247                            max_length: Some(arr_size),
248                            pattern: None,
249                            enum_values: None,
250                            additional_properties: None,
251                        };
252
253                        let error_msg = format!(
254                            "Array size mismatch: expected {} items, found {}",
255                            arr_size,
256                            aa.len()
257                        );
258
259                        out.push(
260                            ValidationError::new(
261                                path.to_string(),
262                                format!("array[{}]", arr_size),
263                                format!("array[{}]", aa.len()),
264                                "length_mismatch",
265                            )
266                            .with_message(error_msg)
267                            .with_schema_info(schema_info),
268                        );
269                    }
270                }
271            } else {
272                // Expected array is empty but actual has items
273                if !aa.is_empty() {
274                    let error_msg = format!("Expected empty array, but found {} items", aa.len());
275
276                    out.push(
277                        ValidationError::new(
278                            path.to_string(),
279                            "empty_array".to_string(),
280                            format!("array[{}]", aa.len()),
281                            "unexpected_items",
282                        )
283                        .with_message(error_msg),
284                    );
285                }
286            }
287        }
288        (e, a) => {
289            let et = type_of(e);
290            let at = type_of(a);
291
292            if et != at {
293                // Type mismatch - provide detailed context based on the expected type
294                let schema_info = SchemaInfo {
295                    data_type: et.clone(),
296                    required: None,
297                    format: None, // Could be expanded to extract format info
298                    minimum: None,
299                    maximum: None,
300                    min_length: None,
301                    max_length: None,
302                    pattern: None,
303                    enum_values: None,
304                    additional_properties: None,
305                };
306
307                let error_msg = format!("Type mismatch: expected {}, found {}", et, at);
308
309                out.push(
310                    ValidationError::new(path.to_string(), et, at, "type_mismatch")
311                        .with_message(error_msg)
312                        .with_schema_info(schema_info),
313                );
314            } else {
315                // Same type but might have other constraints - check string/number specifics
316                match (e, a) {
317                    (Value::String(es), Value::String(actual_str)) => {
318                        // Check string constraints
319                        if es.is_empty() && !actual_str.is_empty() {
320                            // This is a simple example - could be expanded for length/pattern validation
321                        }
322                    }
323                    (Value::Number(en), Value::Number(an)) => {
324                        // Check number constraints - could validate min/max ranges
325                        if let (Some(_en_val), Some(_an_val)) = (en.as_f64(), an.as_f64()) {
326                            // Example: could flag if values are outside expected ranges
327                        }
328                    }
329                    _ => {} // Other same-type validations could be added
330                }
331            }
332        }
333    }
334}
335
336/// Generate enhanced 422 error response with detailed schema information
337pub fn to_enhanced_422_json(errors: Vec<ValidationError>) -> Value {
338    json!({
339        "error": "Schema validation failed",
340        "message": "Request data doesn't match expected schema. See details below for specific issues.",
341        "validation_errors": errors.iter().map(|e| {
342            json!({
343                "path": e.path,
344                "expected": e.expected,
345                "found": e.found,
346                "error_type": e.error_type,
347                "message": e.message,
348                "schema_info": e.schema_info
349            })
350        }).collect::<Vec<_>>(),
351        "help": {
352            "tips": [
353                "Check that all required fields are present",
354                "Ensure field types match the expected schema",
355                "Verify string formats and patterns",
356                "Confirm number values are within required ranges",
357                "Remove any unexpected fields"
358            ],
359            "documentation": "Refer to API specification for complete field definitions"
360        },
361        "timestamp": chrono::Utc::now().to_rfc3339()
362    })
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn test_validation_error_new() {
371        let error = ValidationError::new(
372            "/user/name".to_string(),
373            "string".to_string(),
374            "number".to_string(),
375            "type_mismatch",
376        );
377
378        assert_eq!(error.path, "/user/name");
379        assert_eq!(error.expected, "string");
380        assert_eq!(error.found, "number");
381        assert_eq!(error.error_type, "type_mismatch");
382        assert!(error.message.is_none());
383        assert!(error.schema_info.is_none());
384    }
385
386    #[test]
387    fn test_validation_error_with_message() {
388        let error = ValidationError::new(
389            "/user/age".to_string(),
390            "integer".to_string(),
391            "string".to_string(),
392            "type_mismatch",
393        )
394        .with_message("Expected integer, got string".to_string());
395
396        assert_eq!(error.message, Some("Expected integer, got string".to_string()));
397    }
398
399    #[test]
400    fn test_validation_error_with_schema_info() {
401        let schema_info = SchemaInfo {
402            data_type: "string".to_string(),
403            required: Some(true),
404            format: Some("email".to_string()),
405            minimum: None,
406            maximum: None,
407            min_length: Some(5),
408            max_length: Some(100),
409            pattern: None,
410            enum_values: None,
411            additional_properties: None,
412        };
413
414        let error = ValidationError::new(
415            "/user/email".to_string(),
416            "string".to_string(),
417            "missing".to_string(),
418            "missing_required",
419        )
420        .with_schema_info(schema_info.clone());
421
422        assert!(error.schema_info.is_some());
423        let info = error.schema_info.unwrap();
424        assert_eq!(info.data_type, "string");
425        assert_eq!(info.required, Some(true));
426        assert_eq!(info.format, Some("email".to_string()));
427    }
428
429    #[test]
430    fn test_field_error_from_validation_error() {
431        let validation_error = ValidationError::new(
432            "/user/id".to_string(),
433            "integer".to_string(),
434            "string".to_string(),
435            "type_mismatch",
436        )
437        .with_message("Type mismatch".to_string());
438
439        let field_error: FieldError = validation_error.into();
440
441        assert_eq!(field_error.path, "/user/id");
442        assert_eq!(field_error.expected, "integer");
443        assert_eq!(field_error.found, "string");
444        assert_eq!(field_error.message, Some("Type mismatch".to_string()));
445    }
446
447    #[test]
448    fn test_type_of_null() {
449        let value = json!(null);
450        assert_eq!(type_of(&value), "null");
451    }
452
453    #[test]
454    fn test_type_of_bool() {
455        let value = json!(true);
456        assert_eq!(type_of(&value), "bool");
457    }
458
459    #[test]
460    fn test_type_of_integer() {
461        let value = json!(42);
462        assert_eq!(type_of(&value), "integer");
463    }
464
465    #[test]
466    fn test_type_of_number() {
467        let value = json!(42.5);
468        assert_eq!(type_of(&value), "number");
469    }
470
471    #[test]
472    fn test_type_of_string() {
473        let value = json!("hello");
474        assert_eq!(type_of(&value), "string");
475    }
476
477    #[test]
478    fn test_type_of_array() {
479        let value = json!([1, 2, 3]);
480        assert_eq!(type_of(&value), "array");
481    }
482
483    #[test]
484    fn test_type_of_object() {
485        let value = json!({"key": "value"});
486        assert_eq!(type_of(&value), "object");
487    }
488
489    #[test]
490    fn test_diff_matching_objects() {
491        let expected = json!({"name": "John", "age": 30});
492        let actual = json!({"name": "John", "age": 30});
493
494        let errors = diff(&expected, &actual);
495        assert_eq!(errors.len(), 0);
496    }
497
498    #[test]
499    fn test_diff_missing_field() {
500        let expected = json!({"name": "John", "age": 30});
501        let actual = json!({"name": "John"});
502
503        let errors = diff(&expected, &actual);
504        assert_eq!(errors.len(), 1);
505        assert_eq!(errors[0].path, "/age");
506        assert_eq!(errors[0].expected, "integer");
507        assert_eq!(errors[0].found, "missing");
508    }
509
510    #[test]
511    fn test_diff_type_mismatch() {
512        let expected = json!({"name": "John", "age": 30});
513        let actual = json!({"name": "John", "age": "thirty"});
514
515        let errors = diff(&expected, &actual);
516        assert_eq!(errors.len(), 1);
517        assert_eq!(errors[0].path, "/age");
518        assert_eq!(errors[0].expected, "integer");
519        assert_eq!(errors[0].found, "string");
520    }
521
522    #[test]
523    fn test_diff_nested_objects() {
524        let expected = json!({
525            "user": {
526                "name": "John",
527                "address": {
528                    "city": "NYC"
529                }
530            }
531        });
532        let actual = json!({
533            "user": {
534                "name": "John",
535                "address": {
536                    "city": 123
537                }
538            }
539        });
540
541        let errors = diff(&expected, &actual);
542        assert_eq!(errors.len(), 1);
543        assert_eq!(errors[0].path, "/user/address/city");
544        assert_eq!(errors[0].expected, "string");
545        assert_eq!(errors[0].found, "integer");
546    }
547
548    #[test]
549    fn test_diff_arrays() {
550        let expected = json!([{"id": 1}]);
551        let actual = json!([{"id": 1}, {"id": 2}]);
552
553        let errors = diff(&expected, &actual);
554        assert_eq!(errors.len(), 0); // Both items match the expected structure
555    }
556
557    #[test]
558    fn test_diff_array_type_mismatch() {
559        let expected = json!([{"id": 1}]);
560        let actual = json!([{"id": "one"}]);
561
562        let errors = diff(&expected, &actual);
563        assert_eq!(errors.len(), 1);
564        assert_eq!(errors[0].path, "/0/id");
565        assert_eq!(errors[0].expected, "integer");
566        assert_eq!(errors[0].found, "string");
567    }
568
569    #[test]
570    fn test_to_422_json() {
571        let errors = vec![
572            FieldError {
573                path: "/name".to_string(),
574                expected: "string".to_string(),
575                found: "number".to_string(),
576                message: None,
577            },
578            FieldError {
579                path: "/email".to_string(),
580                expected: "string".to_string(),
581                found: "missing".to_string(),
582                message: Some("required".to_string()),
583            },
584        ];
585
586        let result = to_422_json(errors);
587        assert_eq!(result["error"], "Schema validation failed");
588        assert_eq!(result["details"].as_array().unwrap().len(), 2);
589        assert_eq!(result["details"][0]["path"], "/name");
590        assert_eq!(result["details"][1]["path"], "/email");
591    }
592
593    #[test]
594    fn test_validation_diff_matching_objects() {
595        let expected = json!({"name": "John", "age": 30});
596        let actual = json!({"name": "John", "age": 30});
597
598        let errors = validation_diff(&expected, &actual);
599        assert_eq!(errors.len(), 0);
600    }
601
602    #[test]
603    fn test_validation_diff_missing_required_field() {
604        let expected = json!({"name": "John", "age": 30});
605        let actual = json!({"name": "John"});
606
607        let errors = validation_diff(&expected, &actual);
608        assert_eq!(errors.len(), 1);
609        assert_eq!(errors[0].error_type, "missing_required");
610        assert!(errors[0].message.as_ref().unwrap().contains("Missing required field"));
611        assert!(errors[0].schema_info.is_some());
612    }
613
614    #[test]
615    fn test_validation_diff_additional_property() {
616        let expected = json!({"name": "John"});
617        let actual = json!({"name": "John", "age": 30});
618
619        let errors = validation_diff(&expected, &actual);
620        assert_eq!(errors.len(), 1);
621        assert_eq!(errors[0].error_type, "additional_property");
622        assert!(errors[0].message.as_ref().unwrap().contains("Unexpected additional field"));
623    }
624
625    #[test]
626    fn test_validation_diff_type_mismatch() {
627        let expected = json!({"age": 30});
628        let actual = json!({"age": "thirty"});
629
630        let errors = validation_diff(&expected, &actual);
631        assert_eq!(errors.len(), 1);
632        assert_eq!(errors[0].error_type, "type_mismatch");
633        assert_eq!(errors[0].expected, "integer");
634        assert_eq!(errors[0].found, "string");
635        assert!(errors[0].schema_info.is_some());
636    }
637
638    #[test]
639    fn test_validation_diff_array_items() {
640        let expected = json!([{"id": 1}]);
641        let actual = json!([{"id": "one"}]);
642
643        let errors = validation_diff(&expected, &actual);
644        assert_eq!(errors.len(), 1);
645        assert_eq!(errors[0].path, "/0/id");
646        assert_eq!(errors[0].error_type, "type_mismatch");
647    }
648
649    #[test]
650    fn test_validation_diff_empty_array_with_items() {
651        let expected = json!([]);
652        let actual = json!([1, 2, 3]);
653
654        let errors = validation_diff(&expected, &actual);
655        assert_eq!(errors.len(), 1);
656        assert_eq!(errors[0].error_type, "unexpected_items");
657        assert!(errors[0].message.as_ref().unwrap().contains("Expected empty array"));
658    }
659
660    #[test]
661    fn test_to_enhanced_422_json() {
662        let errors = vec![ValidationError::new(
663            "/name".to_string(),
664            "string".to_string(),
665            "number".to_string(),
666            "type_mismatch",
667        )
668        .with_message("Type mismatch: expected string, found number".to_string())];
669
670        let result = to_enhanced_422_json(errors);
671        assert_eq!(result["error"], "Schema validation failed");
672        assert!(result["message"].as_str().unwrap().contains("doesn't match expected schema"));
673        assert_eq!(result["validation_errors"].as_array().unwrap().len(), 1);
674        assert!(result["help"]["tips"].is_array());
675        assert!(result["timestamp"].is_string());
676    }
677
678    #[test]
679    fn test_validation_diff_nested_objects() {
680        let expected = json!({
681            "user": {
682                "profile": {
683                    "name": "John",
684                    "age": 30
685                }
686            }
687        });
688        let actual = json!({
689            "user": {
690                "profile": {
691                    "name": "John"
692                }
693            }
694        });
695
696        let errors = validation_diff(&expected, &actual);
697        assert_eq!(errors.len(), 1);
698        assert!(errors[0].path.contains("/user/profile"));
699        assert_eq!(errors[0].error_type, "missing_required");
700    }
701
702    #[test]
703    fn test_validation_diff_multiple_errors() {
704        let expected = json!({
705            "name": "John",
706            "age": 30,
707            "email": "john@example.com"
708        });
709        let actual = json!({
710            "name": 123,
711            "extra": "field"
712        });
713
714        let errors = validation_diff(&expected, &actual);
715        // Should have: type mismatch for name, missing age, missing email, additional property 'extra'
716        assert!(errors.len() >= 3);
717
718        let error_types: Vec<_> = errors.iter().map(|e| e.error_type.as_str()).collect();
719        assert!(error_types.contains(&"type_mismatch"));
720        assert!(error_types.contains(&"missing_required"));
721        assert!(error_types.contains(&"additional_property"));
722    }
723}