Skip to main content

greentic_runner_host/runner/
schema_validator.rs

1use jsonschema::{Draft, Validator};
2use serde_json::Value;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct SchemaValidationIssue {
6    pub code: String,
7    pub path: String,
8    pub message_key: String,
9    pub fallback: String,
10}
11
12pub fn validate_json_instance(
13    schema: &Value,
14    instance: &Value,
15    strict: bool,
16) -> Vec<SchemaValidationIssue> {
17    let mut issues = Vec::new();
18    let mut unsupported = Vec::new();
19    collect_unsupported_constraints(schema, "", &mut unsupported);
20    if strict && !unsupported.is_empty() {
21        for path in unsupported {
22            issues.push(SchemaValidationIssue {
23                code: "unsupported_schema_constraint".to_string(),
24                path,
25                message_key: "runner.schema.unsupported_constraint".to_string(),
26                fallback: "schema contains unsupported constraint".to_string(),
27            });
28        }
29        return issues;
30    }
31
32    let validator = match compile_validator(schema) {
33        Ok(validator) => validator,
34        Err(err) => {
35            issues.push(SchemaValidationIssue {
36                code: "invalid_schema".to_string(),
37                path: "/".to_string(),
38                message_key: "runner.schema.invalid_schema".to_string(),
39                fallback: format!("invalid schema: {err}"),
40            });
41            return issues;
42        }
43    };
44
45    for err in validator.iter_errors(instance) {
46        let path = err.instance_path().to_string();
47        issues.push(SchemaValidationIssue {
48            code: "schema_validation".to_string(),
49            path: if path.is_empty() {
50                "/".to_string()
51            } else {
52                path
53            },
54            message_key: "runner.schema.validation_failed".to_string(),
55            fallback: err.to_string(),
56        });
57    }
58    issues
59}
60
61fn compile_validator(schema: &Value) -> Result<Validator, String> {
62    jsonschema::options()
63        .with_draft(Draft::Draft7)
64        .build(schema)
65        .map_err(|err| err.to_string())
66}
67
68fn collect_unsupported_constraints(schema: &Value, path: &str, out: &mut Vec<String>) {
69    let Some(map) = schema.as_object() else {
70        return;
71    };
72    for key in ["pattern", "format", "patternProperties"] {
73        if map.contains_key(key) {
74            out.push(format!("{}/{}", path_or_root(path), key));
75        }
76    }
77    for (key, value) in map {
78        let next = format!("{}/{}", path_or_root(path), key);
79        match value {
80            Value::Object(_) => collect_unsupported_constraints(value, &next, out),
81            Value::Array(items) => {
82                for (idx, item) in items.iter().enumerate() {
83                    let item_path = format!("{}/{}", next, idx);
84                    collect_unsupported_constraints(item, &item_path, out);
85                }
86            }
87            _ => {}
88        }
89    }
90}
91
92fn path_or_root(path: &str) -> &str {
93    if path.is_empty() { "" } else { path }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use serde_json::json;
100
101    #[test]
102    fn strict_mode_rejects_unsupported_constraints() {
103        let schema = json!({
104            "type": "object",
105            "properties": {
106                "id": {
107                    "type": "string",
108                    "pattern": "^[a-z]+$"
109                }
110            }
111        });
112        let issues = validate_json_instance(&schema, &json!({"id": "abc"}), true);
113        assert_eq!(issues.len(), 1);
114        assert_eq!(issues[0].code, "unsupported_schema_constraint");
115    }
116
117    #[test]
118    fn valid_instance_returns_no_issues() {
119        let schema = json!({
120            "type": "object",
121            "required": ["message"],
122            "properties": {
123                "message": { "type": "string" }
124            },
125            "additionalProperties": false
126        });
127        let issues = validate_json_instance(&schema, &json!({"message": "ok"}), true);
128        assert!(issues.is_empty());
129    }
130
131    #[test]
132    fn invalid_instance_reports_schema_issue() {
133        let schema = json!({
134            "type": "object",
135            "required": ["message"],
136            "properties": {
137                "message": { "type": "string" }
138            },
139            "additionalProperties": false
140        });
141        let issues = validate_json_instance(&schema, &json!({"message": 42}), true);
142        assert!(!issues.is_empty());
143        assert_eq!(issues[0].code, "schema_validation");
144    }
145}