luft_core/contract/
schema.rs1use serde_json::Value;
8use thiserror::Error;
9
10#[derive(Error, Debug)]
11pub enum SchemaError {
12 #[error("schema is not a valid JSON Schema: {0}")]
13 InvalidSchema(String),
14 #[error("output does not match schema: {0}")]
15 ValidationFailed(String),
16 #[error("internal error: {0}")]
17 Internal(String),
18}
19
20pub fn validate_output(output: &Value, schema: &Value) -> Result<(), SchemaError> {
30 match schema {
32 Value::Object(_) => {}
33 Value::Bool(b) => {
34 if !b {
36 return Err(SchemaError::ValidationFailed(
37 "schema is 'false' (rejects all)".to_string(),
38 ));
39 }
40 return Ok(());
41 }
42 _ => {
43 return Err(SchemaError::InvalidSchema(
44 "schema must be an object or boolean".to_string(),
45 ));
46 }
47 }
48
49 let validator = jsonschema::JSONSchema::options()
51 .with_draft(jsonschema::Draft::Draft7)
52 .compile(schema)
53 .map_err(|e| SchemaError::InvalidSchema(format!("failed to compile schema: {}", e)))?;
54
55 if let Err(errors) = validator.validate(output) {
56 let details: Vec<String> = errors
58 .take(5)
59 .map(|e| format!("instance {}: {}", e.instance_path, e))
60 .collect();
61 return Err(SchemaError::ValidationFailed(details.join("; ")));
62 }
63
64 Ok(())
65}
66
67#[cfg(test)]
68mod tests {
69 use super::*;
70 use serde_json::json;
71
72 #[test]
73 fn test_valid_output_matches_schema() {
74 let schema = json!({
75 "type": "object",
76 "properties": {
77 "result": { "type": "string" },
78 "confidence": { "type": "number", "minimum": 0, "maximum": 1 }
79 },
80 "required": ["result"]
81 });
82
83 let output = json!({
84 "result": "success",
85 "confidence": 0.95
86 });
87
88 assert!(validate_output(&output, &schema).is_ok());
89 }
90
91 #[test]
92 fn test_invalid_output_type() {
93 let schema = json!({
94 "type": "object",
95 "properties": {
96 "result": { "type": "string" }
97 },
98 "required": ["result"]
99 });
100
101 let output = json!({"result": 42});
102 let err = validate_output(&output, &schema).unwrap_err();
103 assert!(matches!(err, SchemaError::ValidationFailed(_)));
104 }
105
106 #[test]
107 fn test_missing_required_field() {
108 let schema = json!({
109 "type": "object",
110 "properties": {
111 "result": { "type": "string" }
112 },
113 "required": ["result"]
114 });
115
116 let output = json!({"other": "value"});
117 let err = validate_output(&output, &schema).unwrap_err();
118 assert!(matches!(err, SchemaError::ValidationFailed(_)));
119 }
120
121 #[test]
122 fn test_boolean_schema_false() {
123 let err = validate_output(&json!("anything"), &json!(false)).unwrap_err();
124 assert!(matches!(err, SchemaError::ValidationFailed(_)));
125 }
126
127 #[test]
128 fn test_boolean_schema_true() {
129 assert!(validate_output(&json!("anything"), &json!(true)).is_ok());
130 assert!(validate_output(&json!({"key": "value"}), &json!(true)).is_ok());
131 }
132
133 #[test]
134 fn test_invalid_schema_not_object() {
135 let err = validate_output(&json!("value"), &json!("not_a_schema")).unwrap_err();
136 assert!(matches!(err, SchemaError::InvalidSchema(_)));
137 }
138
139 #[test]
140 fn test_nested_object_validation() {
141 let schema = json!({
142 "type": "object",
143 "properties": {
144 "data": {
145 "type": "object",
146 "properties": {
147 "id": { "type": "integer" },
148 "tags": {
149 "type": "array",
150 "items": { "type": "string" }
151 }
152 },
153 "required": ["id"]
154 }
155 },
156 "required": ["data"]
157 });
158
159 let valid = json!({
160 "data": {
161 "id": 1,
162 "tags": ["a", "b"]
163 }
164 });
165 assert!(validate_output(&valid, &schema).is_ok());
166
167 let invalid = json!({
168 "data": {
169 "id": "not_an_int",
170 "tags": [1, 2]
171 }
172 });
173 assert!(validate_output(&invalid, &schema).is_err());
174 }
175}