Skip to main content

rig_core/tool/
validate.rs

1//! Whether a value fits a JSON schema, and where it does not: the check a
2//! tool call's arguments pass before the tool runs
3//! ([`arguments_refusal`](crate::tool::feedback::arguments_refusal)).
4//!
5//! ```
6//! use rig_core::tool::validate::{Problem, validate};
7//! use serde_json::json;
8//!
9//! let schema = json!({
10//!     "type": "object",
11//!     "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}},
12//!     "required": ["path"],
13//! });
14//! assert_eq!(validate(&schema, &json!({"path": "a.rs", "limit": 3})), Ok(()));
15//! let problems = validate(&schema, &json!({"limit": "3"})).unwrap_err();
16//! assert_eq!(
17//!     problems.iter().map(Problem::to_string).collect::<Vec<_>>(),
18//!     ["/limit: expected an integer, got a string", "/path: missing"],
19//! );
20//! ```
21//!
22//! It checks a subset of JSON Schema, in plain `serde_json`:
23//! - `type` (a name or a list; `integer` is a number with no fraction),
24//!   `enum` and `const`;
25//! - `properties`, `required` and `additionalProperties` (`false` or a
26//!   schema);
27//! - `items` (a schema, or a list by position as in draft-07),
28//!   `prefixItems`, `minItems` and `maxItems`;
29//! - `minLength` and `maxLength`, in characters;
30//! - `minimum`, `maximum`, `exclusiveMinimum` and `exclusiveMaximum`;
31//! - `allOf`, `anyOf` and `oneOf` (exactly one);
32//! - `$ref` to any local JSON pointer (`#`, `#/$defs/…`, draft-07's
33//!   `#/definitions/…`), resolved against the root schema. A remote or
34//!   unresolvable `$ref`, or one that loops back without reaching a value,
35//!   **accepts** the value there.
36//!
37//! A `null` for a declared property the object does not require counts as
38//! absent, as serde reads an `Option` field:
39//! [`args_schema`](crate::tool::args_schema) declares an `Option` optional,
40//! not nullable, and a model may still send `null` for it.
41//!
42//! Every other keyword is ignored, so it accepts: `pattern`, `format`,
43//! `patternProperties` (and so `additionalProperties` beside it, since
44//! which keys a pattern allows is not checked), and any keyword not listed.
45
46use std::fmt;
47
48use serde_json::{Map, Number, Value};
49
50/// Where a value does not fit a schema, and how.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct Problem {
53    /// A JSON pointer to the value, such as `/edits/0/old_text`; empty for
54    /// the whole value.
55    pub at: String,
56    /// What is wrong there, such as `expected a string, got a number`.
57    pub what: String,
58}
59
60impl fmt::Display for Problem {
61    /// `at: what`, or `what` alone for the whole value.
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        if self.at.is_empty() {
64            f.write_str(&self.what)
65        } else {
66            write!(f, "{}: {}", self.at, self.what)
67        }
68    }
69}
70
71/// Checks `value` against `schema`: `Ok` when it fits; otherwise every
72/// problem, in document order. The [module docs](self) list what is
73/// checked.
74pub fn validate(schema: &Value, value: &Value) -> Result<(), Vec<Problem>> {
75    let problems = Validator { root: schema }.check(schema, value, "", &[]);
76    if problems.is_empty() {
77        Ok(())
78    } else {
79        Err(problems)
80    }
81}
82
83/// Checks values against subschemas of `root`, which `$ref`s resolve in.
84struct Validator<'s> {
85    root: &'s Value,
86}
87
88impl<'s> Validator<'s> {
89    /// The problems of `value`, at pointer `at`, against `schema`. `refs`
90    /// are the references already followed at this value, so a loop of
91    /// them ends.
92    fn check(&self, schema: &'s Value, value: &Value, at: &str, refs: &[&'s str]) -> Vec<Problem> {
93        let schema = match schema {
94            Value::Object(schema) => schema,
95            Value::Bool(false) => return vec![problem(at, "no value is allowed here")],
96            _ => return Vec::new(),
97        };
98        let mut problems = Vec::new();
99        if let Some(Value::String(reference)) = schema.get("$ref")
100            && !refs.contains(&reference.as_str())
101            && let Some(target) = self.resolve(reference)
102        {
103            let mut followed = refs.to_vec();
104            followed.push(reference);
105            problems.extend(self.check(target, value, at, &followed));
106        }
107        if let Some(types) = schema.get("type")
108            && !fits_type(types, value)
109        {
110            problems.push(problem(
111                at,
112                format!("expected {}, got {}", expected(types), kind(value)),
113            ));
114            return problems;
115        }
116        if let Some(Value::Array(allowed)) = schema.get("enum")
117            && !allowed.iter().any(|it| same(it, value))
118        {
119            let allowed: Vec<String> = allowed.iter().map(Value::to_string).collect();
120            let what = format!(
121                "expected one of {}, got {}",
122                allowed.join(", "),
123                shown(value)
124            );
125            problems.push(problem(at, what));
126        }
127        if let Some(constant) = schema.get("const")
128            && !same(constant, value)
129        {
130            problems.push(problem(
131                at,
132                format!("expected {constant}, got {}", shown(value)),
133            ));
134        }
135        match value {
136            Value::Object(fields) => problems.extend(self.object(schema, fields, at)),
137            Value::Array(items) => problems.extend(self.array(schema, items, at)),
138            Value::String(text) => {
139                let length = text.chars().count();
140                problems.extend(count(
141                    schema,
142                    "minLength",
143                    "maxLength",
144                    "character",
145                    length,
146                    at,
147                ));
148            }
149            Value::Number(number) => problems.extend(bounds(schema, number, at)),
150            Value::Null | Value::Bool(_) => {}
151        }
152        if let Some(Value::Array(all)) = schema.get("allOf") {
153            for alternative in all {
154                problems.extend(self.check(alternative, value, at, refs));
155            }
156        }
157        if let Some(Value::Array(any)) = schema.get("anyOf") {
158            let results: Vec<_> = any
159                .iter()
160                .map(|it| self.check(it, value, at, refs))
161                .collect();
162            if !results.iter().any(Vec::is_empty) {
163                problems.extend(closest(any, results, value, at, "anyOf"));
164            }
165        }
166        if let Some(Value::Array(one)) = schema.get("oneOf") {
167            let results: Vec<_> = one
168                .iter()
169                .map(|it| self.check(it, value, at, refs))
170                .collect();
171            match results.iter().filter(|it| it.is_empty()).count() {
172                0 => problems.extend(closest(one, results, value, at, "oneOf")),
173                1 => {}
174                fitting => problems.push(problem(
175                    at,
176                    format!("fits {fitting} of its `oneOf` alternatives, not exactly one"),
177                )),
178            }
179        }
180        problems
181    }
182
183    /// The problems of an object's fields: each against its property's
184    /// schema or `additionalProperties`, then each required one missing. A
185    /// `null` for a declared property it does not require counts as absent,
186    /// as serde reads an `Option`.
187    fn object(
188        &self,
189        schema: &'s Map<String, Value>,
190        fields: &Map<String, Value>,
191        at: &str,
192    ) -> Vec<Problem> {
193        let mut problems = Vec::new();
194        let properties = schema.get("properties").and_then(Value::as_object);
195        let additional = schema
196            .get("additionalProperties")
197            .filter(|_| !schema.contains_key("patternProperties"));
198        let required: Vec<&str> = match schema.get("required") {
199            Some(Value::Array(required)) => required.iter().filter_map(Value::as_str).collect(),
200            _ => Vec::new(),
201        };
202        for (key, field) in fields {
203            let at = child(at, key);
204            match (
205                properties.and_then(|declared| declared.get(key)),
206                additional,
207            ) {
208                (Some(_), _) if field.is_null() && !required.contains(&key.as_str()) => {}
209                (Some(property), _) => problems.extend(self.check(property, field, &at, &[])),
210                (None, Some(Value::Bool(false))) => {
211                    let allowed: Vec<String> = properties
212                        .into_iter()
213                        .flat_map(Map::keys)
214                        .map(|name| format!("`{name}`"))
215                        .collect();
216                    let what = if allowed.is_empty() {
217                        "not allowed: no keys are".to_owned()
218                    } else {
219                        format!("not allowed; the keys are {}", allowed.join(", "))
220                    };
221                    problems.push(problem(&at, what));
222                }
223                (None, Some(additional)) => {
224                    problems.extend(self.check(additional, field, &at, &[]))
225                }
226                (None, None) => {}
227            }
228        }
229        for name in required {
230            if !fields.contains_key(name) {
231                problems.push(problem(&child(at, name), "missing"));
232            }
233        }
234        problems
235    }
236
237    /// The problems of an array's items, by position (`prefixItems`, or a
238    /// draft-07 `items` list) and then each against `items`, and of its
239    /// length.
240    fn array(&self, schema: &'s Map<String, Value>, items: &[Value], at: &str) -> Vec<Problem> {
241        let mut problems = count(schema, "minItems", "maxItems", "item", items.len(), at);
242        let (prefix, rest) = match (schema.get("prefixItems"), schema.get("items")) {
243            (Some(Value::Array(prefix)), rest) => (prefix.as_slice(), rest),
244            (_, Some(Value::Array(prefix))) => (prefix.as_slice(), None),
245            (_, rest) => (&[][..], rest),
246        };
247        for (index, item) in items.iter().enumerate() {
248            let subschema = prefix
249                .get(index)
250                .or_else(|| rest.filter(|_| index >= prefix.len()));
251            if let Some(subschema) = subschema {
252                problems.extend(self.check(subschema, item, &child(at, &index.to_string()), &[]));
253            }
254        }
255        problems
256    }
257
258    /// The subschema a local `$ref` (`#` or `#/a/b`) points to.
259    fn resolve(&self, reference: &str) -> Option<&'s Value> {
260        let pointer = reference.strip_prefix('#')?;
261        if pointer.is_empty() {
262            Some(self.root)
263        } else if pointer.starts_with('/') {
264            self.root.pointer(pointer)
265        } else {
266            None
267        }
268    }
269}
270
271/// When no alternative of `anyOf`/`oneOf` fits: the types they expect, if
272/// none is of `value`'s type; else the problems of the alternative that
273/// failed only inside the value and had the fewest; else that none fits.
274fn closest(
275    alternatives: &[Value],
276    results: Vec<Vec<Problem>>,
277    value: &Value,
278    at: &str,
279    keyword: &str,
280) -> Vec<Problem> {
281    let types: Option<Vec<&Value>> = alternatives
282        .iter()
283        .map(|alternative| {
284            alternative
285                .get("type")
286                .filter(|types| !fits_type(types, value))
287        })
288        .collect();
289    if let Some(types) = types.filter(|types| !types.is_empty()) {
290        let names: Vec<String> = types.into_iter().map(expected).collect();
291        return vec![problem(
292            at,
293            format!("expected {}, got {}", names.join(" or "), kind(value)),
294        )];
295    }
296    results
297        .into_iter()
298        .filter(|problems| problems.iter().all(|it| it.at.len() > at.len()))
299        .min_by_key(Vec::len)
300        .unwrap_or_else(|| {
301            vec![problem(
302                at,
303                format!("fits none of its `{keyword}` alternatives"),
304            )]
305        })
306}
307
308/// The problems of a length, in `unit`s, against `schema`'s `min` and
309/// `max` keywords.
310fn count(
311    schema: &Map<String, Value>,
312    min: &str,
313    max: &str,
314    unit: &str,
315    length: usize,
316    at: &str,
317) -> Vec<Problem> {
318    let limit = |key| schema.get(key).and_then(Value::as_u64);
319    let plural = |n: u64| {
320        if n == 1 {
321            format!("{n} {unit}")
322        } else {
323            format!("{n} {unit}s")
324        }
325    };
326    let length = u64::try_from(length).unwrap_or(u64::MAX);
327    let mut problems = Vec::new();
328    if let Some(min) = limit(min).filter(|min| length < *min) {
329        problems.push(problem(
330            at,
331            format!("expected at least {}, got {length}", plural(min)),
332        ));
333    }
334    if let Some(max) = limit(max).filter(|max| length > *max) {
335        problems.push(problem(
336            at,
337            format!("expected at most {}, got {length}", plural(max)),
338        ));
339    }
340    problems
341}
342
343/// The problems of a number against `schema`'s bounds.
344fn bounds(schema: &Map<String, Value>, number: &Number, at: &str) -> Vec<Problem> {
345    let Some(n) = number.as_f64() else {
346        return Vec::new();
347    };
348    let checks: [(&str, &str, fn(f64, f64) -> bool); 4] = [
349        ("minimum", "at least", |n, bound| n >= bound),
350        ("maximum", "at most", |n, bound| n <= bound),
351        ("exclusiveMinimum", "more than", |n, bound| n > bound),
352        ("exclusiveMaximum", "less than", |n, bound| n < bound),
353    ];
354    checks
355        .into_iter()
356        .filter_map(|(key, words, fits)| {
357            let bound = schema.get(key).filter(|bound| bound.is_number())?;
358            let fit = bound.as_f64().is_none_or(|bound| fits(n, bound));
359            (!fit).then(|| problem(at, format!("expected {words} {bound}, got {number}")))
360        })
361        .collect()
362}
363
364/// Whether `value` is of the type `types` names, or one of those it lists.
365/// An unknown type name accepts.
366fn fits_type(types: &Value, value: &Value) -> bool {
367    let is = |name: &str| match name {
368        "string" => value.is_string(),
369        "integer" => value.as_number().is_some_and(integral),
370        "number" => value.is_number(),
371        "boolean" => value.is_boolean(),
372        "array" => value.is_array(),
373        "object" => value.is_object(),
374        "null" => value.is_null(),
375        _ => true,
376    };
377    match types {
378        Value::String(name) => is(name),
379        Value::Array(names) => names.iter().filter_map(Value::as_str).any(is),
380        _ => true,
381    }
382}
383
384/// The type `types` names, or those it lists, as words: `a string or null`.
385fn expected(types: &Value) -> String {
386    let word = |name: &str| match name {
387        "string" | "number" | "boolean" => format!("a {name}"),
388        "integer" | "array" | "object" => format!("an {name}"),
389        _ => name.to_owned(),
390    };
391    match types {
392        Value::String(name) => word(name),
393        Value::Array(names) => {
394            let words: Vec<String> = names.iter().filter_map(Value::as_str).map(word).collect();
395            words.join(" or ")
396        }
397        other => other.to_string(),
398    }
399}
400
401/// `value`'s type as words, for a problem's "got".
402fn kind(value: &Value) -> &'static str {
403    match value {
404        Value::Null => "null",
405        Value::Bool(_) => "a boolean",
406        Value::Number(number) if integral(number) => "a number",
407        Value::Number(_) => "a number with a fraction",
408        Value::String(_) => "a string",
409        Value::Array(_) => "an array",
410        Value::Object(_) => "an object",
411    }
412}
413
414/// `value` itself when it is short to quote, else its type.
415fn shown(value: &Value) -> String {
416    match value {
417        Value::Array(_) | Value::Object(_) => kind(value).to_owned(),
418        Value::String(text) if text.chars().count() > 40 => kind(value).to_owned(),
419        scalar => scalar.to_string(),
420    }
421}
422
423/// Whether a number has no fraction: `1` and `1.0` do, `1.5` does not.
424fn integral(number: &Number) -> bool {
425    number.is_i64() || number.is_u64() || number.as_f64().is_some_and(|n| n.fract() == 0.0)
426}
427
428/// Equality as JSON Schema means it, where `1` and `1.0` are the same
429/// number.
430fn same(a: &Value, b: &Value) -> bool {
431    match (a, b) {
432        (Value::Number(a), Value::Number(b)) => {
433            a == b || ((a.is_f64() || b.is_f64()) && a.as_f64() == b.as_f64())
434        }
435        (Value::Array(a), Value::Array(b)) => {
436            a.len() == b.len() && a.iter().zip(b).all(|(a, b)| same(a, b))
437        }
438        (Value::Object(a), Value::Object(b)) => {
439            a.len() == b.len()
440                && a.iter()
441                    .all(|(key, a)| b.get(key).is_some_and(|b| same(a, b)))
442        }
443        (a, b) => a == b,
444    }
445}
446
447/// The pointer to `key` inside the value at `at`.
448fn child(at: &str, key: &str) -> String {
449    format!("{at}/{}", key.replace('~', "~0").replace('/', "~1"))
450}
451
452fn problem(at: &str, what: impl Into<String>) -> Problem {
453    Problem {
454        at: at.to_owned(),
455        what: what.into(),
456    }
457}
458
459#[cfg(test)]
460mod tests;