1use serde_json::Value;
12
13#[derive(Debug, Clone, PartialEq)]
15pub struct Violation {
16 pub path: String,
18 pub message: String,
19}
20
21pub fn response_schema<'a>(
25 spec: &'a Value,
26 method: &str,
27 path: &str,
28 status: u16,
29) -> Option<&'a Value> {
30 let paths = spec.get("paths")?.as_object()?;
31 let path = path.split('?').next().unwrap_or(path);
32 let concrete: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
33 let item = paths
34 .iter()
35 .find(|(template, _)| template_matches(template, &concrete))
36 .map(|(_, item)| item)?;
37 item.get(method.to_ascii_lowercase())?
38 .get("responses")?
39 .get(status.to_string())?
40 .get("content")?
41 .get("application/json")?
42 .get("schema")
43}
44
45pub fn template_matches(template: &str, concrete: &[&str]) -> bool {
48 let segments: Vec<&str> = template.split('/').filter(|s| !s.is_empty()).collect();
49 segments.len() == concrete.len()
50 && segments
51 .iter()
52 .zip(concrete)
53 .all(|(seg, real)| seg.starts_with('{') || seg == real)
54}
55
56pub fn validate(value: &Value, schema: &Value, spec: &Value) -> Vec<Violation> {
59 let mut violations = Vec::new();
60 check(
61 value,
62 schema,
63 spec,
64 String::new(),
65 &mut Vec::new(),
66 &mut violations,
67 );
68 violations
69}
70
71fn check(
72 value: &Value,
73 schema: &Value,
74 spec: &Value,
75 path: String,
76 ref_stack: &mut Vec<String>,
77 out: &mut Vec<Violation>,
78) {
79 if value.is_null() {
80 return;
81 }
82 let Some(schema_obj) = schema.as_object() else {
83 return;
84 };
85 if let Some(reference) = schema_obj.get("$ref").and_then(Value::as_str) {
86 if ref_stack.iter().any(|r| r == reference) {
87 return;
88 }
89 let Some(resolved) = resolve(spec, reference) else {
90 out.push(Violation {
91 path,
92 message: format!("unresolvable {reference}"),
93 });
94 return;
95 };
96 ref_stack.push(reference.to_string());
97 check(value, resolved, spec, path, ref_stack, out);
98 ref_stack.pop();
99 return;
100 }
101 if let Some(branches) = one_of(schema_obj) {
102 let matched = branches.iter().any(|branch| {
103 let mut trial = Vec::new();
104 check(value, branch, spec, path.clone(), ref_stack, &mut trial);
105 trial.is_empty()
106 });
107 if !matched {
108 out.push(Violation {
109 path,
110 message: format!("matches no schema branch, got {}", kind(value)),
111 });
112 }
113 return;
114 }
115 if let Some(all) = schema_obj.get("allOf").and_then(Value::as_array) {
116 for branch in all {
117 check(value, branch, spec, path.clone(), ref_stack, out);
118 }
119 return;
120 }
121 if let Some(expected) = schema_obj.get("type")
122 && !type_matches(value, expected)
123 {
124 out.push(Violation {
125 path,
126 message: format!("expected {}, got {}", type_names(expected), kind(value)),
127 });
128 return;
129 }
130 if let Some(allowed) = schema_obj.get("enum").and_then(Value::as_array)
131 && !allowed.contains(value)
132 {
133 out.push(Violation {
134 path,
135 message: format!("{value} not in enum"),
136 });
137 return;
138 }
139 match value {
140 Value::Object(fields) => {
141 let properties = schema_obj.get("properties").and_then(Value::as_object);
142 if let Some(properties) = properties {
143 for (key, child_schema) in properties {
144 if let Some(child) = fields.get(key) {
145 let child_path = join(&path, key);
146 check(child, child_schema, spec, child_path, ref_stack, out);
147 }
148 }
149 if schema_obj.get("additionalProperties") == Some(&Value::Bool(false)) {
150 for key in fields.keys().filter(|k| !properties.contains_key(*k)) {
151 out.push(Violation {
152 path: join(&path, key),
153 message: "undeclared property".into(),
154 });
155 }
156 }
157 }
158 if let Some(required) = schema_obj.get("required").and_then(Value::as_array) {
159 for key in required.iter().filter_map(Value::as_str) {
160 if !fields.contains_key(key) {
161 out.push(Violation {
162 path: join(&path, key),
163 message: "required property missing".into(),
164 });
165 }
166 }
167 }
168 }
169 Value::Array(items) => {
170 if let Some(item_schema) = schema_obj.get("items") {
171 let element_path = format!("{path}[]");
172 for item in items {
173 check(
174 item,
175 item_schema,
176 spec,
177 element_path.clone(),
178 ref_stack,
179 out,
180 );
181 }
182 }
183 }
184 _ => {}
185 }
186}
187
188fn one_of(schema: &serde_json::Map<String, Value>) -> Option<&Vec<Value>> {
189 schema
190 .get("oneOf")
191 .or_else(|| schema.get("anyOf"))
192 .and_then(Value::as_array)
193}
194
195pub(super) fn resolve<'a>(spec: &'a Value, reference: &str) -> Option<&'a Value> {
196 let name = reference.strip_prefix("#/components/schemas/")?;
197 spec.get("components")?.get("schemas")?.get(name)
198}
199
200fn type_matches(value: &Value, expected: &Value) -> bool {
201 match expected {
202 Value::String(name) => single_type_matches(value, name),
203 Value::Array(names) => names
204 .iter()
205 .filter_map(Value::as_str)
206 .any(|name| single_type_matches(value, name)),
207 _ => true,
208 }
209}
210
211fn single_type_matches(value: &Value, name: &str) -> bool {
212 match name {
213 "object" => value.is_object(),
214 "array" => value.is_array(),
215 "string" => value.is_string(),
216 "boolean" => value.is_boolean(),
217 "integer" => value.as_i64().is_some() || value.as_u64().is_some(),
218 "number" => value.is_number(),
219 "null" => value.is_null(),
220 _ => true,
221 }
222}
223
224fn type_names(expected: &Value) -> String {
225 match expected {
226 Value::String(name) => name.clone(),
227 Value::Array(names) => names
228 .iter()
229 .filter_map(Value::as_str)
230 .collect::<Vec<_>>()
231 .join("|"),
232 _ => "any".into(),
233 }
234}
235
236fn kind(value: &Value) -> &'static str {
237 match value {
238 Value::Null => "null",
239 Value::Bool(_) => "boolean",
240 Value::Number(_) => "number",
241 Value::String(_) => "string",
242 Value::Array(_) => "array",
243 Value::Object(_) => "object",
244 }
245}
246
247fn join(path: &str, key: &str) -> String {
248 if path.is_empty() {
249 key.to_string()
250 } else {
251 format!("{path}.{key}")
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use serde_json::json;
259
260 fn spec() -> Value {
261 json!({
262 "paths": {
263 "/v2/quote/{symbol}": {
264 "get": {
265 "responses": {
266 "200": {
267 "content": {
268 "application/json": {
269 "schema": {"$ref": "#/components/schemas/Quote"}
270 }
271 }
272 }
273 }
274 }
275 }
276 },
277 "components": {
278 "schemas": {
279 "Quote": {
280 "type": "object",
281 "properties": {
282 "symbol": {"type": "string"},
283 "price": {"$ref": "#/components/schemas/Money"},
284 "tags": {"type": "array", "items": {"type": "string"}},
285 },
286 "required": ["symbol"],
287 },
288 "Money": {
289 "type": "object",
290 "properties": {"raw": {"type": "number"}, "fmt": {"type": "string"}},
291 },
292 }
293 }
294 })
295 }
296
297 #[test]
298 fn concrete_paths_match_templates() {
299 let spec = spec();
300 assert!(response_schema(&spec, "GET", "/v2/quote/AAPL", 200).is_some());
301 assert!(response_schema(&spec, "GET", "/v2/quote/AAPL?fields=pe", 200).is_some());
302 assert!(response_schema(&spec, "GET", "/v2/quote/AAPL/extra", 200).is_none());
303 assert!(response_schema(&spec, "POST", "/v2/quote/AAPL", 200).is_none());
304 }
305
306 #[test]
307 fn a_valid_response_through_refs_is_clean() {
308 let spec = spec();
309 let schema = response_schema(&spec, "GET", "/v2/quote/AAPL", 200).unwrap();
310 let value = json!({"symbol": "AAPL", "price": {"raw": 1.0, "fmt": "1.00"}});
311 assert_eq!(validate(&value, schema, &spec), []);
312 }
313
314 #[test]
315 fn wrong_scalar_kind_is_reported_with_its_path() {
316 let spec = spec();
317 let schema = response_schema(&spec, "GET", "/v2/quote/AAPL", 200).unwrap();
318 let value = json!({"symbol": "AAPL", "price": {"raw": "oops"}});
319 let violations = validate(&value, schema, &spec);
320 assert_eq!(violations.len(), 1);
321 assert_eq!(violations[0].path, "price.raw");
322 }
323
324 #[test]
325 fn nulls_pass_everywhere_but_missing_required_does_not() {
326 let spec = spec();
327 let schema = response_schema(&spec, "GET", "/v2/quote/AAPL", 200).unwrap();
328 assert_eq!(
329 validate(&json!({"symbol": null, "price": null}), schema, &spec),
330 []
331 );
332 let violations = validate(&json!({"price": null}), schema, &spec);
333 assert_eq!(violations[0].path, "symbol");
334 }
335
336 #[test]
337 fn array_items_are_checked_per_element() {
338 let spec = spec();
339 let schema = response_schema(&spec, "GET", "/v2/quote/AAPL", 200).unwrap();
340 let violations = validate(&json!({"symbol": "A", "tags": ["ok", 3]}), schema, &spec);
341 assert_eq!(violations[0].path, "tags[]");
342 }
343
344 #[test]
345 fn open_and_recursive_schemas_do_not_loop_or_flag() {
346 let spec = json!({
347 "components": {"schemas": {"Node": {
348 "type": "object",
349 "properties": {"next": {"$ref": "#/components/schemas/Node"}},
350 }}}
351 });
352 let schema = json!({"$ref": "#/components/schemas/Node"});
353 let value = json!({"next": {"next": {}}});
354 assert_eq!(validate(&value, &schema, &spec), []);
355 assert_eq!(validate(&json!({"anything": 1}), &json!({}), &spec), []);
356 }
357}