jsonschema_annotator/schema/
parser.rs

1use schemars::Schema;
2use serde_json::Value;
3
4use super::annotation::{Annotation, AnnotationMap};
5use super::refs::resolve_refs;
6
7/// Extract annotations from a JSON Schema
8///
9/// This resolves $refs and walks the schema recursively,
10/// extracting title/description for each property path.
11pub fn extract_annotations(schema: &Schema) -> AnnotationMap {
12    let resolved = resolve_refs(schema);
13    let mut annotations = AnnotationMap::new();
14    let mut path = Vec::new();
15
16    walk_schema(resolved.as_value(), &mut path, &mut annotations);
17
18    annotations
19}
20
21fn walk_schema(value: &Value, current_path: &mut Vec<String>, annotations: &mut AnnotationMap) {
22    let Some(obj) = value.as_object() else {
23        return;
24    };
25
26    // Extract title/description at current level
27    let title = obj.get("title").and_then(|v| v.as_str());
28    let desc = obj.get("description").and_then(|v| v.as_str());
29
30    if title.is_some() || desc.is_some() {
31        let mut ann = Annotation::new(current_path.join("."));
32        if let Some(t) = title {
33            ann = ann.with_title(t);
34        }
35        if let Some(d) = desc {
36            ann = ann.with_description(d);
37        }
38        annotations.insert(ann);
39    }
40
41    // Recurse into properties
42    if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
43        for (key, val) in props {
44            current_path.push(key.clone());
45            walk_schema(val, current_path, annotations);
46            current_path.pop();
47        }
48    }
49
50    // Handle array items (annotation applies to the array key itself)
51    if let Some(items) = obj.get("items") {
52        walk_schema(items, current_path, annotations);
53    }
54
55    // Handle additionalProperties if it's a schema object
56    if let Some(additional) = obj.get("additionalProperties") {
57        if additional.is_object() {
58            walk_schema(additional, current_path, annotations);
59        }
60    }
61
62    // Handle oneOf/allOf/anyOf composition
63    for keyword in ["oneOf", "allOf", "anyOf"] {
64        if let Some(schemas) = obj.get(keyword).and_then(|v| v.as_array()) {
65            for schema in schemas {
66                walk_schema(schema, current_path, annotations);
67            }
68        }
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use serde_json::json;
76
77    #[test]
78    fn test_extract_simple() {
79        let schema_json = json!({
80            "properties": {
81                "name": {
82                    "title": "Name",
83                    "description": "User's full name"
84                },
85                "age": {
86                    "title": "Age"
87                }
88            }
89        });
90
91        let schema: Schema = schema_json.try_into().unwrap();
92        let annotations = extract_annotations(&schema);
93
94        assert_eq!(annotations.len(), 2);
95
96        let name = annotations.get("name").unwrap();
97        assert_eq!(name.title, Some("Name".to_string()));
98        assert_eq!(name.description, Some("User's full name".to_string()));
99
100        let age = annotations.get("age").unwrap();
101        assert_eq!(age.title, Some("Age".to_string()));
102        assert_eq!(age.description, None);
103    }
104
105    #[test]
106    fn test_extract_nested() {
107        let schema_json = json!({
108            "properties": {
109                "server": {
110                    "title": "Server",
111                    "description": "Server configuration",
112                    "properties": {
113                        "host": {
114                            "title": "Host",
115                            "description": "Server hostname"
116                        },
117                        "port": {
118                            "title": "Port"
119                        }
120                    }
121                }
122            }
123        });
124
125        let schema: Schema = schema_json.try_into().unwrap();
126        let annotations = extract_annotations(&schema);
127
128        assert_eq!(annotations.len(), 3);
129
130        let server = annotations.get("server").unwrap();
131        assert_eq!(server.title, Some("Server".to_string()));
132
133        let host = annotations.get("server.host").unwrap();
134        assert_eq!(host.title, Some("Host".to_string()));
135        assert_eq!(host.description, Some("Server hostname".to_string()));
136
137        let port = annotations.get("server.port").unwrap();
138        assert_eq!(port.title, Some("Port".to_string()));
139    }
140
141    #[test]
142    fn test_extract_with_refs() {
143        let schema_json = json!({
144            "$defs": {
145                "Address": {
146                    "title": "Address",
147                    "description": "A physical address",
148                    "properties": {
149                        "city": {
150                            "title": "City"
151                        }
152                    }
153                }
154            },
155            "properties": {
156                "home": {"$ref": "#/$defs/Address"},
157                "work": {"$ref": "#/$defs/Address"}
158            }
159        });
160
161        let schema: Schema = schema_json.try_into().unwrap();
162        let annotations = extract_annotations(&schema);
163
164        // Both home and work should have annotations from the Address $def
165        let home = annotations.get("home").unwrap();
166        assert_eq!(home.title, Some("Address".to_string()));
167
168        let home_city = annotations.get("home.city").unwrap();
169        assert_eq!(home_city.title, Some("City".to_string()));
170
171        let work = annotations.get("work").unwrap();
172        assert_eq!(work.title, Some("Address".to_string()));
173    }
174
175    #[test]
176    fn test_extract_root_annotation() {
177        let schema_json = json!({
178            "title": "Config",
179            "description": "Application configuration",
180            "properties": {
181                "debug": {
182                    "title": "Debug Mode"
183                }
184            }
185        });
186
187        let schema: Schema = schema_json.try_into().unwrap();
188        let annotations = extract_annotations(&schema);
189
190        // Root level annotation has empty path
191        let root = annotations.get("").unwrap();
192        assert_eq!(root.title, Some("Config".to_string()));
193        assert_eq!(root.description, Some("Application configuration".to_string()));
194
195        let debug = annotations.get("debug").unwrap();
196        assert_eq!(debug.title, Some("Debug Mode".to_string()));
197    }
198
199    #[test]
200    fn test_extract_no_annotations() {
201        let schema_json = json!({
202            "properties": {
203                "name": {"type": "string"},
204                "age": {"type": "number"}
205            }
206        });
207
208        let schema: Schema = schema_json.try_into().unwrap();
209        let annotations = extract_annotations(&schema);
210
211        assert!(annotations.is_empty());
212    }
213
214    #[test]
215    fn test_extract_array_items() {
216        let schema_json = json!({
217            "properties": {
218                "users": {
219                    "title": "Users",
220                    "description": "List of users",
221                    "items": {
222                        "properties": {
223                            "name": {
224                                "title": "User Name"
225                            }
226                        }
227                    }
228                }
229            }
230        });
231
232        let schema: Schema = schema_json.try_into().unwrap();
233        let annotations = extract_annotations(&schema);
234
235        let users = annotations.get("users").unwrap();
236        assert_eq!(users.title, Some("Users".to_string()));
237
238        // Items inherit the parent path
239        let user_name = annotations.get("users.name").unwrap();
240        assert_eq!(user_name.title, Some("User Name".to_string()));
241    }
242
243    #[test]
244    fn test_extract_oneof() {
245        let schema_json = json!({
246            "properties": {
247                "value": {
248                    "title": "Value",
249                    "oneOf": [
250                        {
251                            "properties": {
252                                "string_val": {
253                                    "title": "String Value",
254                                    "description": "A string value"
255                                }
256                            }
257                        },
258                        {
259                            "properties": {
260                                "number_val": {
261                                    "title": "Number Value"
262                                }
263                            }
264                        }
265                    ]
266                }
267            }
268        });
269
270        let schema: Schema = schema_json.try_into().unwrap();
271        let annotations = extract_annotations(&schema);
272
273        let value = annotations.get("value").unwrap();
274        assert_eq!(value.title, Some("Value".to_string()));
275
276        let string_val = annotations.get("value.string_val").unwrap();
277        assert_eq!(string_val.title, Some("String Value".to_string()));
278        assert_eq!(string_val.description, Some("A string value".to_string()));
279
280        let number_val = annotations.get("value.number_val").unwrap();
281        assert_eq!(number_val.title, Some("Number Value".to_string()));
282    }
283
284    #[test]
285    fn test_extract_allof() {
286        let schema_json = json!({
287            "allOf": [
288                {
289                    "properties": {
290                        "base": {
291                            "title": "Base Property"
292                        }
293                    }
294                },
295                {
296                    "properties": {
297                        "extended": {
298                            "title": "Extended Property"
299                        }
300                    }
301                }
302            ]
303        });
304
305        let schema: Schema = schema_json.try_into().unwrap();
306        let annotations = extract_annotations(&schema);
307
308        let base = annotations.get("base").unwrap();
309        assert_eq!(base.title, Some("Base Property".to_string()));
310
311        let extended = annotations.get("extended").unwrap();
312        assert_eq!(extended.title, Some("Extended Property".to_string()));
313    }
314
315    #[test]
316    fn test_extract_anyof() {
317        let schema_json = json!({
318            "properties": {
319                "config": {
320                    "title": "Config",
321                    "anyOf": [
322                        {
323                            "properties": {
324                                "simple": {
325                                    "title": "Simple Mode"
326                                }
327                            }
328                        },
329                        {
330                            "properties": {
331                                "advanced": {
332                                    "title": "Advanced Mode",
333                                    "description": "For power users"
334                                }
335                            }
336                        }
337                    ]
338                }
339            }
340        });
341
342        let schema: Schema = schema_json.try_into().unwrap();
343        let annotations = extract_annotations(&schema);
344
345        let config = annotations.get("config").unwrap();
346        assert_eq!(config.title, Some("Config".to_string()));
347
348        let simple = annotations.get("config.simple").unwrap();
349        assert_eq!(simple.title, Some("Simple Mode".to_string()));
350
351        let advanced = annotations.get("config.advanced").unwrap();
352        assert_eq!(advanced.title, Some("Advanced Mode".to_string()));
353        assert_eq!(advanced.description, Some("For power users".to_string()));
354    }
355}