Skip to main content

soothfast_spec/probe/
shape.rs

1//! Structural validation of a live response against the spec's own
2//! response schema.
3//!
4//! `null` is accepted everywhere: the generated schemas don't model
5//! nullability, and the server answers `null` for any absent optional —
6//! judging nulls is the population baseline's job. Shape only asks
7//! whether the data that *is* there has the declared structure, which is
8//! what catches a number turning into a string or an array of objects
9//! flattening into scalars.
10
11use serde_json::Value;
12
13/// One structural disagreement between response and schema.
14#[derive(Debug, Clone, PartialEq)]
15pub struct Violation {
16    /// Dotted path into the response, `[]` for array elements.
17    pub path: String,
18    pub message: String,
19}
20
21/// Find the JSON response schema the spec declares for `method` on the
22/// concrete `path` (path template segments `{like_this}` match anything),
23/// for the given status code.
24pub 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
45/// Whether a path template matches concrete path segments, treating any
46/// `{seg}` as a wildcard. Segment counts must agree.
47pub 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
56/// Validate `value` against `schema`, resolving `$ref`s through the
57/// spec's `components/schemas`. Returns every violation found.
58pub 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}