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