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
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
54/// Validate `value` against `schema`, resolving `$ref`s through the
55/// spec's `components/schemas`. Returns every violation found.
56pub 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}