Skip to main content

fastmcp_protocol/
schema.rs

1//! JSON Schema validation for MCP tool inputs.
2//!
3//! This module provides a simple JSON Schema validator that covers the core
4//! requirements for MCP tool input validation:
5//!
6//! - Type checking (string, number, integer, boolean, object, array, null)
7//! - Required field validation
8//! - Enum validation
9//! - Property validation for objects
10//! - Items validation for arrays
11//!
12//! This is not a full JSON Schema implementation but covers the subset used by MCP.
13
14use regex::Regex;
15use serde_json::Value;
16use std::fmt;
17
18/// Error returned when JSON Schema validation fails.
19#[derive(Debug, Clone)]
20pub struct ValidationError {
21    /// Path to the invalid value (e.g., `root.foo.bar` or `root[0]`).
22    pub path: String,
23    /// Description of what went wrong.
24    pub message: String,
25}
26
27impl fmt::Display for ValidationError {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        write!(f, "{}: {}", self.path, self.message)
30    }
31}
32
33impl std::error::Error for ValidationError {}
34
35/// Result of JSON Schema validation.
36pub type ValidationResult = Result<(), Vec<ValidationError>>;
37
38/// Validates a JSON value against a JSON Schema.
39///
40/// # Arguments
41///
42/// * `schema` - The JSON Schema to validate against
43/// * `value` - The value to validate
44///
45/// # Returns
46///
47/// `Ok(())` if the value is valid, or `Err(Vec<ValidationError>)` with all
48/// validation errors found.
49///
50/// # Example
51///
52/// ```
53/// use fastmcp_protocol::schema::validate;
54/// use serde_json::json;
55///
56/// let schema = json!({
57///     "type": "object",
58///     "properties": {
59///         "name": { "type": "string" },
60///         "age": { "type": "integer" }
61///     },
62///     "required": ["name"]
63/// });
64///
65/// let valid = json!({ "name": "Alice", "age": 30 });
66/// assert!(validate(&schema, &valid).is_ok());
67///
68/// let invalid = json!({ "age": 30 });
69/// assert!(validate(&schema, &invalid).is_err());
70/// ```
71pub fn validate(schema: &Value, value: &Value) -> ValidationResult {
72    let mut errors = Vec::new();
73    validate_internal(schema, value, "root", &mut errors);
74
75    if errors.is_empty() {
76        Ok(())
77    } else {
78        Err(errors)
79    }
80}
81
82/// Validates a JSON value against a JSON Schema in strict mode.
83///
84/// Strict mode enforces `additionalProperties: false` on all object schemas,
85/// rejecting any properties not explicitly defined in the schema.
86///
87/// # Arguments
88///
89/// * `schema` - The JSON Schema to validate against
90/// * `value` - The value to validate
91///
92/// # Returns
93///
94/// `Ok(())` if the value is valid, or `Err(Vec<ValidationError>)` with all
95/// validation errors found.
96///
97/// # Example
98///
99/// ```
100/// use fastmcp_protocol::schema::validate_strict;
101/// use serde_json::json;
102///
103/// let schema = json!({
104///     "type": "object",
105///     "properties": {
106///         "name": { "type": "string" }
107///     }
108/// });
109///
110/// // Extra property "age" is rejected in strict mode
111/// let with_extra = json!({ "name": "Alice", "age": 30 });
112/// assert!(validate_strict(&schema, &with_extra).is_err());
113///
114/// // Only defined properties pass
115/// let valid = json!({ "name": "Alice" });
116/// assert!(validate_strict(&schema, &valid).is_ok());
117/// ```
118pub fn validate_strict(schema: &Value, value: &Value) -> ValidationResult {
119    // Clone and modify the schema to enforce additionalProperties: false
120    let strict_schema = make_strict_schema(schema);
121    validate(&strict_schema, value)
122}
123
124/// Recursively adds `additionalProperties: false` to all object schemas.
125fn make_strict_schema(schema: &Value) -> Value {
126    match schema {
127        Value::Object(obj) => {
128            let mut new_obj = obj.clone();
129
130            // Add additionalProperties: false if this is an object type schema
131            // and doesn't already have additionalProperties defined
132            if let Some(type_val) = obj.get("type") {
133                let is_object_type = type_val == "object"
134                    || type_val
135                        .as_array()
136                        .is_some_and(|arr| arr.iter().any(|t| t == "object"));
137
138                if is_object_type && !obj.contains_key("additionalProperties") {
139                    new_obj.insert("additionalProperties".to_string(), Value::Bool(false));
140                }
141            }
142
143            // Recursively process nested schemas
144            if let Some(Value::Object(props)) = obj.get("properties") {
145                let strict_props: serde_json::Map<String, Value> = props
146                    .iter()
147                    .map(|(k, v)| (k.clone(), make_strict_schema(v)))
148                    .collect();
149                new_obj.insert("properties".to_string(), Value::Object(strict_props));
150            }
151
152            // Handle additionalProperties if it's a schema object
153            if let Some(additional) = obj.get("additionalProperties") {
154                if additional.is_object() {
155                    new_obj.insert(
156                        "additionalProperties".to_string(),
157                        make_strict_schema(additional),
158                    );
159                }
160            }
161
162            // Handle items schema for arrays
163            if let Some(items) = obj.get("items") {
164                new_obj.insert("items".to_string(), make_strict_schema(items));
165            }
166
167            // Handle prefixItems for tuple validation
168            if let Some(Value::Array(arr)) = obj.get("prefixItems") {
169                let strict_items: Vec<Value> = arr.iter().map(make_strict_schema).collect();
170                new_obj.insert("prefixItems".to_string(), Value::Array(strict_items));
171            }
172
173            Value::Object(new_obj)
174        }
175        Value::Array(arr) => {
176            // Handle array schemas (union types in older drafts)
177            Value::Array(arr.iter().map(make_strict_schema).collect())
178        }
179        _ => schema.clone(),
180    }
181}
182
183/// Internal recursive validation function.
184fn validate_internal(schema: &Value, value: &Value, path: &str, errors: &mut Vec<ValidationError>) {
185    // Handle boolean schemas (true = accept all, false = reject all)
186    if let Some(b) = schema.as_bool() {
187        if !b {
188            errors.push(ValidationError {
189                path: path.to_string(),
190                message: "schema rejects all values".to_string(),
191            });
192        }
193        return;
194    }
195
196    // Schema must be an object
197    let Some(schema_obj) = schema.as_object() else {
198        return; // Invalid schema, skip validation
199    };
200
201    // Check type constraint
202    if let Some(type_val) = schema_obj.get("type") {
203        if !validate_type(type_val, value) {
204            let expected = type_val
205                .as_str()
206                .map(String::from)
207                .or_else(|| type_val.as_array().map(|arr| format!("{arr:?}")))
208                .unwrap_or_else(|| "unknown".to_string());
209            errors.push(ValidationError {
210                path: path.to_string(),
211                message: format!("expected type {expected}, got {}", json_type_name(value)),
212            });
213            return; // Type mismatch, skip further validation
214        }
215    }
216
217    // Check enum constraint
218    if let Some(enum_val) = schema_obj.get("enum") {
219        if let Some(enum_arr) = enum_val.as_array() {
220            if !enum_arr.contains(value) {
221                errors.push(ValidationError {
222                    path: path.to_string(),
223                    message: format!("value must be one of: {enum_arr:?}"),
224                });
225            }
226        }
227    }
228
229    // Check const constraint
230    if let Some(const_val) = schema_obj.get("const") {
231        if value != const_val {
232            errors.push(ValidationError {
233                path: path.to_string(),
234                message: format!("value must equal {const_val}"),
235            });
236        }
237    }
238
239    // Type-specific validation
240    match value {
241        Value::Object(obj) => {
242            validate_object(schema_obj, obj, path, errors);
243        }
244        Value::Array(arr) => {
245            validate_array(schema_obj, arr, path, errors);
246        }
247        Value::String(s) => {
248            validate_string(schema_obj, s, path, errors);
249        }
250        Value::Number(n) => {
251            validate_number(schema_obj, n, path, errors);
252        }
253        _ => {}
254    }
255}
256
257/// Validates type constraint.
258fn validate_type(type_val: &Value, value: &Value) -> bool {
259    match type_val {
260        Value::String(t) => matches_type(t, value),
261        Value::Array(types) => types.iter().any(|t| {
262            t.as_str()
263                .is_some_and(|type_str| matches_type(type_str, value))
264        }),
265        _ => true, // Invalid type constraint, skip
266    }
267}
268
269/// Checks if a value matches a single type name.
270fn matches_type(type_name: &str, value: &Value) -> bool {
271    match type_name {
272        "string" => value.is_string(),
273        "number" => value.is_number(),
274        "integer" => value.is_i64() || value.is_u64(),
275        "boolean" => value.is_boolean(),
276        "object" => value.is_object(),
277        "array" => value.is_array(),
278        "null" => value.is_null(),
279        _ => true, // Unknown type, accept
280    }
281}
282
283/// Returns the JSON type name for a value.
284fn json_type_name(value: &Value) -> &'static str {
285    match value {
286        Value::Null => "null",
287        Value::Bool(_) => "boolean",
288        Value::Number(n) => {
289            if n.is_i64() || n.is_u64() {
290                "integer"
291            } else {
292                "number"
293            }
294        }
295        Value::String(_) => "string",
296        Value::Array(_) => "array",
297        Value::Object(_) => "object",
298    }
299}
300
301/// Validates object-specific constraints.
302fn validate_object(
303    schema: &serde_json::Map<String, Value>,
304    obj: &serde_json::Map<String, Value>,
305    path: &str,
306    errors: &mut Vec<ValidationError>,
307) {
308    // Check required fields
309    if let Some(required) = schema.get("required").and_then(|v| v.as_array()) {
310        for req in required {
311            if let Some(req_name) = req.as_str() {
312                if !obj.contains_key(req_name) {
313                    errors.push(ValidationError {
314                        path: path.to_string(),
315                        message: format!("missing required field: {req_name}"),
316                    });
317                }
318            }
319        }
320    }
321
322    // Validate properties
323    if let Some(properties) = schema.get("properties").and_then(|v| v.as_object()) {
324        for (key, value) in obj {
325            if let Some(prop_schema) = properties.get(key) {
326                let prop_path = format!("{path}.{key}");
327                validate_internal(prop_schema, value, &prop_path, errors);
328            }
329        }
330    }
331
332    // Check additionalProperties constraint
333    if let Some(additional) = schema.get("additionalProperties") {
334        // Get properties map directly - avoid collecting keys into Vec
335        let properties = schema.get("properties").and_then(|v| v.as_object());
336
337        for (key, value) in obj {
338            // Use contains_key directly on the Map (O(1) lookup) instead of Vec::contains (O(n))
339            let is_defined_property = properties.is_some_and(|p| p.contains_key(key));
340            if !is_defined_property {
341                match additional {
342                    Value::Bool(false) => {
343                        errors.push(ValidationError {
344                            path: path.to_string(),
345                            message: format!("additional property not allowed: {key}"),
346                        });
347                    }
348                    Value::Object(_) => {
349                        let prop_path = format!("{path}.{key}");
350                        validate_internal(additional, value, &prop_path, errors);
351                    }
352                    _ => {}
353                }
354            }
355        }
356    }
357
358    // Check minProperties/maxProperties
359    if let Some(min) = schema
360        .get("minProperties")
361        .and_then(serde_json::Value::as_u64)
362    {
363        if (obj.len() as u64) < min {
364            errors.push(ValidationError {
365                path: path.to_string(),
366                message: format!("object must have at least {min} properties"),
367            });
368        }
369    }
370    if let Some(max) = schema
371        .get("maxProperties")
372        .and_then(serde_json::Value::as_u64)
373    {
374        if (obj.len() as u64) > max {
375            errors.push(ValidationError {
376                path: path.to_string(),
377                message: format!("object must have at most {max} properties"),
378            });
379        }
380    }
381}
382
383/// Validates array-specific constraints.
384fn validate_array(
385    schema: &serde_json::Map<String, Value>,
386    arr: &[Value],
387    path: &str,
388    errors: &mut Vec<ValidationError>,
389) {
390    // Validate prefixItems (tuple validation)
391    let mut prefix_len = 0;
392    if let Some(prefix_items) = schema.get("prefixItems").and_then(|v| v.as_array()) {
393        prefix_len = prefix_items.len();
394        for (i, item_schema) in prefix_items.iter().enumerate() {
395            if let Some(item) = arr.get(i) {
396                let item_path = format!("{path}[{i}]");
397                validate_internal(item_schema, item, &item_path, errors);
398            }
399        }
400    }
401
402    // Validate items (remaining items or all items)
403    if let Some(items_schema) = schema.get("items") {
404        // If items is an array (Draft 4-7 tuple), treat as prefixItems fallback if prefixItems absent
405        if items_schema.is_array() && prefix_len == 0 {
406            if let Some(items_arr) = items_schema.as_array() {
407                for (i, item_schema) in items_arr.iter().enumerate() {
408                    if let Some(item) = arr.get(i) {
409                        let item_path = format!("{path}[{i}]");
410                        validate_internal(item_schema, item, &item_path, errors);
411                    }
412                }
413                // In older drafts, 'additionalItems' controls the rest. We skip that for simplicity unless needed.
414            }
415        } else if items_schema.is_object() || items_schema.is_boolean() {
416            // Validate items starting from where prefixItems left off
417            for (i, item) in arr.iter().enumerate().skip(prefix_len) {
418                let item_path = format!("{path}[{i}]");
419                validate_internal(items_schema, item, &item_path, errors);
420            }
421        }
422    }
423
424    // Check minItems/maxItems
425    if let Some(min) = schema.get("minItems").and_then(serde_json::Value::as_u64) {
426        if (arr.len() as u64) < min {
427            errors.push(ValidationError {
428                path: path.to_string(),
429                message: format!("array must have at least {min} items"),
430            });
431        }
432    }
433    if let Some(max) = schema.get("maxItems").and_then(serde_json::Value::as_u64) {
434        if (arr.len() as u64) > max {
435            errors.push(ValidationError {
436                path: path.to_string(),
437                message: format!("array must have at most {max} items"),
438            });
439        }
440    }
441
442    // Check uniqueItems
443    if schema
444        .get("uniqueItems")
445        .and_then(serde_json::Value::as_bool)
446        .unwrap_or(false)
447    {
448        // Use HashSet with serialized JSON strings for O(1) lookup instead of O(n) Vec::contains
449        // This makes the overall algorithm O(n) instead of O(n²)
450        let mut seen = std::collections::HashSet::with_capacity(arr.len());
451        for (i, item) in arr.iter().enumerate() {
452            // Serialize to canonical JSON string for comparison
453            // serde_json produces consistent output for equal values
454            let key = serde_json::to_string(item).unwrap_or_default();
455            if !seen.insert(key) {
456                errors.push(ValidationError {
457                    path: format!("{path}[{i}]"),
458                    message: "duplicate item in array".to_string(),
459                });
460            }
461        }
462    }
463}
464
465/// Validates string-specific constraints.
466fn validate_string(
467    schema: &serde_json::Map<String, Value>,
468    s: &str,
469    path: &str,
470    errors: &mut Vec<ValidationError>,
471) {
472    // Check minLength/maxLength
473    let len = s.chars().count();
474    if let Some(min) = schema.get("minLength").and_then(serde_json::Value::as_u64) {
475        if (len as u64) < min {
476            errors.push(ValidationError {
477                path: path.to_string(),
478                message: format!("string must be at least {min} characters"),
479            });
480        }
481    }
482    if let Some(max) = schema.get("maxLength").and_then(serde_json::Value::as_u64) {
483        if (len as u64) > max {
484            errors.push(ValidationError {
485                path: path.to_string(),
486                message: format!("string must be at most {max} characters"),
487            });
488        }
489    }
490
491    // Check pattern (JSON Schema semantics: pattern matches if any substring matches).
492    if let Some(pattern) = schema.get("pattern").and_then(serde_json::Value::as_str) {
493        match Regex::new(pattern) {
494            Ok(re) => {
495                if !re.is_match(s) {
496                    errors.push(ValidationError {
497                        path: path.to_string(),
498                        message: format!("string does not match pattern {pattern:?}"),
499                    });
500                }
501            }
502            Err(e) => {
503                // Invalid schema: treat as a validation error rather than silently skipping.
504                errors.push(ValidationError {
505                    path: path.to_string(),
506                    message: format!("invalid schema pattern {pattern:?}: {e}"),
507                });
508            }
509        }
510    }
511}
512
513/// Validates number-specific constraints.
514fn validate_number(
515    schema: &serde_json::Map<String, Value>,
516    n: &serde_json::Number,
517    path: &str,
518    errors: &mut Vec<ValidationError>,
519) {
520    let val = n.as_f64().unwrap_or(0.0);
521
522    // Check minimum/maximum
523    if let Some(min) = schema.get("minimum").and_then(serde_json::Value::as_f64) {
524        if val < min {
525            errors.push(ValidationError {
526                path: path.to_string(),
527                message: format!("value must be >= {min}"),
528            });
529        }
530    }
531    if let Some(max) = schema.get("maximum").and_then(serde_json::Value::as_f64) {
532        if val > max {
533            errors.push(ValidationError {
534                path: path.to_string(),
535                message: format!("value must be <= {max}"),
536            });
537        }
538    }
539
540    // Check exclusiveMinimum/exclusiveMaximum
541    if let Some(min) = schema
542        .get("exclusiveMinimum")
543        .and_then(serde_json::Value::as_f64)
544    {
545        if val <= min {
546            errors.push(ValidationError {
547                path: path.to_string(),
548                message: format!("value must be > {min}"),
549            });
550        }
551    }
552    if let Some(max) = schema
553        .get("exclusiveMaximum")
554        .and_then(serde_json::Value::as_f64)
555    {
556        if val >= max {
557            errors.push(ValidationError {
558                path: path.to_string(),
559                message: format!("value must be < {max}"),
560            });
561        }
562    }
563
564    // Check multipleOf
565    if let Some(multiple) = schema.get("multipleOf").and_then(serde_json::Value::as_f64) {
566        if multiple != 0.0 && (val % multiple).abs() > f64::EPSILON {
567            errors.push(ValidationError {
568                path: path.to_string(),
569                message: format!("value must be a multiple of {multiple}"),
570            });
571        }
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use serde_json::json;
579
580    #[test]
581    fn test_type_validation_string() {
582        let schema = json!({"type": "string"});
583        assert!(validate(&schema, &json!("hello")).is_ok());
584        assert!(validate(&schema, &json!(123)).is_err());
585    }
586
587    #[test]
588    fn test_type_validation_number() {
589        let schema = json!({"type": "number"});
590        assert!(validate(&schema, &json!(123)).is_ok());
591        assert!(validate(&schema, &json!(12.5)).is_ok());
592        assert!(validate(&schema, &json!("hello")).is_err());
593    }
594
595    #[test]
596    fn test_type_validation_integer() {
597        let schema = json!({"type": "integer"});
598        assert!(validate(&schema, &json!(123)).is_ok());
599        assert!(validate(&schema, &json!(12.5)).is_err());
600    }
601
602    #[test]
603    fn test_type_validation_boolean() {
604        let schema = json!({"type": "boolean"});
605        assert!(validate(&schema, &json!(true)).is_ok());
606        assert!(validate(&schema, &json!(false)).is_ok());
607        assert!(validate(&schema, &json!(1)).is_err());
608    }
609
610    #[test]
611    fn test_type_validation_object() {
612        let schema = json!({"type": "object"});
613        assert!(validate(&schema, &json!({})).is_ok());
614        assert!(validate(&schema, &json!({"a": 1})).is_ok());
615        assert!(validate(&schema, &json!([])).is_err());
616    }
617
618    #[test]
619    fn test_type_validation_array() {
620        let schema = json!({"type": "array"});
621        assert!(validate(&schema, &json!([])).is_ok());
622        assert!(validate(&schema, &json!([1, 2, 3])).is_ok());
623        assert!(validate(&schema, &json!({})).is_err());
624    }
625
626    #[test]
627    fn test_type_validation_null() {
628        let schema = json!({"type": "null"});
629        assert!(validate(&schema, &json!(null)).is_ok());
630        assert!(validate(&schema, &json!(0)).is_err());
631    }
632
633    #[test]
634    fn test_type_validation_union() {
635        let schema = json!({"type": ["string", "number"]});
636        assert!(validate(&schema, &json!("hello")).is_ok());
637        assert!(validate(&schema, &json!(123)).is_ok());
638        assert!(validate(&schema, &json!(true)).is_err());
639    }
640
641    #[test]
642    fn test_required_fields() {
643        let schema = json!({
644            "type": "object",
645            "properties": {
646                "name": {"type": "string"},
647                "age": {"type": "integer"}
648            },
649            "required": ["name"]
650        });
651
652        assert!(validate(&schema, &json!({"name": "Alice"})).is_ok());
653        assert!(validate(&schema, &json!({"name": "Alice", "age": 30})).is_ok());
654        assert!(validate(&schema, &json!({"age": 30})).is_err());
655        assert!(validate(&schema, &json!({})).is_err());
656    }
657
658    #[test]
659    fn test_enum_validation() {
660        let schema = json!({"enum": ["red", "green", "blue"]});
661        assert!(validate(&schema, &json!("red")).is_ok());
662        assert!(validate(&schema, &json!("yellow")).is_err());
663    }
664
665    #[test]
666    fn test_const_validation() {
667        let schema = json!({"const": "fixed"});
668        assert!(validate(&schema, &json!("fixed")).is_ok());
669        assert!(validate(&schema, &json!("other")).is_err());
670    }
671
672    #[test]
673    fn test_string_length() {
674        let schema = json!({
675            "type": "string",
676            "minLength": 2,
677            "maxLength": 5
678        });
679
680        assert!(validate(&schema, &json!("ab")).is_ok());
681        assert!(validate(&schema, &json!("abcde")).is_ok());
682        assert!(validate(&schema, &json!("a")).is_err());
683        assert!(validate(&schema, &json!("abcdef")).is_err());
684    }
685
686    #[test]
687    fn test_string_pattern() {
688        let schema = json!({
689            "type": "string",
690            "pattern": "^[a-z]+$"
691        });
692
693        assert!(validate(&schema, &json!("hello")).is_ok());
694        assert!(validate(&schema, &json!("Hello")).is_err());
695        assert!(validate(&schema, &json!("hello123")).is_err());
696    }
697
698    #[test]
699    fn test_string_pattern_invalid_regex_is_error() {
700        let schema = json!({
701            "type": "string",
702            "pattern": "("
703        });
704
705        assert!(validate(&schema, &json!("anything")).is_err());
706    }
707
708    #[test]
709    fn test_number_range() {
710        let schema = json!({
711            "type": "number",
712            "minimum": 0,
713            "maximum": 100
714        });
715
716        assert!(validate(&schema, &json!(0)).is_ok());
717        assert!(validate(&schema, &json!(50)).is_ok());
718        assert!(validate(&schema, &json!(100)).is_ok());
719        assert!(validate(&schema, &json!(-1)).is_err());
720        assert!(validate(&schema, &json!(101)).is_err());
721    }
722
723    #[test]
724    fn test_number_exclusive_range() {
725        let schema = json!({
726            "type": "number",
727            "exclusiveMinimum": 0,
728            "exclusiveMaximum": 10
729        });
730
731        assert!(validate(&schema, &json!(1)).is_ok());
732        assert!(validate(&schema, &json!(9)).is_ok());
733        assert!(validate(&schema, &json!(0)).is_err());
734        assert!(validate(&schema, &json!(10)).is_err());
735    }
736
737    #[test]
738    fn test_array_items() {
739        let schema = json!({
740            "type": "array",
741            "items": {"type": "integer"}
742        });
743
744        assert!(validate(&schema, &json!([1, 2, 3])).is_ok());
745        assert!(validate(&schema, &json!([])).is_ok());
746        assert!(validate(&schema, &json!([1, "two", 3])).is_err());
747    }
748
749    #[test]
750    fn test_array_length() {
751        let schema = json!({
752            "type": "array",
753            "minItems": 1,
754            "maxItems": 3
755        });
756
757        assert!(validate(&schema, &json!([1])).is_ok());
758        assert!(validate(&schema, &json!([1, 2, 3])).is_ok());
759        assert!(validate(&schema, &json!([])).is_err());
760        assert!(validate(&schema, &json!([1, 2, 3, 4])).is_err());
761    }
762
763    #[test]
764    fn test_unique_items() {
765        let schema = json!({
766            "type": "array",
767            "uniqueItems": true
768        });
769
770        assert!(validate(&schema, &json!([1, 2, 3])).is_ok());
771        assert!(validate(&schema, &json!([1, 1, 2])).is_err());
772    }
773
774    #[test]
775    fn test_nested_object() {
776        let schema = json!({
777            "type": "object",
778            "properties": {
779                "person": {
780                    "type": "object",
781                    "properties": {
782                        "name": {"type": "string"},
783                        "age": {"type": "integer"}
784                    },
785                    "required": ["name"]
786                }
787            }
788        });
789
790        assert!(validate(&schema, &json!({"person": {"name": "Alice"}})).is_ok());
791        assert!(validate(&schema, &json!({"person": {"name": "Alice", "age": 30}})).is_ok());
792        assert!(validate(&schema, &json!({"person": {"age": 30}})).is_err());
793    }
794
795    #[test]
796    fn test_additional_properties_false() {
797        let schema = json!({
798            "type": "object",
799            "properties": {
800                "name": {"type": "string"}
801            },
802            "additionalProperties": false
803        });
804
805        assert!(validate(&schema, &json!({"name": "Alice"})).is_ok());
806        assert!(validate(&schema, &json!({})).is_ok());
807        assert!(validate(&schema, &json!({"name": "Alice", "extra": 1})).is_err());
808    }
809
810    #[test]
811    fn test_boolean_schema() {
812        // true schema accepts everything
813        assert!(validate(&json!(true), &json!("anything")).is_ok());
814        assert!(validate(&json!(true), &json!(123)).is_ok());
815
816        // false schema rejects everything
817        assert!(validate(&json!(false), &json!("anything")).is_err());
818    }
819
820    #[test]
821    fn test_multiple_errors() {
822        let schema = json!({
823            "type": "object",
824            "properties": {
825                "name": {"type": "string"},
826                "age": {"type": "integer"}
827            },
828            "required": ["name", "age"]
829        });
830
831        let result = validate(&schema, &json!({}));
832        assert!(result.is_err());
833        let errors = result.unwrap_err();
834        assert_eq!(errors.len(), 2); // Missing both name and age
835    }
836
837    #[test]
838    fn test_error_path() {
839        let schema = json!({
840            "type": "object",
841            "properties": {
842                "items": {
843                    "type": "array",
844                    "items": {"type": "integer"}
845                }
846            }
847        });
848
849        let result = validate(&schema, &json!({"items": [1, "two", 3]}));
850        assert!(result.is_err());
851        let errors = result.unwrap_err();
852        assert_eq!(errors.len(), 1);
853        assert_eq!(errors[0].path, "root.items[1]");
854    }
855
856    // ========================================================================
857    // Strict Validation Tests
858    // ========================================================================
859
860    #[test]
861    fn test_validate_strict_rejects_extra_properties() {
862        let schema = json!({
863            "type": "object",
864            "properties": {
865                "name": {"type": "string"}
866            }
867        });
868
869        // Regular validate allows extra properties
870        assert!(validate(&schema, &json!({"name": "Alice", "extra": 123})).is_ok());
871
872        // Strict validate rejects extra properties
873        assert!(validate_strict(&schema, &json!({"name": "Alice", "extra": 123})).is_err());
874
875        // Strict validate allows only defined properties
876        assert!(validate_strict(&schema, &json!({"name": "Alice"})).is_ok());
877    }
878
879    #[test]
880    fn test_validate_strict_nested_objects() {
881        let schema = json!({
882            "type": "object",
883            "properties": {
884                "person": {
885                    "type": "object",
886                    "properties": {
887                        "name": {"type": "string"}
888                    }
889                }
890            }
891        });
892
893        // Regular validate allows extra properties at any level
894        assert!(
895            validate(
896                &schema,
897                &json!({
898                    "person": {"name": "Alice", "age": 30}
899                })
900            )
901            .is_ok()
902        );
903
904        // Strict validate rejects extra properties at nested level
905        assert!(
906            validate_strict(
907                &schema,
908                &json!({
909                    "person": {"name": "Alice", "age": 30}
910                })
911            )
912            .is_err()
913        );
914
915        // Strict validate passes with only defined properties
916        assert!(
917            validate_strict(
918                &schema,
919                &json!({
920                    "person": {"name": "Alice"}
921                })
922            )
923            .is_ok()
924        );
925    }
926
927    #[test]
928    fn test_validate_strict_preserves_explicit_additional_properties() {
929        // Schema explicitly allows additional properties with a specific type
930        let schema = json!({
931            "type": "object",
932            "properties": {
933                "name": {"type": "string"}
934            },
935            "additionalProperties": {"type": "integer"}
936        });
937
938        // With explicit additionalProperties schema, strict mode should honor it
939        assert!(
940            validate_strict(
941                &schema,
942                &json!({
943                    "name": "Alice",
944                    "count": 42
945                })
946            )
947            .is_ok()
948        );
949
950        // But still validate the type of additional properties
951        assert!(
952            validate_strict(
953                &schema,
954                &json!({
955                    "name": "Alice",
956                    "count": "not an integer"
957                })
958            )
959            .is_err()
960        );
961    }
962
963    #[test]
964    fn test_validate_strict_array_items() {
965        let schema = json!({
966            "type": "array",
967            "items": {
968                "type": "object",
969                "properties": {
970                    "id": {"type": "integer"}
971                }
972            }
973        });
974
975        // Regular validate allows extra properties in array items
976        assert!(
977            validate(
978                &schema,
979                &json!([
980                    {"id": 1, "extra": "value"}
981                ])
982            )
983            .is_ok()
984        );
985
986        // Strict validate rejects extra properties in array items
987        assert!(
988            validate_strict(
989                &schema,
990                &json!([
991                    {"id": 1, "extra": "value"}
992                ])
993            )
994            .is_err()
995        );
996
997        // Strict validate passes with only defined properties
998        assert!(
999            validate_strict(
1000                &schema,
1001                &json!([
1002                    {"id": 1}
1003                ])
1004            )
1005            .is_ok()
1006        );
1007    }
1008
1009    #[test]
1010    fn test_validate_strict_empty_schema() {
1011        // Empty schema or true accepts everything
1012        let schema = json!({});
1013
1014        // Empty schema doesn't have type: "object", so strict doesn't add additionalProperties
1015        assert!(validate_strict(&schema, &json!({"anything": "goes"})).is_ok());
1016    }
1017
1018    #[test]
1019    fn test_validate_strict_non_object_types() {
1020        // Strict mode shouldn't affect non-object types
1021        let string_schema = json!({"type": "string"});
1022        assert!(validate_strict(&string_schema, &json!("hello")).is_ok());
1023
1024        let number_schema = json!({"type": "number"});
1025        assert!(validate_strict(&number_schema, &json!(42)).is_ok());
1026
1027        let array_schema = json!({"type": "array"});
1028        assert!(validate_strict(&array_schema, &json!([1, 2, 3])).is_ok());
1029    }
1030
1031    // =========================================================================
1032    // Additional coverage tests (bd-qpwf)
1033    // =========================================================================
1034
1035    #[test]
1036    fn validation_error_display_and_error_trait() {
1037        let err = ValidationError {
1038            path: "root.name".to_string(),
1039            message: "expected string".to_string(),
1040        };
1041        assert_eq!(err.to_string(), "root.name: expected string");
1042        let _: &dyn std::error::Error = &err;
1043    }
1044
1045    #[test]
1046    fn validation_error_debug_and_clone() {
1047        let err = ValidationError {
1048            path: "root".to_string(),
1049            message: "missing".to_string(),
1050        };
1051        let debug = format!("{err:?}");
1052        assert!(debug.contains("ValidationError"));
1053
1054        let cloned = err.clone();
1055        assert_eq!(cloned.path, "root");
1056        assert_eq!(cloned.message, "missing");
1057    }
1058
1059    #[test]
1060    fn json_type_name_all_types() {
1061        assert_eq!(json_type_name(&json!(null)), "null");
1062        assert_eq!(json_type_name(&json!(true)), "boolean");
1063        assert_eq!(json_type_name(&json!(42)), "integer");
1064        assert_eq!(json_type_name(&json!(3.14)), "number");
1065        assert_eq!(json_type_name(&json!("hello")), "string");
1066        assert_eq!(json_type_name(&json!([])), "array");
1067        assert_eq!(json_type_name(&json!({})), "object");
1068    }
1069
1070    #[test]
1071    fn number_multiple_of() {
1072        let schema = json!({"type": "number", "multipleOf": 3});
1073        assert!(validate(&schema, &json!(9)).is_ok());
1074        assert!(validate(&schema, &json!(6)).is_ok());
1075        assert!(validate(&schema, &json!(0)).is_ok());
1076        assert!(validate(&schema, &json!(7)).is_err());
1077    }
1078
1079    #[test]
1080    fn object_min_max_properties() {
1081        let schema = json!({
1082            "type": "object",
1083            "minProperties": 1,
1084            "maxProperties": 2
1085        });
1086
1087        assert!(validate(&schema, &json!({"a": 1})).is_ok());
1088        assert!(validate(&schema, &json!({"a": 1, "b": 2})).is_ok());
1089        assert!(validate(&schema, &json!({})).is_err());
1090        assert!(validate(&schema, &json!({"a": 1, "b": 2, "c": 3})).is_err());
1091    }
1092
1093    #[test]
1094    fn prefix_items_tuple_validation() {
1095        let schema = json!({
1096            "type": "array",
1097            "prefixItems": [
1098                {"type": "string"},
1099                {"type": "integer"}
1100            ]
1101        });
1102
1103        assert!(validate(&schema, &json!(["hello", 42])).is_ok());
1104        assert!(validate(&schema, &json!(["hello", 42, true])).is_ok()); // extra items allowed by default
1105        assert!(validate(&schema, &json!([123, "wrong"])).is_err()); // first should be string
1106    }
1107
1108    #[test]
1109    fn prefix_items_with_additional_items_schema() {
1110        let schema = json!({
1111            "type": "array",
1112            "prefixItems": [
1113                {"type": "string"}
1114            ],
1115            "items": {"type": "integer"}
1116        });
1117
1118        // First item must be string, rest must be integers
1119        assert!(validate(&schema, &json!(["hello", 1, 2])).is_ok());
1120        assert!(validate(&schema, &json!(["hello", "bad"])).is_err());
1121    }
1122
1123    #[test]
1124    fn items_as_array_draft4_fallback() {
1125        // Draft 4-7 style: items is an array (treated as prefixItems when no prefixItems present)
1126        let schema = json!({
1127            "type": "array",
1128            "items": [
1129                {"type": "string"},
1130                {"type": "integer"}
1131            ]
1132        });
1133
1134        assert!(validate(&schema, &json!(["hello", 42])).is_ok());
1135        assert!(validate(&schema, &json!([123, "wrong"])).is_err());
1136    }
1137
1138    #[test]
1139    fn additional_properties_as_schema() {
1140        let schema = json!({
1141            "type": "object",
1142            "properties": {
1143                "name": {"type": "string"}
1144            },
1145            "additionalProperties": {"type": "integer"}
1146        });
1147
1148        assert!(validate(&schema, &json!({"name": "Alice", "count": 42})).is_ok());
1149        assert!(validate(&schema, &json!({"name": "Alice", "bad": "string"})).is_err());
1150    }
1151
1152    #[test]
1153    fn strict_schema_with_prefix_items() {
1154        let schema = json!({
1155            "type": "array",
1156            "prefixItems": [
1157                {
1158                    "type": "object",
1159                    "properties": {
1160                        "id": {"type": "integer"}
1161                    }
1162                }
1163            ]
1164        });
1165
1166        // Strict mode adds additionalProperties: false to nested objects in prefixItems
1167        assert!(validate_strict(&schema, &json!([{"id": 1}])).is_ok());
1168        assert!(validate_strict(&schema, &json!([{"id": 1, "extra": "val"}])).is_err());
1169    }
1170
1171    #[test]
1172    fn strict_schema_with_union_type() {
1173        let schema = json!({
1174            "type": ["object", "null"],
1175            "properties": {
1176                "name": {"type": "string"}
1177            }
1178        });
1179
1180        // Strict mode should add additionalProperties for union types including object
1181        assert!(validate_strict(&schema, &json!(null)).is_ok());
1182        assert!(validate_strict(&schema, &json!({"name": "Alice"})).is_ok());
1183        assert!(validate_strict(&schema, &json!({"name": "Alice", "extra": 1})).is_err());
1184    }
1185
1186    #[test]
1187    fn unknown_type_in_matches_type_is_permissive() {
1188        let schema = json!({"type": "custom_extension"});
1189        // Unknown types accept everything
1190        assert!(validate(&schema, &json!("anything")).is_ok());
1191        assert!(validate(&schema, &json!(42)).is_ok());
1192    }
1193
1194    #[test]
1195    fn invalid_schema_not_an_object() {
1196        // Non-object, non-boolean schemas are silently skipped
1197        assert!(validate(&json!(42), &json!("anything")).is_ok());
1198        assert!(validate(&json!("bad_schema"), &json!(123)).is_ok());
1199    }
1200}