1use 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}