Skip to main content

adk_ui/a2ui/
validator.rs

1use jsonschema::Validator;
2use serde_json::{Value, json};
3
4use super::messages::A2uiMessage;
5
6#[derive(Debug, Clone, Copy)]
7pub enum A2uiSchemaVersion {
8    V0_9,
9    V0_8,
10}
11
12#[derive(Debug, Clone)]
13pub struct A2uiValidationError {
14    pub message: String,
15    pub instance_path: String,
16}
17
18impl std::fmt::Display for A2uiValidationError {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        write!(f, "{} at {}", self.message, self.instance_path)
21    }
22}
23
24impl std::error::Error for A2uiValidationError {}
25
26/// Lightweight A2UI schema validator.
27///
28/// This validates envelope structure and required fields. Component-level
29/// validation is intentionally minimal and can be upgraded later with full
30/// catalog schema resolution.
31pub struct A2uiValidator {
32    v0_9: Validator,
33    v0_8: Validator,
34}
35
36impl A2uiValidator {
37    pub fn new() -> Result<Self, A2uiValidationError> {
38        let v0_9 = Validator::new(&schema_v0_9()).map_err(|e| A2uiValidationError {
39            message: format!("Invalid v0.9 schema: {}", e),
40            instance_path: "/".to_string(),
41        })?;
42        let v0_8 = Validator::new(&schema_v0_8()).map_err(|e| A2uiValidationError {
43            message: format!("Invalid v0.8 schema: {}", e),
44            instance_path: "/".to_string(),
45        })?;
46
47        Ok(Self { v0_9, v0_8 })
48    }
49
50    pub fn validate_message(
51        &self,
52        message: &A2uiMessage,
53        version: A2uiSchemaVersion,
54    ) -> Result<(), Vec<A2uiValidationError>> {
55        let value = serde_json::to_value(message).map_err(|e| {
56            vec![A2uiValidationError {
57                message: format!("Serialization failed: {}", e),
58                instance_path: "/".to_string(),
59            }]
60        })?;
61        self.validate_value(&value, version)
62    }
63
64    pub fn validate_value(
65        &self,
66        value: &Value,
67        version: A2uiSchemaVersion,
68    ) -> Result<(), Vec<A2uiValidationError>> {
69        let validator = match version {
70            A2uiSchemaVersion::V0_9 => &self.v0_9,
71            A2uiSchemaVersion::V0_8 => &self.v0_8,
72        };
73
74        let mapped = validator
75            .iter_errors(value)
76            .map(|e| A2uiValidationError {
77                message: e.to_string(),
78                instance_path: e.instance_path.to_string(),
79            })
80            .collect::<Vec<_>>();
81
82        if !mapped.is_empty() {
83            return Err(mapped);
84        }
85
86        Ok(())
87    }
88}
89
90fn schema_v0_9() -> Value {
91    json!({
92        "type": "object",
93        "oneOf": [
94            {
95                "required": ["createSurface"],
96                "properties": {
97                    "createSurface": {
98                        "type": "object",
99                        "required": ["surfaceId", "catalogId"],
100                        "properties": {
101                            "surfaceId": { "type": "string" },
102                            "catalogId": { "type": "string" },
103                            "theme": { "type": "object" },
104                            "sendDataModel": { "type": "boolean" }
105                        }
106                    }
107                }
108            },
109            {
110                "required": ["updateComponents"],
111                "properties": {
112                    "updateComponents": {
113                        "type": "object",
114                        "required": ["surfaceId", "components"],
115                        "properties": {
116                            "surfaceId": { "type": "string" },
117                            "components": {
118                                "type": "array",
119                                "minItems": 1,
120                                "items": {
121                                    "type": "object",
122                                    "required": ["id", "component"],
123                                    "properties": {
124                                        "id": { "type": "string" },
125                                        "component": {
126                                            "oneOf": [
127                                                { "type": "string" },
128                                                { "type": "object" }
129                                            ],
130                                            "description": "Component discriminator in flat form (\"Text\") or legacy nested object form."
131                                        }
132                                    }
133                                }
134                            }
135                        }
136                    }
137                }
138            },
139            {
140                "required": ["updateDataModel"],
141                "properties": {
142                    "updateDataModel": {
143                        "type": "object",
144                        "required": ["surfaceId"],
145                        "properties": {
146                            "surfaceId": { "type": "string" },
147                            "path": { "type": "string" },
148                            "value": {}
149                        }
150                    }
151                }
152            },
153            {
154                "required": ["deleteSurface"],
155                "properties": {
156                    "deleteSurface": {
157                        "type": "object",
158                        "required": ["surfaceId"],
159                        "properties": {
160                            "surfaceId": { "type": "string" }
161                        }
162                    }
163                }
164            }
165        ]
166    })
167}
168
169fn schema_v0_8() -> Value {
170    json!({
171        "type": "object",
172        "oneOf": [
173            {
174                "required": ["beginRendering"],
175                "properties": {
176                    "beginRendering": {
177                        "type": "object",
178                        "required": ["surfaceId", "root"],
179                        "properties": {
180                            "surfaceId": { "type": "string" },
181                            "root": { "type": "string" },
182                            "catalogId": { "type": "string" },
183                            "styles": { "type": "object" }
184                        }
185                    }
186                }
187            },
188            {
189                "required": ["surfaceUpdate"],
190                "properties": {
191                    "surfaceUpdate": {
192                        "type": "object",
193                        "required": ["surfaceId", "components"],
194                        "properties": {
195                            "surfaceId": { "type": "string" },
196                            "components": {
197                                "type": "array",
198                                "minItems": 1,
199                                "items": {
200                                    "type": "object",
201                                    "required": ["id", "component"],
202                                    "properties": {
203                                        "id": { "type": "string" },
204                                        "component": { "type": "object" }
205                                    }
206                                }
207                            }
208                        }
209                    }
210                }
211            },
212            {
213                "required": ["dataModelUpdate"],
214                "properties": {
215                    "dataModelUpdate": {
216                        "type": "object",
217                        "required": ["surfaceId", "contents"],
218                        "properties": {
219                            "surfaceId": { "type": "string" },
220                            "path": { "type": "string" },
221                            "contents": { "type": "array" }
222                        }
223                    }
224                }
225            },
226            {
227                "required": ["deleteSurface"],
228                "properties": {
229                    "deleteSurface": {
230                        "type": "object",
231                        "required": ["surfaceId"],
232                        "properties": {
233                            "surfaceId": { "type": "string" }
234                        }
235                    }
236                }
237            }
238        ]
239    })
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::a2ui::messages::{
246        A2uiMessage, CreateSurface, CreateSurfaceMessage, UpdateComponents, UpdateComponentsMessage,
247    };
248    use serde_json::json;
249
250    #[test]
251    fn validates_v0_9_create_surface() {
252        let validator = A2uiValidator::new().unwrap();
253        let value = json!({
254            "createSurface": {
255                "surfaceId": "main",
256                "catalogId": "catalog"
257            }
258        });
259        assert!(validator.validate_value(&value, A2uiSchemaVersion::V0_9).is_ok());
260    }
261
262    #[test]
263    fn rejects_invalid_v0_9_message() {
264        let validator = A2uiValidator::new().unwrap();
265        let value = json!({ "createSurface": { "catalogId": "missing_surface" } });
266        assert!(validator.validate_value(&value, A2uiSchemaVersion::V0_9).is_err());
267    }
268
269    #[test]
270    fn validates_struct_message() {
271        let validator = A2uiValidator::new().unwrap();
272        let message = A2uiMessage::CreateSurface(CreateSurfaceMessage {
273            create_surface: CreateSurface {
274                surface_id: "main".to_string(),
275                catalog_id: "catalog".to_string(),
276                theme: None,
277                send_data_model: None,
278            },
279        });
280        assert!(validator.validate_message(&message, A2uiSchemaVersion::V0_9).is_ok());
281    }
282
283    #[test]
284    fn validates_update_components_minimal() {
285        let validator = A2uiValidator::new().unwrap();
286        let message = A2uiMessage::UpdateComponents(UpdateComponentsMessage {
287            update_components: UpdateComponents {
288                surface_id: "main".to_string(),
289                components: vec![json!({
290                    "id": "root",
291                    "component": {
292                        "Text": {
293                            "text": { "literalString": "Hello" }
294                        }
295                    }
296                })],
297            },
298        });
299        assert!(validator.validate_message(&message, A2uiSchemaVersion::V0_9).is_ok());
300    }
301
302    #[test]
303    fn validates_update_components_flat_shape() {
304        let validator = A2uiValidator::new().unwrap();
305        let message = A2uiMessage::UpdateComponents(UpdateComponentsMessage {
306            update_components: UpdateComponents {
307                surface_id: "main".to_string(),
308                components: vec![json!({
309                    "id": "root",
310                    "component": "Text",
311                    "text": "Hello"
312                })],
313            },
314        });
315        assert!(validator.validate_message(&message, A2uiSchemaVersion::V0_9).is_ok());
316    }
317}