Skip to main content

synx_core/
schema_json.rs

1//! Build [JSON Schema](https://json-schema.org/) from `!active` [`Constraints`](crate::Constraints)
2//! and validate [`Value`](crate::Value) instances (optional `jsonschema` feature).
3
4use crate::value::{Constraints, MetaMap, Value};
5use serde_json::{json, Map, Value as JsonValue};
6
7const SCHEMA_URL: &str = "https://json-schema.org/draft/2020-12/schema";
8
9/// Convert a parsed SYNX [`Value`](crate::Value) tree to [`serde_json::Value`] for JSON Schema tooling.
10pub fn value_to_json_value(v: &Value) -> JsonValue {
11    match v {
12        Value::Null => JsonValue::Null,
13        Value::Bool(b) => JsonValue::Bool(*b),
14        Value::Int(n) => JsonValue::Number((*n).into()),
15        Value::Float(f) => serde_json::Number::from_f64(*f)
16            .map(JsonValue::Number)
17            .unwrap_or(JsonValue::Null),
18        Value::String(s) | Value::Secret(s) => JsonValue::String(s.clone()),
19        Value::Array(items) => JsonValue::Array(items.iter().map(value_to_json_value).collect()),
20        Value::Object(map) => {
21            let mut out = Map::new();
22            for (k, val) in map.iter() {
23                out.insert(k.clone(), value_to_json_value(val));
24            }
25            JsonValue::Object(out)
26        }
27    }
28}
29
30/// Build a draft 2020-12 JSON Schema object from [`ParseResult::metadata`](crate::ParseResult::metadata).
31///
32/// Nested SYNX paths (`server`, `server.ssl`) become nested `properties`. Only keys that have
33/// [`Meta::constraints`](crate::Meta::constraints) are emitted (same criterion as the JS `Synx.schema()`).
34pub fn metadata_to_json_schema(metadata: &std::collections::HashMap<String, MetaMap>) -> JsonValue {
35    let mut root = json!({
36        "$schema": SCHEMA_URL,
37        "type": "object",
38        "properties": {},
39        "required": []
40    });
41
42    let mut keys: Vec<&String> = metadata.keys().collect();
43    keys.sort_by_key(|k| (k.matches('.').count(), k.len(), k.as_str()));
44
45    for prefix in keys {
46        let mmap = &metadata[prefix];
47        let segments: Vec<&str> = if prefix.is_empty() {
48            vec![]
49        } else {
50            prefix.split('.').collect()
51        };
52        for (field_key, meta) in mmap.iter() {
53            if let Some(ref c) = meta.constraints {
54                let prop = constraints_to_property(c);
55                insert_constraint_at(&mut root, &segments, field_key, prop, c.required);
56            }
57        }
58    }
59
60    root
61}
62
63fn descend_create<'a>(root: &'a mut JsonValue, path: &[&str]) -> &'a mut JsonValue {
64    let mut cur = root;
65    for seg in path {
66        let o = cur.as_object_mut().expect("schema node");
67        let props = o
68            .get_mut("properties")
69            .expect("properties")
70            .as_object_mut()
71            .expect("properties object");
72        cur = props.entry((*seg).to_string()).or_insert_with(|| {
73            json!({
74                "type": "object",
75                "properties": {},
76                "required": []
77            })
78        });
79    }
80    cur
81}
82
83fn insert_constraint_at(
84    root: &mut JsonValue,
85    path: &[&str],
86    field_key: &str,
87    prop: Map<String, JsonValue>,
88    is_required: bool,
89) {
90    let target = descend_create(root, path);
91    {
92        let obj = target.as_object_mut().expect("target schema");
93        let props = obj
94            .get_mut("properties")
95            .expect("properties")
96            .as_object_mut()
97            .expect("properties map");
98        props.insert(field_key.to_string(), JsonValue::Object(prop));
99    }
100    if is_required {
101        let obj = target.as_object_mut().expect("target schema");
102        let req = obj
103            .get_mut("required")
104            .expect("required")
105            .as_array_mut()
106            .expect("required array");
107        if !req.iter().any(|e| e.as_str() == Some(field_key)) {
108            req.push(JsonValue::String(field_key.to_string()));
109        }
110    }
111}
112
113fn constraints_to_property(c: &Constraints) -> Map<String, JsonValue> {
114    let mut prop = Map::new();
115
116    if let Some(ref t) = c.type_name {
117        match t.as_str() {
118            "int" => {
119                prop.insert("type".into(), json!("integer"));
120            }
121            "float" => {
122                prop.insert("type".into(), json!("number"));
123            }
124            "bool" => {
125                prop.insert("type".into(), json!("boolean"));
126            }
127            "string" => {
128                prop.insert("type".into(), json!("string"));
129            }
130            _ => {}
131        }
132    }
133
134    if c.min.is_some() || c.max.is_some() {
135        let is_string = matches!(c.type_name.as_deref(), Some("string"));
136        if is_string {
137            if let Some(mn) = c.min {
138                if mn.fract() == 0.0 {
139                    prop.insert("minLength".into(), json!(mn as u64));
140                }
141            }
142            if let Some(mx) = c.max {
143                if mx.fract() == 0.0 {
144                    prop.insert("maxLength".into(), json!(mx as u64));
145                }
146            }
147        } else {
148            if let Some(mn) = c.min {
149                prop.insert("minimum".into(), json!(mn));
150            }
151            if let Some(mx) = c.max {
152                prop.insert("maximum".into(), json!(mx));
153            }
154        }
155    }
156
157    if let Some(ref p) = c.pattern {
158        prop.insert("pattern".into(), json!(p));
159    }
160
161    if let Some(ref ev) = c.enum_values {
162        let mut arr: Vec<JsonValue> = ev.iter().cloned().map(JsonValue::String).collect();
163        if let Some(et) = c.type_name.as_deref() {
164            if et == "int" {
165                arr = ev
166                    .iter()
167                    .filter_map(|s| s.parse::<i64>().ok())
168                    .map(|n| JsonValue::Number(n.into()))
169                    .collect();
170            } else if et == "float" {
171                arr = ev
172                    .iter()
173                    .filter_map(|s| s.parse::<f64>().ok())
174                    .filter_map(|f| serde_json::Number::from_f64(f).map(JsonValue::Number))
175                    .collect();
176            } else if et == "bool" {
177                arr = ev
178                    .iter()
179                    .map(|s| JsonValue::Bool(s == "true"))
180                    .collect();
181            }
182        }
183        if !arr.is_empty() {
184            prop.insert("enum".into(), JsonValue::Array(arr));
185        }
186    }
187
188    prop
189}
190
191/// Validate `instance` against a JSON Schema value using the `jsonschema` crate.
192#[cfg(feature = "jsonschema")]
193pub fn validate_with_json_schema(
194    instance: &Value,
195    schema: &JsonValue,
196) -> Result<(), Vec<String>> {
197    validate_serde_json(&value_to_json_value(instance), schema)
198}
199
200/// Validate a [`serde_json::Value`] instance (e.g. raw JSON file) against a JSON Schema.
201#[cfg(feature = "jsonschema")]
202pub fn validate_serde_json(instance: &JsonValue, schema: &JsonValue) -> Result<(), Vec<String>> {
203    let validator = match jsonschema::validator_for(schema) {
204        Ok(v) => v,
205        Err(e) => return Err(vec![format!("invalid JSON Schema: {e}")]),
206    };
207    let mut errs = Vec::new();
208    for e in validator.iter_errors(instance) {
209        errs.push(e.to_string());
210    }
211    if errs.is_empty() {
212        Ok(())
213    } else {
214        Err(errs)
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use crate::value::Meta;
222    use std::collections::HashMap;
223
224    #[test]
225    fn nested_metadata_schema() {
226        let mut metadata: HashMap<String, MetaMap> = HashMap::new();
227        let mut root_map = MetaMap::new();
228        root_map.insert(
229            "name".into(),
230            Meta {
231                markers: vec![],
232                args: vec![],
233                type_hint: None,
234                constraints: Some(Constraints {
235                    required: true,
236                    type_name: Some("string".into()),
237                    min: Some(1.0),
238                    max: Some(20.0),
239                    ..Default::default()
240                }),
241            },
242        );
243        metadata.insert(String::new(), root_map);
244
245        let sch = metadata_to_json_schema(&metadata);
246        assert_eq!(sch["properties"]["name"]["type"], json!("string"));
247        assert_eq!(sch["properties"]["name"]["minLength"], json!(1));
248        assert_eq!(sch["required"], json!(["name"]));
249    }
250}