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    // Lightweight envelope schema (v0.9 family including v0.9.1 version stamp).
92    let version = json!({ "type": "string" });
93    json!({
94        "type": "object",
95        "oneOf": [
96            {
97                "required": ["createSurface"],
98                "properties": {
99                    "version": version,
100                    "createSurface": {
101                        "type": "object",
102                        "required": ["surfaceId", "catalogId"],
103                        "properties": {
104                            "surfaceId": { "type": "string" },
105                            "catalogId": { "type": "string" },
106                            "theme": { "type": "object" },
107                            "sendDataModel": { "type": "boolean" },
108                            "components": { "type": "array" },
109                            "dataModel": {}
110                        }
111                    }
112                }
113            },
114            {
115                "required": ["updateComponents"],
116                "properties": {
117                    "version": version,
118                    "updateComponents": {
119                        "type": "object",
120                        "required": ["surfaceId", "components"],
121                        "properties": {
122                            "surfaceId": { "type": "string" },
123                            "components": {
124                                "type": "array",
125                                "minItems": 1,
126                                "items": {
127                                    "type": "object",
128                                    "required": ["id", "component"],
129                                    "properties": {
130                                        "id": { "type": "string" },
131                                        "component": {
132                                            "oneOf": [
133                                                { "type": "string" },
134                                                { "type": "object" }
135                                            ],
136                                            "description": "Component discriminator in flat form (\"Text\") or legacy nested object form."
137                                        }
138                                    }
139                                }
140                            }
141                        }
142                    }
143                }
144            },
145            {
146                "required": ["updateDataModel"],
147                "properties": {
148                    "version": version,
149                    "updateDataModel": {
150                        "type": "object",
151                        "required": ["surfaceId"],
152                        "properties": {
153                            "surfaceId": { "type": "string" },
154                            "path": { "type": "string" },
155                            "value": {}
156                        }
157                    }
158                }
159            },
160            {
161                "required": ["deleteSurface"],
162                "properties": {
163                    "version": version,
164                    "deleteSurface": {
165                        "type": "object",
166                        "required": ["surfaceId"],
167                        "properties": {
168                            "surfaceId": { "type": "string" }
169                        }
170                    }
171                }
172            },
173            {
174                "required": ["actionId", "actionResponse"],
175                "properties": {
176                    "version": version,
177                    "actionId": { "type": "string" },
178                    "actionResponse": {
179                        "type": "object",
180                        "properties": {
181                            "value": {},
182                            "error": {
183                                "type": "object",
184                                "required": ["code", "message"],
185                                "properties": {
186                                    "code": { "type": "string" },
187                                    "message": { "type": "string" }
188                                }
189                            }
190                        }
191                    }
192                }
193            },
194            {
195                "required": ["functionCallId", "callFunction"],
196                "properties": {
197                    "version": version,
198                    "functionCallId": { "type": "string" },
199                    "wantResponse": { "type": "boolean" },
200                    "callFunction": {
201                        "type": "object",
202                        "required": ["call"],
203                        "properties": {
204                            "call": { "type": "string" },
205                            "args": {}
206                        }
207                    }
208                }
209            }
210        ]
211    })
212}
213
214fn schema_v0_8() -> Value {
215    json!({
216        "type": "object",
217        "oneOf": [
218            {
219                "required": ["beginRendering"],
220                "properties": {
221                    "beginRendering": {
222                        "type": "object",
223                        "required": ["surfaceId", "root"],
224                        "properties": {
225                            "surfaceId": { "type": "string" },
226                            "root": { "type": "string" },
227                            "catalogId": { "type": "string" },
228                            "styles": { "type": "object" }
229                        }
230                    }
231                }
232            },
233            {
234                "required": ["surfaceUpdate"],
235                "properties": {
236                    "surfaceUpdate": {
237                        "type": "object",
238                        "required": ["surfaceId", "components"],
239                        "properties": {
240                            "surfaceId": { "type": "string" },
241                            "components": {
242                                "type": "array",
243                                "minItems": 1,
244                                "items": {
245                                    "type": "object",
246                                    "required": ["id", "component"],
247                                    "properties": {
248                                        "id": { "type": "string" },
249                                        "component": { "type": "object" }
250                                    }
251                                }
252                            }
253                        }
254                    }
255                }
256            },
257            {
258                "required": ["dataModelUpdate"],
259                "properties": {
260                    "dataModelUpdate": {
261                        "type": "object",
262                        "required": ["surfaceId", "contents"],
263                        "properties": {
264                            "surfaceId": { "type": "string" },
265                            "path": { "type": "string" },
266                            "contents": { "type": "array" }
267                        }
268                    }
269                }
270            },
271            {
272                "required": ["deleteSurface"],
273                "properties": {
274                    "deleteSurface": {
275                        "type": "object",
276                        "required": ["surfaceId"],
277                        "properties": {
278                            "surfaceId": { "type": "string" }
279                        }
280                    }
281                }
282            }
283        ]
284    })
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::a2ui::messages::{
291        A2uiMessage, CreateSurface, CreateSurfaceMessage, UpdateComponents, UpdateComponentsMessage,
292    };
293    use serde_json::json;
294
295    #[test]
296    fn validates_v0_9_create_surface() {
297        let validator = A2uiValidator::new().unwrap();
298        let value = json!({
299            "createSurface": {
300                "surfaceId": "main",
301                "catalogId": "catalog"
302            }
303        });
304        assert!(
305            validator
306                .validate_value(&value, A2uiSchemaVersion::V0_9)
307                .is_ok()
308        );
309    }
310
311    #[test]
312    fn rejects_invalid_v0_9_message() {
313        let validator = A2uiValidator::new().unwrap();
314        let value = json!({ "createSurface": { "catalogId": "missing_surface" } });
315        assert!(
316            validator
317                .validate_value(&value, A2uiSchemaVersion::V0_9)
318                .is_err()
319        );
320    }
321
322    #[test]
323    fn validates_struct_message() {
324        let validator = A2uiValidator::new().unwrap();
325        let message = A2uiMessage::CreateSurface(CreateSurfaceMessage::new(CreateSurface {
326            surface_id: "main".to_string(),
327            catalog_id: "catalog".to_string(),
328            theme: None,
329            send_data_model: None,
330            components: None,
331            data_model: None,
332        }));
333        assert!(
334            validator
335                .validate_message(&message, A2uiSchemaVersion::V0_9)
336                .is_ok()
337        );
338    }
339
340    #[test]
341    fn validates_update_components_minimal() {
342        let validator = A2uiValidator::new().unwrap();
343        let message =
344            A2uiMessage::UpdateComponents(UpdateComponentsMessage::new(UpdateComponents {
345                surface_id: "main".to_string(),
346                components: vec![json!({
347                    "id": "root",
348                    "component": {
349                        "Text": {
350                            "text": { "literalString": "Hello" }
351                        }
352                    }
353                })],
354            }));
355        assert!(
356            validator
357                .validate_message(&message, A2uiSchemaVersion::V0_9)
358                .is_ok()
359        );
360    }
361
362    #[test]
363    fn validates_update_components_flat_shape() {
364        let validator = A2uiValidator::new().unwrap();
365        let message =
366            A2uiMessage::UpdateComponents(UpdateComponentsMessage::new(UpdateComponents {
367                surface_id: "main".to_string(),
368                components: vec![json!({
369                    "id": "root",
370                    "component": "Text",
371                    "text": "Hello"
372                })],
373            }));
374        assert!(
375            validator
376                .validate_message(&message, A2uiSchemaVersion::V0_9)
377                .is_ok()
378        );
379    }
380}