Skip to main content

greentic_flow/
schema_validate.rs

1use ciborium::value::Value as CborValue;
2use greentic_types::schemas::common::schema_ir::{AdditionalProperties, SchemaIr};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum Severity {
6    Error,
7    Warning,
8}
9
10#[derive(Debug, Clone)]
11pub struct SchemaDiagnostic {
12    pub code: &'static str,
13    pub severity: Severity,
14    pub message: String,
15    pub path: String,
16}
17
18pub fn validate_value_against_schema(
19    schema: &SchemaIr,
20    value: &CborValue,
21) -> Vec<SchemaDiagnostic> {
22    let mut diags = Vec::new();
23    validate_inner(schema, value, "$", &mut diags);
24    diags
25}
26
27fn validate_inner(
28    schema: &SchemaIr,
29    value: &CborValue,
30    path: &str,
31    diags: &mut Vec<SchemaDiagnostic>,
32) {
33    match schema {
34        SchemaIr::Object {
35            properties,
36            required,
37            additional,
38        } => validate_object(properties, required, additional, value, path, diags),
39        SchemaIr::Array {
40            items,
41            min_items,
42            max_items,
43        } => validate_array(items, *min_items, *max_items, value, path, diags),
44        SchemaIr::String {
45            min_len,
46            max_len,
47            regex,
48            format,
49        } => validate_string(
50            *min_len,
51            *max_len,
52            regex.as_deref(),
53            format.as_deref(),
54            value,
55            path,
56            diags,
57        ),
58        SchemaIr::Int { min, max } => validate_int(*min, *max, value, path, diags),
59        SchemaIr::Float { min, max } => validate_float(*min, *max, value, path, diags),
60        SchemaIr::Bool => require_kind("boolean", matches!(value, CborValue::Bool(_)), path, diags),
61        SchemaIr::Null => require_kind("null", matches!(value, CborValue::Null), path, diags),
62        SchemaIr::Bytes => require_kind("bytes", matches!(value, CborValue::Bytes(_)), path, diags),
63        SchemaIr::Enum { values } => validate_enum(values, value, path, diags),
64        SchemaIr::OneOf { variants } => validate_one_of(variants, value, path, diags),
65        SchemaIr::Ref { id } => {
66            diags.push(SchemaDiagnostic {
67                code: "SCHEMA_REF_UNSUPPORTED",
68                severity: Severity::Error,
69                message: format!("schema ref '{}' is not supported", id),
70                path: path.to_string(),
71            });
72        }
73    }
74}
75
76fn require_kind(kind: &str, ok: bool, path: &str, diags: &mut Vec<SchemaDiagnostic>) {
77    if !ok {
78        diags.push(SchemaDiagnostic {
79            code: "SCHEMA_TYPE_MISMATCH",
80            severity: Severity::Error,
81            message: format!("expected {kind} at {path}"),
82            path: path.to_string(),
83        });
84    }
85}
86
87fn validate_object(
88    properties: &std::collections::BTreeMap<String, SchemaIr>,
89    required: &[String],
90    additional: &AdditionalProperties,
91    value: &CborValue,
92    path: &str,
93    diags: &mut Vec<SchemaDiagnostic>,
94) {
95    let map = match value {
96        CborValue::Map(entries) => entries,
97        _ => {
98            require_kind("object", false, path, diags);
99            return;
100        }
101    };
102
103    let mut values: std::collections::BTreeMap<String, &CborValue> =
104        std::collections::BTreeMap::new();
105    for (k, v) in map {
106        match k {
107            CborValue::Text(s) => {
108                values.insert(s.clone(), v);
109            }
110            _ => {
111                diags.push(SchemaDiagnostic {
112                    code: "SCHEMA_INVALID_KEY",
113                    severity: Severity::Error,
114                    message: format!("non-string object key at {path}"),
115                    path: path.to_string(),
116                });
117            }
118        }
119    }
120
121    for key in required {
122        if !values.contains_key(key) {
123            diags.push(SchemaDiagnostic {
124                code: "SCHEMA_REQUIRED_MISSING",
125                severity: Severity::Error,
126                message: format!("missing required field '{key}' at {path}"),
127                path: format!("{path}.{key}"),
128            });
129        }
130    }
131
132    for (key, val) in values {
133        if let Some(prop_schema) = properties.get(&key) {
134            validate_inner(prop_schema, val, &format!("{path}.{key}"), diags);
135            continue;
136        }
137        match additional {
138            AdditionalProperties::Allow => {}
139            AdditionalProperties::Forbid => {
140                diags.push(SchemaDiagnostic {
141                    code: "SCHEMA_ADDITIONAL_FORBIDDEN",
142                    severity: Severity::Error,
143                    message: format!("additional property '{key}' not allowed at {path}"),
144                    path: format!("{path}.{key}"),
145                });
146            }
147            AdditionalProperties::Schema(schema) => {
148                validate_inner(schema, val, &format!("{path}.{key}"), diags);
149            }
150        }
151    }
152}
153
154fn validate_array(
155    items: &SchemaIr,
156    min_items: Option<u64>,
157    max_items: Option<u64>,
158    value: &CborValue,
159    path: &str,
160    diags: &mut Vec<SchemaDiagnostic>,
161) {
162    let items_val = match value {
163        CborValue::Array(items) => items,
164        _ => {
165            require_kind("array", false, path, diags);
166            return;
167        }
168    };
169    let len = items_val.len() as u64;
170    if let Some(min) = min_items
171        && len < min
172    {
173        diags.push(SchemaDiagnostic {
174            code: "SCHEMA_ARRAY_MIN_ITEMS",
175            severity: Severity::Error,
176            message: format!("array length {len} < min_items {min} at {path}"),
177            path: path.to_string(),
178        });
179    }
180    if let Some(max) = max_items
181        && len > max
182    {
183        diags.push(SchemaDiagnostic {
184            code: "SCHEMA_ARRAY_MAX_ITEMS",
185            severity: Severity::Error,
186            message: format!("array length {len} > max_items {max} at {path}"),
187            path: path.to_string(),
188        });
189    }
190    for (idx, item) in items_val.iter().enumerate() {
191        validate_inner(items, item, &format!("{path}[{idx}]"), diags);
192    }
193}
194
195fn validate_string(
196    min_len: Option<u64>,
197    max_len: Option<u64>,
198    regex: Option<&str>,
199    format: Option<&str>,
200    value: &CborValue,
201    path: &str,
202    diags: &mut Vec<SchemaDiagnostic>,
203) {
204    let text = match value {
205        CborValue::Text(s) => s,
206        _ => {
207            require_kind("string", false, path, diags);
208            return;
209        }
210    };
211    let len = text.chars().count() as u64;
212    if let Some(min) = min_len
213        && len < min
214    {
215        diags.push(SchemaDiagnostic {
216            code: "SCHEMA_STRING_MIN_LEN",
217            severity: Severity::Error,
218            message: format!("string length {len} < min_len {min} at {path}"),
219            path: path.to_string(),
220        });
221    }
222    if let Some(max) = max_len
223        && len > max
224    {
225        diags.push(SchemaDiagnostic {
226            code: "SCHEMA_STRING_MAX_LEN",
227            severity: Severity::Error,
228            message: format!("string length {len} > max_len {max} at {path}"),
229            path: path.to_string(),
230        });
231    }
232    if regex.is_some() {
233        diags.push(SchemaDiagnostic {
234            code: "SCHEMA_REGEX_UNSUPPORTED",
235            severity: Severity::Warning,
236            message: format!("regex constraint not enforced at {path}"),
237            path: path.to_string(),
238        });
239    }
240    if format.is_some() {
241        diags.push(SchemaDiagnostic {
242            code: "SCHEMA_FORMAT_UNSUPPORTED",
243            severity: Severity::Warning,
244            message: format!("format constraint not enforced at {path}"),
245            path: path.to_string(),
246        });
247    }
248}
249
250fn validate_int(
251    min: Option<i64>,
252    max: Option<i64>,
253    value: &CborValue,
254    path: &str,
255    diags: &mut Vec<SchemaDiagnostic>,
256) {
257    let num = match value {
258        CborValue::Integer(i) => i128::from(*i),
259        _ => {
260            require_kind("integer", false, path, diags);
261            return;
262        }
263    };
264    if let Some(min) = min
265        && num < min as i128
266    {
267        diags.push(SchemaDiagnostic {
268            code: "SCHEMA_INT_MIN",
269            severity: Severity::Error,
270            message: format!("integer {num} < min {min} at {path}"),
271            path: path.to_string(),
272        });
273    }
274    if let Some(max) = max
275        && num > max as i128
276    {
277        diags.push(SchemaDiagnostic {
278            code: "SCHEMA_INT_MAX",
279            severity: Severity::Error,
280            message: format!("integer {num} > max {max} at {path}"),
281            path: path.to_string(),
282        });
283    }
284}
285
286fn validate_float(
287    min: Option<f64>,
288    max: Option<f64>,
289    value: &CborValue,
290    path: &str,
291    diags: &mut Vec<SchemaDiagnostic>,
292) {
293    let num = match value {
294        CborValue::Float(f) => *f,
295        CborValue::Integer(i) => i128::from(*i) as f64,
296        _ => {
297            require_kind("number", false, path, diags);
298            return;
299        }
300    };
301    if let Some(min) = min
302        && num < min
303    {
304        diags.push(SchemaDiagnostic {
305            code: "SCHEMA_FLOAT_MIN",
306            severity: Severity::Error,
307            message: format!("number {num} < min {min} at {path}"),
308            path: path.to_string(),
309        });
310    }
311    if let Some(max) = max
312        && num > max
313    {
314        diags.push(SchemaDiagnostic {
315            code: "SCHEMA_FLOAT_MAX",
316            severity: Severity::Error,
317            message: format!("number {num} > max {max} at {path}"),
318            path: path.to_string(),
319        });
320    }
321}
322
323fn validate_enum(
324    values: &[CborValue],
325    value: &CborValue,
326    path: &str,
327    diags: &mut Vec<SchemaDiagnostic>,
328) {
329    if values.iter().any(|candidate| candidate == value) {
330        return;
331    }
332    diags.push(SchemaDiagnostic {
333        code: "SCHEMA_ENUM",
334        severity: Severity::Error,
335        message: format!("value is not in enum at {path}"),
336        path: path.to_string(),
337    });
338}
339
340fn validate_one_of(
341    variants: &[SchemaIr],
342    value: &CborValue,
343    path: &str,
344    diags: &mut Vec<SchemaDiagnostic>,
345) {
346    for variant in variants {
347        let mut local = Vec::new();
348        validate_inner(variant, value, path, &mut local);
349        if local.iter().all(|d| d.severity != Severity::Error) {
350            return;
351        }
352    }
353    diags.push(SchemaDiagnostic {
354        code: "SCHEMA_ONE_OF",
355        severity: Severity::Error,
356        message: format!("value does not match any oneOf variant at {path}"),
357        path: path.to_string(),
358    });
359}