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