Skip to main content

ferrum_sampler/
schema_validation.rs

1//! Semantic validation for the JSON Schema subset used by guided decoding.
2//!
3//! Generation constraints and final validation have different jobs: the
4//! regex translator limits token choices, while this module validates parsed
5//! JSON without depending on property order or whitespace serialization.
6
7use serde_json::{Map, Value};
8
9#[derive(Debug, thiserror::Error, PartialEq, Eq)]
10pub enum JsonSchemaValidationError {
11    #[error("invalid JSON Schema at {path}: {message}")]
12    InvalidSchema { path: String, message: String },
13    #[error("JSON value at {path} does not satisfy schema: {message}")]
14    Mismatch { path: String, message: String },
15}
16
17pub fn validate_json_schema_value(
18    schema: &Value,
19    value: &Value,
20) -> Result<(), JsonSchemaValidationError> {
21    validate_at(schema, value, "$".to_string())
22}
23
24fn validate_at(
25    schema: &Value,
26    value: &Value,
27    path: String,
28) -> Result<(), JsonSchemaValidationError> {
29    let schema = schema
30        .as_object()
31        .ok_or_else(|| invalid_schema(&path, "schema must be an object"))?;
32
33    if let Some(expected) = schema.get("const") {
34        if value != expected {
35            return Err(mismatch(&path, "value differs from const"));
36        }
37    }
38    if let Some(allowed) = schema.get("enum") {
39        let allowed = allowed
40            .as_array()
41            .ok_or_else(|| invalid_schema(&path, "enum must be an array"))?;
42        if allowed.is_empty() {
43            return Err(invalid_schema(&path, "enum must not be empty"));
44        }
45        if !allowed.contains(value) {
46            return Err(mismatch(&path, "value is not in enum"));
47        }
48    }
49
50    let Some(schema_type) = schema.get("type") else {
51        if schema.contains_key("const") || schema.contains_key("enum") {
52            return Ok(());
53        }
54        return Err(invalid_schema(&path, "missing type, const, or enum"));
55    };
56    let schema_type = schema_type
57        .as_str()
58        .ok_or_else(|| invalid_schema(&path, "type must be a string"))?;
59
60    match schema_type {
61        "object" => validate_object(schema, value, &path),
62        "array" => validate_array(schema, value, &path),
63        "string" => validate_string(schema, value, &path),
64        "integer" if is_json_integer(value) => Ok(()),
65        "integer" => Err(mismatch(&path, "expected integer")),
66        "number" if value.is_number() => Ok(()),
67        "number" => Err(mismatch(&path, "expected number")),
68        "boolean" if value.is_boolean() => Ok(()),
69        "boolean" => Err(mismatch(&path, "expected boolean")),
70        "null" if value.is_null() => Ok(()),
71        "null" => Err(mismatch(&path, "expected null")),
72        other => Err(invalid_schema(&path, format!("unsupported type '{other}'"))),
73    }
74}
75
76fn validate_object(
77    schema: &Map<String, Value>,
78    value: &Value,
79    path: &str,
80) -> Result<(), JsonSchemaValidationError> {
81    let object = value
82        .as_object()
83        .ok_or_else(|| mismatch(path, "expected object"))?;
84    let properties = schema
85        .get("properties")
86        .map(|value| {
87            value
88                .as_object()
89                .ok_or_else(|| invalid_schema(path, "properties must be an object"))
90        })
91        .transpose()?;
92
93    if let Some(required) = schema.get("required") {
94        let required = required
95            .as_array()
96            .ok_or_else(|| invalid_schema(path, "required must be an array"))?;
97        for key in required {
98            let key = key
99                .as_str()
100                .ok_or_else(|| invalid_schema(path, "required entries must be strings"))?;
101            if !object.contains_key(key) {
102                return Err(mismatch(path, format!("missing required property '{key}'")));
103            }
104        }
105    }
106
107    for (key, member) in object {
108        let member_path = format!("{path}.{key}");
109        if let Some(member_schema) = properties.and_then(|properties| properties.get(key)) {
110            validate_at(member_schema, member, member_path)?;
111            continue;
112        }
113        match schema.get("additionalProperties") {
114            Some(Value::Bool(false)) => {
115                return Err(mismatch(path, format!("unexpected property '{key}'")));
116            }
117            Some(Value::Object(member_schema)) => {
118                validate_at(&Value::Object(member_schema.clone()), member, member_path)?;
119            }
120            Some(Value::Bool(true)) | None => {}
121            Some(_) => {
122                return Err(invalid_schema(
123                    path,
124                    "additionalProperties must be a boolean or schema object",
125                ));
126            }
127        }
128    }
129    Ok(())
130}
131
132fn validate_array(
133    schema: &Map<String, Value>,
134    value: &Value,
135    path: &str,
136) -> Result<(), JsonSchemaValidationError> {
137    let array = value
138        .as_array()
139        .ok_or_else(|| mismatch(path, "expected array"))?;
140    let items = schema
141        .get("items")
142        .ok_or_else(|| invalid_schema(path, "array schema missing items"))?;
143    for (index, member) in array.iter().enumerate() {
144        validate_at(items, member, format!("{path}[{index}]"))?;
145    }
146    Ok(())
147}
148
149fn validate_string(
150    schema: &Map<String, Value>,
151    value: &Value,
152    path: &str,
153) -> Result<(), JsonSchemaValidationError> {
154    let text = value
155        .as_str()
156        .ok_or_else(|| mismatch(path, "expected string"))?;
157    let char_count = text.chars().count() as u64;
158    if let Some(min) = schema.get("minLength") {
159        let min = min
160            .as_u64()
161            .ok_or_else(|| invalid_schema(path, "minLength must be a non-negative integer"))?;
162        if char_count < min {
163            return Err(mismatch(path, format!("string length is below {min}")));
164        }
165    }
166    if let Some(max) = schema.get("maxLength") {
167        let max = max
168            .as_u64()
169            .ok_or_else(|| invalid_schema(path, "maxLength must be a non-negative integer"))?;
170        if char_count > max {
171            return Err(mismatch(path, format!("string length exceeds {max}")));
172        }
173    }
174    Ok(())
175}
176
177fn is_json_integer(value: &Value) -> bool {
178    let Some(number) = value.as_number() else {
179        return false;
180    };
181    number.as_i64().is_some()
182        || number.as_u64().is_some()
183        || number.as_f64().is_some_and(|value| value.fract() == 0.0)
184}
185
186fn invalid_schema(path: &str, message: impl Into<String>) -> JsonSchemaValidationError {
187    JsonSchemaValidationError::InvalidSchema {
188        path: path.to_string(),
189        message: message.into(),
190    }
191}
192
193fn mismatch(path: &str, message: impl Into<String>) -> JsonSchemaValidationError {
194    JsonSchemaValidationError::Mismatch {
195        path: path.to_string(),
196        message: message.into(),
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use serde_json::json;
204
205    #[test]
206    fn validates_required_const_property_without_serialization_constraints() {
207        let schema = json!({
208            "type": "object",
209            "properties": {"value": {"type": "string", "const": "marker-21"}},
210            "required": ["value"],
211            "additionalProperties": false
212        });
213        validate_json_schema_value(&schema, &json!({"value": "marker-21"})).unwrap();
214        let error = validate_json_schema_value(&schema, &json!({"value": "wrong"})).unwrap_err();
215        assert!(error.to_string().contains("differs from const"));
216    }
217
218    #[test]
219    fn validates_optional_properties_and_rejects_unknown_properties() {
220        let schema = json!({
221            "type": "object",
222            "properties": {
223                "required": {"type": "string"},
224                "optional": {"type": "integer"}
225            },
226            "required": ["required"],
227            "additionalProperties": false
228        });
229        validate_json_schema_value(&schema, &json!({"optional": 2, "required": "ok"})).unwrap();
230        let error =
231            validate_json_schema_value(&schema, &json!({"required": "ok", "unknown": true}))
232                .unwrap_err();
233        assert!(error.to_string().contains("unexpected property 'unknown'"));
234    }
235
236    #[test]
237    fn object_without_properties_accepts_any_object() {
238        validate_json_schema_value(&json!({"type": "object"}), &json!({"city": "Paris"})).unwrap();
239    }
240}