struct_llm/
schema.rs

1//! JSON Schema generation and validation utilities
2
3use crate::error::{Error, Result};
4use serde_json::{json, Value};
5
6/// Helper to build JSON schema for common Rust types
7pub struct SchemaBuilder;
8
9impl SchemaBuilder {
10    /// Create a string schema
11    pub fn string() -> Value {
12        json!({ "type": "string" })
13    }
14
15    /// Create a string schema with enum values
16    pub fn string_enum(values: &[&str]) -> Value {
17        json!({
18            "type": "string",
19            "enum": values
20        })
21    }
22
23    /// Create a number schema
24    pub fn number() -> Value {
25        json!({ "type": "number" })
26    }
27
28    /// Create an integer schema
29    pub fn integer() -> Value {
30        json!({ "type": "integer" })
31    }
32
33    /// Create a boolean schema
34    pub fn boolean() -> Value {
35        json!({ "type": "boolean" })
36    }
37
38    /// Create an array schema
39    pub fn array(items: Value) -> Value {
40        json!({
41            "type": "array",
42            "items": items
43        })
44    }
45
46    /// Create an object schema
47    pub fn object(properties: Value, required: &[&str]) -> Value {
48        json!({
49            "type": "object",
50            "properties": properties,
51            "required": required
52        })
53    }
54
55    /// Add description to a schema
56    pub fn with_description(mut schema: Value, description: &str) -> Value {
57        if let Some(obj) = schema.as_object_mut() {
58            obj.insert("description".to_string(), json!(description));
59        }
60        schema
61    }
62}
63
64/// Validate a JSON value against a JSON Schema
65///
66/// This is a basic validator that checks common schema constraints:
67/// - Type checking (string, number, integer, boolean, array, object)
68/// - Required properties
69/// - Enum values
70/// - Array items
71///
72/// Note: This is not a complete JSON Schema validator. For production use,
73/// consider using a dedicated JSON Schema validation library.
74pub fn validate(value: &Value, schema: &Value) -> Result<()> {
75    // Check type
76    if let Some(schema_type) = schema.get("type").and_then(|t| t.as_str()) {
77        match schema_type {
78            "string" => {
79                if !value.is_string() {
80                    return Err(Error::ValidationFailed(format!(
81                        "Expected string, got {:?}",
82                        value
83                    )));
84                }
85
86                // Check enum if present
87                if let Some(enum_values) = schema.get("enum") {
88                    if let Some(enum_array) = enum_values.as_array() {
89                        if !enum_array.contains(value) {
90                            return Err(Error::ValidationFailed(format!(
91                                "Value {:?} not in allowed enum values",
92                                value
93                            )));
94                        }
95                    }
96                }
97            }
98            "number" => {
99                if !value.is_f64() && !value.is_i64() && !value.is_u64() {
100                    return Err(Error::ValidationFailed(format!(
101                        "Expected number, got {:?}",
102                        value
103                    )));
104                }
105            }
106            "integer" => {
107                if !value.is_i64() && !value.is_u64() {
108                    return Err(Error::ValidationFailed(format!(
109                        "Expected integer, got {:?}",
110                        value
111                    )));
112                }
113            }
114            "boolean" => {
115                if !value.is_boolean() {
116                    return Err(Error::ValidationFailed(format!(
117                        "Expected boolean, got {:?}",
118                        value
119                    )));
120                }
121            }
122            "array" => {
123                if !value.is_array() {
124                    return Err(Error::ValidationFailed(format!(
125                        "Expected array, got {:?}",
126                        value
127                    )));
128                }
129
130                // Validate items if schema specifies
131                if let Some(items_schema) = schema.get("items") {
132                    if let Some(array) = value.as_array() {
133                        for item in array {
134                            validate(item, items_schema)?;
135                        }
136                    }
137                }
138            }
139            "object" => {
140                if !value.is_object() {
141                    return Err(Error::ValidationFailed(format!(
142                        "Expected object, got {:?}",
143                        value
144                    )));
145                }
146
147                // Check required properties
148                if let Some(required) = schema.get("required") {
149                    if let Some(required_array) = required.as_array() {
150                        if let Some(obj) = value.as_object() {
151                            for req_prop in required_array {
152                                if let Some(prop_name) = req_prop.as_str() {
153                                    if !obj.contains_key(prop_name) {
154                                        return Err(Error::ValidationFailed(format!(
155                                            "Missing required property: {}",
156                                            prop_name
157                                        )));
158                                    }
159                                }
160                            }
161                        }
162                    }
163                }
164
165                // Validate properties if schema specifies
166                if let Some(properties) = schema.get("properties") {
167                    if let Some(props_obj) = properties.as_object() {
168                        if let Some(value_obj) = value.as_object() {
169                            for (prop_name, prop_value) in value_obj {
170                                if let Some(prop_schema) = props_obj.get(prop_name) {
171                                    validate(prop_value, prop_schema)?;
172                                }
173                            }
174                        }
175                    }
176                }
177            }
178            _ => {
179                // Unknown type, skip validation
180            }
181        }
182    }
183
184    Ok(())
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn test_schema_builder() {
193        let schema = SchemaBuilder::string();
194        assert_eq!(schema["type"], "string");
195
196        let schema = SchemaBuilder::string_enum(&["positive", "negative", "neutral"]);
197        assert_eq!(schema["type"], "string");
198        assert!(schema["enum"].is_array());
199        assert_eq!(schema["enum"].as_array().unwrap().len(), 3);
200
201        let schema = SchemaBuilder::object(
202            json!({
203                "name": SchemaBuilder::string(),
204                "age": SchemaBuilder::integer()
205            }),
206            &["name"],
207        );
208        assert_eq!(schema["type"], "object");
209        assert!(schema["properties"].is_object());
210        assert_eq!(schema["required"].as_array().unwrap().len(), 1);
211    }
212
213    #[test]
214    fn test_with_description() {
215        let schema = SchemaBuilder::with_description(
216            SchemaBuilder::string(),
217            "A user's name",
218        );
219        assert_eq!(schema["description"], "A user's name");
220    }
221
222    #[test]
223    fn test_validate_string() {
224        let schema = SchemaBuilder::string();
225        let value = json!("hello");
226
227        assert!(validate(&value, &schema).is_ok());
228
229        let invalid = json!(123);
230        assert!(validate(&invalid, &schema).is_err());
231    }
232
233    #[test]
234    fn test_validate_enum() {
235        let schema = SchemaBuilder::string_enum(&["positive", "negative", "neutral"]);
236        let value = json!("positive");
237
238        assert!(validate(&value, &schema).is_ok());
239
240        let invalid = json!("unknown");
241        assert!(validate(&invalid, &schema).is_err());
242    }
243
244    #[test]
245    fn test_validate_integer() {
246        let schema = SchemaBuilder::integer();
247        let value = json!(42);
248
249        assert!(validate(&value, &schema).is_ok());
250
251        let invalid = json!(3.14);
252        assert!(validate(&invalid, &schema).is_err());
253    }
254
255    #[test]
256    fn test_validate_array() {
257        let schema = SchemaBuilder::array(SchemaBuilder::string());
258        let value = json!(["a", "b", "c"]);
259
260        assert!(validate(&value, &schema).is_ok());
261
262        let invalid_type = json!("not an array");
263        assert!(validate(&invalid_type, &schema).is_err());
264
265        let invalid_items = json!([1, 2, 3]);
266        assert!(validate(&invalid_items, &schema).is_err());
267    }
268
269    #[test]
270    fn test_validate_object() {
271        let schema = SchemaBuilder::object(
272            json!({
273                "name": SchemaBuilder::string(),
274                "age": SchemaBuilder::integer()
275            }),
276            &["name"],
277        );
278
279        let value = json!({
280            "name": "Alice",
281            "age": 30
282        });
283        assert!(validate(&value, &schema).is_ok());
284
285        // Missing required field
286        let missing_required = json!({
287            "age": 30
288        });
289        assert!(validate(&missing_required, &schema).is_err());
290
291        // Wrong property type
292        let wrong_type = json!({
293            "name": "Alice",
294            "age": "thirty"
295        });
296        assert!(validate(&wrong_type, &schema).is_err());
297    }
298
299    #[test]
300    fn test_validate_nested() {
301        let schema = SchemaBuilder::object(
302            json!({
303                "user": SchemaBuilder::object(
304                    json!({
305                        "name": SchemaBuilder::string(),
306                        "tags": SchemaBuilder::array(SchemaBuilder::string())
307                    }),
308                    &["name"]
309                )
310            }),
311            &["user"],
312        );
313
314        let value = json!({
315            "user": {
316                "name": "Bob",
317                "tags": ["admin", "user"]
318            }
319        });
320        assert!(validate(&value, &schema).is_ok());
321
322        let invalid = json!({
323            "user": {
324                "name": "Bob",
325                "tags": [1, 2, 3]
326            }
327        });
328        assert!(validate(&invalid, &schema).is_err());
329    }
330}