1use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5
6pub const A2UI_PROTOCOL_VERSION: &str = "v0.9.1";
8
9pub const A2UI_MIME_TYPE: &str = "application/a2ui+json";
11
12pub const A2UI_MIME_TYPE_LEGACY: &str = "application/json+a2ui";
14
15pub const A2UI_BASIC_CATALOG_ID: &str =
17 "https://a2ui.org/specification/v0_9_1/catalogs/basic/catalog.json";
18
19pub const A2UI_BASIC_CATALOG_ID_V1: &str =
21 "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json";
22
23fn default_a2ui_version() -> String {
24 A2UI_PROTOCOL_VERSION.to_string()
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct CreateSurface {
31 pub surface_id: String,
32 pub catalog_id: String,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub theme: Option<Value>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub send_data_model: Option<bool>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub components: Option<Vec<Value>>,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub data_model: Option<Value>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct UpdateComponents {
49 pub surface_id: String,
50 pub components: Vec<Value>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct UpdateDataModel {
57 pub surface_id: String,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub path: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub value: Option<Value>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct DeleteSurface {
68 pub surface_id: String,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct CreateSurfaceMessage {
74 #[serde(default = "default_a2ui_version")]
75 pub version: String,
76 #[serde(rename = "createSurface")]
77 pub create_surface: CreateSurface,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct UpdateComponentsMessage {
83 #[serde(default = "default_a2ui_version")]
84 pub version: String,
85 #[serde(rename = "updateComponents")]
86 pub update_components: UpdateComponents,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct UpdateDataModelMessage {
92 #[serde(default = "default_a2ui_version")]
93 pub version: String,
94 #[serde(rename = "updateDataModel")]
95 pub update_data_model: UpdateDataModel,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct DeleteSurfaceMessage {
101 #[serde(default = "default_a2ui_version")]
102 pub version: String,
103 #[serde(rename = "deleteSurface")]
104 pub delete_surface: DeleteSurface,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct ActionResponseBody {
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub value: Option<Value>,
113 #[serde(skip_serializing_if = "Option::is_none")]
114 pub error: Option<A2uiActionResponseError>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct A2uiActionResponseError {
120 pub code: String,
121 pub message: String,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(rename_all = "camelCase")]
130pub struct ActionResponseMessage {
131 #[serde(default = "default_a2ui_version")]
132 pub version: String,
133 pub action_id: String,
134 pub action_response: ActionResponseBody,
135}
136
137impl ActionResponseMessage {
138 pub fn success(action_id: impl Into<String>, value: Value) -> Self {
139 Self {
140 version: "v1.0".to_string(),
141 action_id: action_id.into(),
142 action_response: ActionResponseBody {
143 value: Some(value),
144 error: None,
145 },
146 }
147 }
148
149 pub fn failure(
150 action_id: impl Into<String>,
151 code: impl Into<String>,
152 message: impl Into<String>,
153 ) -> Self {
154 Self {
155 version: "v1.0".to_string(),
156 action_id: action_id.into(),
157 action_response: ActionResponseBody {
158 value: None,
159 error: Some(A2uiActionResponseError {
160 code: code.into(),
161 message: message.into(),
162 }),
163 },
164 }
165 }
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
170#[serde(rename_all = "camelCase")]
171pub struct CallFunctionBody {
172 pub call: String,
173 #[serde(skip_serializing_if = "Option::is_none")]
174 pub args: Option<Value>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct CallFunctionMessage {
180 #[serde(default = "default_a2ui_version")]
181 pub version: String,
182 pub function_call_id: String,
183 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
184 pub want_response: bool,
185 pub call_function: CallFunctionBody,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
190#[serde(rename_all = "camelCase")]
191pub struct FunctionResponseBody {
192 pub function_call_id: String,
193 pub call: String,
194 pub value: Value,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
198#[serde(rename_all = "camelCase")]
199pub struct FunctionResponseMessage {
200 #[serde(default = "default_a2ui_version")]
201 pub version: String,
202 pub function_response: FunctionResponseBody,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
207#[serde(untagged)]
208pub enum A2uiMessage {
209 CreateSurface(CreateSurfaceMessage),
210 UpdateComponents(UpdateComponentsMessage),
211 UpdateDataModel(UpdateDataModelMessage),
212 DeleteSurface(DeleteSurfaceMessage),
213 ActionResponse(ActionResponseMessage),
214 CallFunction(CallFunctionMessage),
215}
216
217impl A2uiMessage {
218 pub fn version(&self) -> &str {
219 match self {
220 Self::CreateSurface(m) => &m.version,
221 Self::UpdateComponents(m) => &m.version,
222 Self::UpdateDataModel(m) => &m.version,
223 Self::DeleteSurface(m) => &m.version,
224 Self::ActionResponse(m) => &m.version,
225 Self::CallFunction(m) => &m.version,
226 }
227 }
228
229 pub fn with_version(mut self, version: impl Into<String>) -> Self {
230 let version = version.into();
231 match &mut self {
232 Self::CreateSurface(m) => m.version = version,
233 Self::UpdateComponents(m) => m.version = version,
234 Self::UpdateDataModel(m) => m.version = version,
235 Self::DeleteSurface(m) => m.version = version,
236 Self::ActionResponse(m) => m.version = version,
237 Self::CallFunction(m) => m.version = version,
238 }
239 self
240 }
241}
242
243impl CreateSurfaceMessage {
245 pub fn new(create_surface: CreateSurface) -> Self {
246 Self {
247 version: default_a2ui_version(),
248 create_surface,
249 }
250 }
251}
252
253impl UpdateComponentsMessage {
254 pub fn new(update_components: UpdateComponents) -> Self {
255 Self {
256 version: default_a2ui_version(),
257 update_components,
258 }
259 }
260}
261
262impl UpdateDataModelMessage {
263 pub fn new(update_data_model: UpdateDataModel) -> Self {
264 Self {
265 version: default_a2ui_version(),
266 update_data_model,
267 }
268 }
269}
270
271impl DeleteSurfaceMessage {
272 pub fn new(delete_surface: DeleteSurface) -> Self {
273 Self {
274 version: default_a2ui_version(),
275 delete_surface,
276 }
277 }
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
284#[serde(rename_all = "camelCase")]
285pub struct A2uiClientAction {
286 pub name: String,
287 pub surface_id: String,
288 pub source_component_id: String,
289 pub timestamp: String,
290 pub context: Value,
291 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
293 pub want_response: bool,
294 #[serde(skip_serializing_if = "Option::is_none")]
296 pub action_id: Option<String>,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct A2uiClientActionMessage {
302 #[serde(default = "default_a2ui_version")]
303 pub version: String,
304 pub action: A2uiClientAction,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct A2uiValidationFailedError {
311 pub code: String,
312 pub surface_id: String,
313 pub path: String,
314 pub message: String,
315}
316
317impl A2uiValidationFailedError {
318 pub fn new(
319 surface_id: impl Into<String>,
320 path: impl Into<String>,
321 message: impl Into<String>,
322 ) -> Self {
323 Self {
324 code: "VALIDATION_FAILED".to_string(),
325 surface_id: surface_id.into(),
326 path: path.into(),
327 message: message.into(),
328 }
329 }
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct A2uiErrorMessage {
335 #[serde(default = "default_a2ui_version")]
336 pub version: String,
337 pub error: A2uiValidationFailedError,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
342#[serde(untagged)]
343pub enum A2uiClientMessage {
344 Action(A2uiClientActionMessage),
345 FunctionResponse(FunctionResponseMessage),
346 Error(A2uiErrorMessage),
347}
348
349pub fn apply_action_response_value(
351 data_model: &mut Value,
352 response_path: Option<&str>,
353 value: Value,
354) {
355 let path = response_path.unwrap_or("/__a2ui/lastActionResponse");
356 if path.is_empty() || path == "/" {
357 *data_model = value;
358 return;
359 }
360 let tokens: Vec<String> = path
361 .trim_start_matches('/')
362 .split('/')
363 .filter(|t| !t.is_empty())
364 .map(|t| t.replace("~1", "/").replace("~0", "~"))
365 .collect();
366 if tokens.is_empty() {
367 *data_model = value;
368 return;
369 }
370
371 let mut leaf = value;
373 for token in tokens.iter().rev() {
374 leaf = json!({ token: leaf });
375 }
376 merge_json(data_model, leaf);
377}
378
379fn merge_json(target: &mut Value, source: Value) {
380 match (target, source) {
381 (Value::Object(target_map), Value::Object(source_map)) => {
382 for (key, value) in source_map {
383 match target_map.get_mut(&key) {
384 Some(existing) => merge_json(existing, value),
385 None => {
386 target_map.insert(key, value);
387 }
388 }
389 }
390 }
391 (target, source) => {
392 *target = source;
393 }
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use serde_json::json;
401
402 #[test]
403 fn create_surface_includes_version_and_mime_constants() {
404 let msg = CreateSurfaceMessage::new(CreateSurface {
405 surface_id: "main".into(),
406 catalog_id: A2UI_BASIC_CATALOG_ID.into(),
407 theme: None,
408 send_data_model: Some(true),
409 components: None,
410 data_model: None,
411 });
412 let value = serde_json::to_value(&msg).unwrap();
413 assert_eq!(value["version"], "v0.9.1");
414 assert_eq!(value["createSurface"]["surfaceId"], "main");
415 assert_eq!(A2UI_MIME_TYPE, "application/a2ui+json");
416 }
417
418 #[test]
419 fn validation_failed_error_uses_standard_code() {
420 let err = A2uiValidationFailedError::new("s1", "/components/0/text", "bad type");
421 let value = serde_json::to_value(A2uiErrorMessage {
422 version: default_a2ui_version(),
423 error: err,
424 })
425 .unwrap();
426 assert_eq!(value["error"]["code"], "VALIDATION_FAILED");
427 assert_eq!(value["error"]["path"], "/components/0/text");
428 }
429
430 #[test]
431 fn client_action_round_trips() {
432 let msg = A2uiClientActionMessage {
433 version: default_a2ui_version(),
434 action: A2uiClientAction {
435 name: "submit".into(),
436 surface_id: "form".into(),
437 source_component_id: "btn".into(),
438 timestamp: "2026-08-09T00:00:00Z".into(),
439 context: json!({"ok": true}),
440 want_response: true,
441 action_id: Some("act-1".into()),
442 },
443 };
444 let raw = serde_json::to_string(&msg).unwrap();
445 let parsed: A2uiClientActionMessage = serde_json::from_str(&raw).unwrap();
446 assert_eq!(parsed.action.name, "submit");
447 assert_eq!(parsed.action.action_id.as_deref(), Some("act-1"));
448 assert!(parsed.action.want_response);
449 assert!(!raw.contains("responsePath"));
450 }
451
452 #[test]
453 fn action_response_success_serializes_v1() {
454 let msg = ActionResponseMessage::success("act-1", json!(["apple", "application"]));
455 let value = serde_json::to_value(&msg).unwrap();
456 assert_eq!(value["version"], "v1.0");
457 assert_eq!(value["actionId"], "act-1");
458 assert_eq!(value["actionResponse"]["value"][0], "apple");
459 }
460
461 #[test]
462 fn apply_action_response_writes_nested_path() {
463 let mut model = json!({"form": {"email": "a@b.c"}});
464 apply_action_response_value(
465 &mut model,
466 Some("/form/suggestions"),
467 json!(["alice", "alex"]),
468 );
469 assert_eq!(model["form"]["email"], "a@b.c");
470 assert_eq!(model["form"]["suggestions"][0], "alice");
471 }
472}