Skip to main content

agentzero_core/
validation.rs

1//! Lightweight JSON Schema validator for tool input schemas.
2//!
3//! Supports the subset of JSON Schema used by tool definitions:
4//! - `type`: "object", "string", "number", "integer", "boolean", "array", "null"
5//! - `required`: array of required field names (for objects)
6//! - `properties`: map of field name → sub-schema (for objects)
7//! - `items`: schema for array items
8//! - `enum`: list of allowed values
9
10use serde_json::Value;
11
12/// Validate a JSON value against a JSON Schema.
13///
14/// Returns `Ok(())` if the value conforms to the schema, or `Err(errors)` with
15/// a list of human-readable validation error messages.
16pub fn validate_json(value: &Value, schema: &Value) -> Result<(), Vec<String>> {
17    let mut errors = Vec::new();
18    validate_inner(value, schema, "", &mut errors);
19    if errors.is_empty() {
20        Ok(())
21    } else {
22        Err(errors)
23    }
24}
25
26fn validate_inner(value: &Value, schema: &Value, path: &str, errors: &mut Vec<String>) {
27    // Empty schema or boolean true accepts anything.
28    if schema.is_boolean() || schema.as_object().is_some_and(|o| o.is_empty()) {
29        return;
30    }
31
32    let schema_obj = match schema.as_object() {
33        Some(obj) => obj,
34        None => return,
35    };
36
37    // Check `enum` constraint.
38    if let Some(enum_values) = schema_obj.get("enum").and_then(|v| v.as_array()) {
39        if !enum_values.contains(value) {
40            let allowed: Vec<String> = enum_values.iter().map(|v| v.to_string()).collect();
41            errors.push(format!(
42                "{}: value {} is not one of [{}]",
43                display_path(path),
44                value,
45                allowed.join(", ")
46            ));
47        }
48    }
49
50    // Check `type` constraint.
51    if let Some(expected_type) = schema_obj.get("type").and_then(|v| v.as_str()) {
52        if !type_matches(value, expected_type) {
53            errors.push(format!(
54                "{}: expected type \"{}\", got {}",
55                display_path(path),
56                expected_type,
57                json_type_name(value)
58            ));
59            return; // No point checking sub-constraints if type is wrong.
60        }
61    }
62
63    // Object-specific checks.
64    if let Some(obj) = value.as_object() {
65        // Check `required` fields.
66        if let Some(required) = schema_obj.get("required").and_then(|v| v.as_array()) {
67            for req in required {
68                if let Some(field_name) = req.as_str() {
69                    if !obj.contains_key(field_name) {
70                        errors.push(format!(
71                            "{}: missing required field \"{}\"",
72                            display_path(path),
73                            field_name
74                        ));
75                    }
76                }
77            }
78        }
79
80        // Recurse into `properties`.
81        if let Some(properties) = schema_obj.get("properties").and_then(|v| v.as_object()) {
82            for (prop_name, prop_schema) in properties {
83                if let Some(prop_value) = obj.get(prop_name) {
84                    let child_path = if path.is_empty() {
85                        prop_name.clone()
86                    } else {
87                        format!("{}.{}", path, prop_name)
88                    };
89                    validate_inner(prop_value, prop_schema, &child_path, errors);
90                }
91            }
92        }
93    }
94
95    // Array-specific checks.
96    if let Some(arr) = value.as_array() {
97        if let Some(items_schema) = schema_obj.get("items") {
98            for (i, item) in arr.iter().enumerate() {
99                let child_path = format!("{}[{}]", path, i);
100                validate_inner(item, items_schema, &child_path, errors);
101            }
102        }
103    }
104}
105
106fn type_matches(value: &Value, expected: &str) -> bool {
107    match expected {
108        "object" => value.is_object(),
109        "string" => value.is_string(),
110        "number" => value.is_number(),
111        "integer" => value.is_i64() || value.is_u64(),
112        "boolean" => value.is_boolean(),
113        "array" => value.is_array(),
114        "null" => value.is_null(),
115        _ => true, // Unknown type — don't reject.
116    }
117}
118
119fn json_type_name(value: &Value) -> &'static str {
120    match value {
121        Value::Null => "null",
122        Value::Bool(_) => "boolean",
123        Value::Number(n) => {
124            if n.is_i64() || n.is_u64() {
125                "integer"
126            } else {
127                "number"
128            }
129        }
130        Value::String(_) => "string",
131        Value::Array(_) => "array",
132        Value::Object(_) => "object",
133    }
134}
135
136fn display_path(path: &str) -> &str {
137    if path.is_empty() {
138        "$"
139    } else {
140        path
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use serde_json::json;
148
149    #[test]
150    fn empty_schema_accepts_anything() {
151        assert!(validate_json(&json!(42), &json!({})).is_ok());
152        assert!(validate_json(&json!("hello"), &json!({})).is_ok());
153        assert!(validate_json(&json!(null), &json!({})).is_ok());
154    }
155
156    #[test]
157    fn type_string_valid() {
158        let schema = json!({"type": "string"});
159        assert!(validate_json(&json!("hello"), &schema).is_ok());
160    }
161
162    #[test]
163    fn type_string_invalid() {
164        let schema = json!({"type": "string"});
165        let result = validate_json(&json!(42), &schema);
166        assert!(result.is_err());
167        let errors = result.unwrap_err();
168        assert_eq!(errors.len(), 1);
169        assert!(errors[0].contains("expected type \"string\""));
170    }
171
172    #[test]
173    fn type_object_valid() {
174        let schema = json!({"type": "object"});
175        assert!(validate_json(&json!({"a": 1}), &schema).is_ok());
176    }
177
178    #[test]
179    fn type_integer_valid() {
180        let schema = json!({"type": "integer"});
181        assert!(validate_json(&json!(42), &schema).is_ok());
182    }
183
184    #[test]
185    fn type_integer_rejects_float() {
186        let schema = json!({"type": "integer"});
187        let result = validate_json(&json!(2.5), &schema);
188        assert!(result.is_err());
189    }
190
191    #[test]
192    fn type_number_accepts_float_and_int() {
193        let schema = json!({"type": "number"});
194        assert!(validate_json(&json!(42), &schema).is_ok());
195        assert!(validate_json(&json!(2.5), &schema).is_ok());
196    }
197
198    #[test]
199    fn type_boolean_valid() {
200        let schema = json!({"type": "boolean"});
201        assert!(validate_json(&json!(true), &schema).is_ok());
202    }
203
204    #[test]
205    fn type_array_valid() {
206        let schema = json!({"type": "array"});
207        assert!(validate_json(&json!([1, 2, 3]), &schema).is_ok());
208    }
209
210    #[test]
211    fn type_null_valid() {
212        let schema = json!({"type": "null"});
213        assert!(validate_json(&json!(null), &schema).is_ok());
214    }
215
216    #[test]
217    fn required_fields_present() {
218        let schema = json!({
219            "type": "object",
220            "required": ["name", "age"],
221            "properties": {
222                "name": {"type": "string"},
223                "age": {"type": "integer"}
224            }
225        });
226        assert!(validate_json(&json!({"name": "Alice", "age": 30}), &schema).is_ok());
227    }
228
229    #[test]
230    fn required_field_missing() {
231        let schema = json!({
232            "type": "object",
233            "required": ["name", "age"],
234            "properties": {
235                "name": {"type": "string"},
236                "age": {"type": "integer"}
237            }
238        });
239        let result = validate_json(&json!({"name": "Alice"}), &schema);
240        assert!(result.is_err());
241        let errors = result.unwrap_err();
242        assert_eq!(errors.len(), 1);
243        assert!(errors[0].contains("missing required field \"age\""));
244    }
245
246    #[test]
247    fn nested_object_validation() {
248        let schema = json!({
249            "type": "object",
250            "properties": {
251                "address": {
252                    "type": "object",
253                    "required": ["city"],
254                    "properties": {
255                        "city": {"type": "string"},
256                        "zip": {"type": "string"}
257                    }
258                }
259            }
260        });
261        // Valid
262        assert!(validate_json(&json!({"address": {"city": "NYC"}}), &schema).is_ok());
263        // Invalid: city is wrong type
264        let result = validate_json(&json!({"address": {"city": 123}}), &schema);
265        assert!(result.is_err());
266        assert!(result.unwrap_err()[0].contains("address.city"));
267    }
268
269    #[test]
270    fn array_items_validation() {
271        let schema = json!({
272            "type": "array",
273            "items": {"type": "string"}
274        });
275        assert!(validate_json(&json!(["a", "b", "c"]), &schema).is_ok());
276
277        let result = validate_json(&json!(["a", 42, "c"]), &schema);
278        assert!(result.is_err());
279        let errors = result.unwrap_err();
280        assert_eq!(errors.len(), 1);
281        assert!(errors[0].contains("[1]"));
282    }
283
284    #[test]
285    fn enum_validation_valid() {
286        let schema = json!({
287            "type": "string",
288            "enum": ["red", "green", "blue"]
289        });
290        assert!(validate_json(&json!("red"), &schema).is_ok());
291    }
292
293    #[test]
294    fn enum_validation_invalid() {
295        let schema = json!({
296            "type": "string",
297            "enum": ["red", "green", "blue"]
298        });
299        let result = validate_json(&json!("yellow"), &schema);
300        assert!(result.is_err());
301        assert!(result.unwrap_err()[0].contains("not one of"));
302    }
303
304    #[test]
305    fn multiple_errors_reported() {
306        let schema = json!({
307            "type": "object",
308            "required": ["a", "b"],
309            "properties": {
310                "a": {"type": "string"},
311                "b": {"type": "integer"}
312            }
313        });
314        // Missing both required fields
315        let result = validate_json(&json!({}), &schema);
316        assert!(result.is_err());
317        assert_eq!(result.unwrap_err().len(), 2);
318    }
319
320    #[test]
321    fn extra_properties_allowed() {
322        let schema = json!({
323            "type": "object",
324            "properties": {
325                "name": {"type": "string"}
326            }
327        });
328        // Extra field "extra" should be accepted (no additionalProperties restriction)
329        assert!(validate_json(&json!({"name": "Alice", "extra": true}), &schema).is_ok());
330    }
331
332    #[test]
333    fn wrong_type_at_root_stops_early() {
334        let schema = json!({
335            "type": "object",
336            "required": ["name"],
337            "properties": {"name": {"type": "string"}}
338        });
339        let result = validate_json(&json!("not an object"), &schema);
340        assert!(result.is_err());
341        // Should only report the type mismatch, not the missing field
342        assert_eq!(result.unwrap_err().len(), 1);
343    }
344}