Skip to main content

jlens_schema/
jsonschema.rs

1//! Conversion between [`Schema`] and JSON Schema (draft 2020-12).
2//!
3//! JSON Schema is the interchange format: `jlens schema infer` writes it, editors and the
4//! language service read it, and people can edit it by hand. jlens-specific information travels
5//! in `x-jlens-*` keywords that other tools ignore:
6//!
7//! | Keyword | Meaning |
8//! |---|---|
9//! | `title` | The shape's label |
10//! | `x-jlens-presence` | Fraction of objects that have an optional field |
11//! | `x-jlens-hidden` | Hide from completion |
12//! | `x-jlens-samples` | (root) Number of sample documents inferred from |
13
14use serde_json::{Map, Value as JsonValue, json};
15
16use crate::shape::{Field, JsonType, Schema, Shape};
17
18const DIALECT: &str = "https://json-schema.org/draft/2020-12/schema";
19
20impl Schema {
21    /// Converts to a JSON Schema document.
22    ///
23    /// ```
24    /// use jlens_schema::Schema;
25    /// use serde_json::json;
26    ///
27    /// let schema = Schema::infer(&[json!({"name": "a", "tags": ["x"]})]);
28    /// let doc = schema.to_json_schema();
29    /// assert_eq!(doc["properties"]["tags"]["items"]["type"], "string");
30    /// assert_eq!(Schema::from_json_schema(&doc).unwrap().root, schema.root.without_state());
31    /// ```
32    pub fn to_json_schema(&self) -> JsonValue {
33        let mut doc = export(&self.root);
34        if let JsonValue::Object(map) = &mut doc {
35            let mut out = Map::new();
36            out.insert("$schema".into(), DIALECT.into());
37            out.extend(std::mem::take(map));
38            if self.samples > 0 {
39                out.insert("x-jlens-samples".into(), self.samples.into());
40            }
41            *map = out;
42        }
43        doc
44    }
45
46    /// Reads a JSON Schema document. Supports `type`, `properties`, `required`, `items`,
47    /// `enum`, `const`, `format`, `minimum`, `maximum`, `title`, `description`, `examples`,
48    /// local `$ref`s (`#/$defs/...`, `#/definitions/...`), and `anyOf`/`oneOf`/`allOf` (whose
49    /// branches are merged). Other keywords are ignored.
50    pub fn from_json_schema(doc: &JsonValue) -> Result<Schema, String> {
51        if !doc.is_object() && !doc.is_boolean() {
52            return Err("a JSON Schema must be an object or a boolean".into());
53        }
54        let root = import(doc, doc, 0)?;
55        let samples = doc
56            .get("x-jlens-samples")
57            .and_then(JsonValue::as_u64)
58            .unwrap_or(0);
59        Ok(Schema { root, samples })
60    }
61}
62
63fn export(shape: &Shape) -> JsonValue {
64    let mut out = Map::new();
65    let types: Vec<JsonValue> = shape.types.iter().map(|t| t.name().into()).collect();
66    match types.len() {
67        0 => {}
68        1 => {
69            out.insert("type".into(), types[0].clone());
70        }
71        _ => {
72            out.insert("type".into(), types.into());
73        }
74    }
75    if let Some(label) = &shape.label {
76        out.insert("title".into(), label.clone().into());
77    }
78    if let Some(d) = &shape.description {
79        out.insert("description".into(), d.clone().into());
80    }
81    if let Some(f) = &shape.format {
82        out.insert("format".into(), f.clone().into());
83    }
84    if let Some(e) = &shape.enumeration {
85        out.insert("enum".into(), e.clone().into());
86    }
87    if let Some(m) = shape.minimum {
88        out.insert("minimum".into(), json_number(m));
89    }
90    if let Some(m) = shape.maximum {
91        out.insert("maximum".into(), json_number(m));
92    }
93    if !shape.examples.is_empty() {
94        out.insert("examples".into(), shape.examples.clone().into());
95    }
96    if !shape.fields.is_empty() {
97        let mut props = Map::new();
98        let mut required = Vec::new();
99        for (name, field) in &shape.fields {
100            let mut p = export(&field.shape);
101            if field.required {
102                required.push(JsonValue::from(name.clone()));
103            } else if let JsonValue::Object(m) = &mut p
104                && field.presence > 0.0
105            {
106                m.insert("x-jlens-presence".into(), json_number(field.presence));
107            }
108            props.insert(name.clone(), p);
109        }
110        out.insert("properties".into(), props.into());
111        if !required.is_empty() {
112            out.insert("required".into(), required.into());
113        }
114    }
115    if let Some(items) = &shape.items {
116        out.insert("items".into(), export(items));
117    }
118    if shape.hidden {
119        out.insert("x-jlens-hidden".into(), true.into());
120    }
121    out.into()
122}
123
124fn json_number(n: f64) -> JsonValue {
125    if n.fract() == 0.0 && n.abs() < 9e15 {
126        (n as i64).into()
127    } else {
128        json!(n)
129    }
130}
131
132fn resolve<'a>(root: &'a JsonValue, reference: &str) -> Option<&'a JsonValue> {
133    let pointer = reference.strip_prefix('#')?;
134    root.pointer(pointer)
135}
136
137fn import(node: &JsonValue, root: &JsonValue, depth: usize) -> Result<Shape, String> {
138    if depth > 64 {
139        return Err("$ref nesting is too deep (recursive schema?)".into());
140    }
141    let JsonValue::Object(map) = node else {
142        return Ok(Shape::default()); // `true` / `false`: nothing known
143    };
144    let mut shape = match map.get("$ref").and_then(JsonValue::as_str) {
145        Some(r) => {
146            let target = resolve(root, r).ok_or_else(|| format!("cannot resolve $ref {r}"))?;
147            import(target, root, depth + 1)?
148        }
149        None => Shape::default(),
150    };
151    for key in ["allOf", "anyOf", "oneOf"] {
152        if let Some(JsonValue::Array(branches)) = map.get(key) {
153            for branch in branches {
154                let b = import(branch, root, depth + 1)?;
155                merge(&mut shape, b);
156            }
157        }
158    }
159    match map.get("type") {
160        Some(JsonValue::String(t)) => add_type(&mut shape, t),
161        Some(JsonValue::Array(ts)) => {
162            for t in ts.iter().filter_map(JsonValue::as_str) {
163                add_type(&mut shape, t);
164            }
165        }
166        _ => {}
167    }
168    if let Some(JsonValue::Object(props)) = map.get("properties") {
169        let required: Vec<&str> = map
170            .get("required")
171            .and_then(JsonValue::as_array)
172            .map(|r| r.iter().filter_map(JsonValue::as_str).collect())
173            .unwrap_or_default();
174        for (name, p) in props {
175            let field_shape = import(p, root, depth + 1)?;
176            let is_required = required.contains(&name.as_str());
177            let presence = p
178                .get("x-jlens-presence")
179                .and_then(JsonValue::as_f64)
180                .unwrap_or(if is_required { 1.0 } else { 0.0 });
181            shape.fields.insert(
182                name.clone(),
183                Field {
184                    shape: field_shape,
185                    required: is_required,
186                    presence,
187                },
188            );
189        }
190        add_type(&mut shape, "object");
191    }
192    match map.get("items") {
193        Some(items @ JsonValue::Object(_)) => {
194            shape.items = Some(Box::new(import(items, root, depth + 1)?));
195            add_type(&mut shape, "array");
196        }
197        Some(JsonValue::Array(tuple)) => {
198            // Draft-07 tuple form: merge the item schemas.
199            let mut merged = Shape::default();
200            for item in tuple {
201                merge(&mut merged, import(item, root, depth + 1)?);
202            }
203            shape.items = Some(Box::new(merged));
204            add_type(&mut shape, "array");
205        }
206        _ => {}
207    }
208    if let Some(JsonValue::Array(values)) = map.get("enum") {
209        shape.enumeration = Some(values.clone());
210    }
211    if let Some(value) = map.get("const") {
212        shape.enumeration = Some(vec![value.clone()]);
213    }
214    if let Some(f) = map.get("format").and_then(JsonValue::as_str) {
215        shape.format = Some(f.to_owned());
216    }
217    shape.minimum = map
218        .get("minimum")
219        .and_then(JsonValue::as_f64)
220        .or(shape.minimum);
221    shape.maximum = map
222        .get("maximum")
223        .and_then(JsonValue::as_f64)
224        .or(shape.maximum);
225    if let Some(t) = map.get("title").and_then(JsonValue::as_str) {
226        shape.label = Some(t.to_owned());
227    }
228    if let Some(d) = map.get("description").and_then(JsonValue::as_str) {
229        shape.description = Some(d.to_owned());
230    }
231    if let Some(JsonValue::Array(examples)) = map.get("examples") {
232        shape.examples = examples.clone();
233    }
234    if map.get("x-jlens-hidden").and_then(JsonValue::as_bool) == Some(true) {
235        shape.hidden = true;
236    }
237    Ok(shape)
238}
239
240fn add_type(shape: &mut Shape, name: &str) {
241    if let Some(t) = JsonType::from_name(name)
242        && !shape.types.contains(&t)
243    {
244        shape.types.push(t);
245        shape.types.sort();
246    }
247}
248
249/// Merges `other` into `shape` (union of types and fields).
250fn merge(shape: &mut Shape, other: Shape) {
251    for t in other.types {
252        if !shape.types.contains(&t) {
253            shape.types.push(t);
254        }
255    }
256    shape.types.sort();
257    for (name, field) in other.fields {
258        match shape.fields.get_mut(&name) {
259            Some(existing) => {
260                existing.required &= field.required;
261                merge(&mut existing.shape, field.shape);
262            }
263            None => {
264                shape.fields.insert(name, field);
265            }
266        }
267    }
268    match (&mut shape.items, other.items) {
269        (Some(a), Some(b)) => merge(a, *b),
270        (slot @ None, Some(b)) => *slot = Some(b),
271        _ => {}
272    }
273    shape.enumeration = shape.enumeration.take().or(other.enumeration);
274    shape.format = shape.format.take().or(other.format);
275    shape.description = shape.description.take().or(other.description);
276    shape.label = shape.label.take().or(other.label);
277}
278
279impl Shape {
280    /// A copy without inference bookkeeping, as produced by a JSON Schema round trip.
281    pub fn without_state(&self) -> Shape {
282        let mut copy = Shape {
283            types: self.types.clone(),
284            fields: Default::default(),
285            items: self.items.as_ref().map(|i| Box::new(i.without_state())),
286            minimum: self.minimum,
287            maximum: self.maximum,
288            format: self.format.clone(),
289            enumeration: self.enumeration.clone(),
290            examples: self.examples.clone(),
291            description: self.description.clone(),
292            label: self.label.clone(),
293            hidden: self.hidden,
294            ..Shape::default()
295        };
296        for (k, f) in &self.fields {
297            copy.fields.insert(
298                k.clone(),
299                Field {
300                    shape: f.shape.without_state(),
301                    required: f.required,
302                    presence: f.presence,
303                },
304            );
305        }
306        copy
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn round_trips_through_json_schema() {
316        let mut schema = Schema::infer(&[
317            json!({"id": 1, "kind": "a", "tags": ["x"], "owner": {"name": "Ann"}, "at": "2024-01-01"}),
318            json!({"id": 2.5, "kind": "a", "tags": [], "at": "2024-02-01"}),
319            json!({"id": 3, "kind": "b", "tags": ["y", "y"], "owner": null, "at": "2024-03-01"}),
320        ]);
321        schema.root.fields["kind"].shape.description = Some("Kind of thing".into());
322        let doc = schema.to_json_schema();
323        assert_eq!(doc["$schema"], DIALECT);
324        assert_eq!(doc["required"], json!(["id", "kind", "tags", "at"]));
325        assert_eq!(
326            doc["properties"]["owner"]["x-jlens-presence"],
327            json!(2.0 / 3.0)
328        );
329        let back = Schema::from_json_schema(&doc).unwrap();
330        assert_eq!(back.root, schema.root.without_state());
331        assert_eq!(back.samples, 3);
332    }
333
334    #[test]
335    fn imports_refs_and_combinators() {
336        let doc = json!({
337            "$defs": {"item": {"type": "object", "properties": {"sku": {"type": "string"}}}},
338            "type": "object",
339            "properties": {
340                "items": {"type": "array", "items": {"$ref": "#/$defs/item"}},
341                "status": {"anyOf": [{"const": "open"}, {"type": "null"}]}
342            }
343        });
344        let schema = Schema::from_json_schema(&doc).unwrap();
345        assert!(schema.shape_at("items[].sku").is_some());
346        assert_eq!(schema.shape_at("status").unwrap().type_summary(), "null");
347        assert_eq!(
348            schema.shape_at("status").unwrap().enumeration,
349            Some(vec![json!("open")])
350        );
351    }
352}