greentic_runner_host/runner/
schema_validator.rs1use 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}