soothfast_spec/probe/
coverage.rs1use std::collections::BTreeSet;
10
11use serde_json::Value;
12
13pub fn declared_paths(schema: &Value, spec: &Value) -> BTreeSet<String> {
20 let mut paths = BTreeSet::new();
21 walk(schema, spec, String::new(), &mut Vec::new(), &mut paths);
22 paths
23}
24
25fn walk(
26 schema: &Value,
27 spec: &Value,
28 path: String,
29 ref_stack: &mut Vec<String>,
30 out: &mut BTreeSet<String>,
31) {
32 let Some(schema_obj) = schema.as_object() else {
33 return;
34 };
35 if let Some(reference) = schema_obj.get("$ref").and_then(Value::as_str) {
36 if ref_stack.iter().any(|r| r == reference) {
37 return;
38 }
39 let Some(resolved) = super::shape::resolve(spec, reference) else {
40 return;
41 };
42 ref_stack.push(reference.to_string());
43 walk(resolved, spec, path, ref_stack, out);
44 ref_stack.pop();
45 return;
46 }
47 if schema_obj.contains_key("oneOf") || schema_obj.contains_key("anyOf") {
48 return;
49 }
50 if let Some(branches) = schema_obj.get("allOf").and_then(Value::as_array) {
51 for branch in branches {
52 walk(branch, spec, path.clone(), ref_stack, out);
53 }
54 return;
55 }
56 if let Some(properties) = schema_obj.get("properties").and_then(Value::as_object) {
57 for (key, child) in properties {
58 let child_path = if path.is_empty() {
59 key.clone()
60 } else {
61 format!("{path}.{key}")
62 };
63 out.insert(child_path.clone());
64 walk(child, spec, child_path, ref_stack, out);
65 }
66 }
67 if let Some(items) = schema_obj.get("items") {
68 walk(items, spec, format!("{path}[]"), ref_stack, out);
69 }
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75 use serde_json::json;
76
77 #[test]
78 fn properties_arrays_and_refs_enumerate_like_population_paths() {
79 let spec = json!({
80 "components": {"schemas": {"Money": {
81 "type": "object",
82 "properties": {"raw": {"type": "number"}, "fmt": {"type": "string"}},
83 }}}
84 });
85 let schema = json!({
86 "type": "object",
87 "properties": {
88 "symbol": {"type": "string"},
89 "price": {"$ref": "#/components/schemas/Money"},
90 "candles": {"type": "array", "items": {
91 "type": "object",
92 "properties": {"close": {"type": "number"}},
93 }},
94 },
95 });
96 let paths = declared_paths(&schema, &spec);
97 let expected: BTreeSet<String> = [
98 "symbol",
99 "price",
100 "price.raw",
101 "price.fmt",
102 "candles",
103 "candles[].close",
104 ]
105 .into_iter()
106 .map(String::from)
107 .collect();
108 assert_eq!(paths, expected);
109 }
110
111 #[test]
112 fn open_and_recursive_schemas_terminate() {
113 let spec = json!({
114 "components": {"schemas": {"Node": {
115 "type": "object",
116 "properties": {
117 "value": {"type": "number"},
118 "next": {"$ref": "#/components/schemas/Node"},
119 },
120 }}}
121 });
122 let schema = json!({
123 "type": "object",
124 "properties": {"open": {}, "tree": {"$ref": "#/components/schemas/Node"}},
125 });
126 let paths = declared_paths(&schema, &spec);
127 assert!(paths.contains("open"));
128 assert!(paths.contains("tree.value"));
129 assert!(paths.contains("tree.next"));
130 assert!(!paths.contains("tree.next.value"));
131 }
132
133 #[test]
134 fn union_children_are_conditional_but_all_of_is_conjunctive() {
135 let spec = json!({});
136 let union = json!({
137 "oneOf": [
138 {"type": "number"},
139 {"type": "object", "properties": {"raw": {"type": "number"}}},
140 ],
141 });
142 assert!(declared_paths(&union, &spec).is_empty());
143 let conjunction = json!({
144 "allOf": [
145 {"type": "object", "properties": {"a": {"type": "number"}}},
146 {"type": "object", "properties": {"b": {"type": "number"}}},
147 ],
148 });
149 let paths = declared_paths(&conjunction, &spec);
150 assert!(paths.contains("a"));
151 assert!(paths.contains("b"));
152 }
153}