mockforge_core/
schema_diff.rs

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