1use serde::{Deserialize, Serialize};
17
18use crate::error::{A2uiError, Result};
19
20#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
28pub struct ServerCapabilities {
29 #[serde(default, rename = "supportedCatalogIds")]
31 pub supported_catalog_ids: Vec<String>,
32 #[serde(default, rename = "acceptsInlineCatalogs")]
35 pub accepts_inline_catalogs: bool,
36}
37
38#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
40pub struct ServerCapabilitiesEnvelope {
41 #[serde(rename = "v1.0")]
43 pub v1_0: ServerCapabilities,
44}
45
46#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
50pub struct ClientCapabilities {
51 #[serde(default, rename = "supportedCatalogIds")]
53 pub supported_catalog_ids: Vec<String>,
54 #[serde(default, rename = "inlineCatalogs", skip_serializing_if = "Vec::is_empty")]
58 pub inline_catalogs: Vec<serde_json::Value>,
59}
60
61#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
63pub struct ClientCapabilitiesEnvelope {
64 #[serde(rename = "v1.0")]
66 pub v1_0: ClientCapabilities,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct FunctionSchema {
77 pub name: String,
79 pub return_type: String,
82 pub arg_names: Vec<String>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct InlineCatalog {
90 pub catalog_id: String,
92 pub component_names: Vec<String>,
94 pub functions: Vec<FunctionSchema>,
96}
97
98fn validate_name<'a>(name: &'a str, kind: &str) -> Result<&'a str> {
105 let mut chars = name.chars();
106 let first = chars.next().ok_or_else(|| {
107 A2uiError::Validation(format!("inline catalog {kind} name must not be empty"))
108 })?;
109 if !(first.is_ascii_alphabetic() || first == '_') {
110 return Err(A2uiError::Validation(format!(
111 "invalid inline catalog {kind} name '{name}': must start with a letter or underscore"
112 )));
113 }
114 if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
115 return Err(A2uiError::Validation(format!(
116 "invalid inline catalog {kind} name '{name}': may only contain letters, digits, or underscore"
117 )));
118 }
119 Ok(name)
120}
121
122pub fn parse_inline_catalog(json: &serde_json::Value) -> Result<InlineCatalog> {
134 let obj = json
135 .as_object()
136 .ok_or_else(|| A2uiError::Validation("inline catalog must be a JSON object".into()))?;
137
138 let catalog_id = obj
139 .get("catalogId")
140 .and_then(|v| v.as_str())
141 .ok_or_else(|| A2uiError::Validation("inline catalog missing 'catalogId'".into()))?
142 .to_string();
143 if catalog_id.is_empty() {
144 return Err(A2uiError::Validation(
145 "inline catalog 'catalogId' must not be empty".into(),
146 ));
147 }
148
149 let mut component_names = Vec::new();
151 if let Some(components) = obj.get("components").and_then(|v| v.as_object()) {
152 for key in components.keys() {
153 validate_name(key, "component")?;
154 component_names.push(key.clone());
155 }
156 }
157
158 let mut functions = Vec::new();
160 if let Some(funcs) = obj.get("functions").and_then(|v| v.as_object()) {
161 for (key, fval) in funcs {
162 validate_name(key, "function")?;
163 let fobj = fval.as_object().ok_or_else(|| {
164 A2uiError::Validation(format!(
165 "inline catalog function '{key}' must be an object"
166 ))
167 })?;
168 let return_type = fobj
169 .get("returnType")
170 .and_then(|v| v.as_str())
171 .ok_or_else(|| {
172 A2uiError::Validation(format!(
173 "inline catalog function '{key}' missing 'returnType'"
174 ))
175 })?
176 .to_string();
177
178 let mut arg_names = Vec::new();
183 let args_obj = fobj
184 .get("properties")
185 .and_then(|p| p.get("args"))
186 .or_else(|| fobj.get("args"))
187 .and_then(|v| v.as_object());
188 if let Some(args) = args_obj {
189 if let Some(props) = args.get("properties").and_then(|v| v.as_object()) {
190 for arg_key in props.keys() {
191 validate_name(arg_key, "function argument")?;
192 arg_names.push(arg_key.clone());
193 }
194 }
195 }
196
197 functions.push(FunctionSchema {
198 name: key.clone(),
199 return_type,
200 arg_names,
201 });
202 }
203 }
204
205 Ok(InlineCatalog {
206 catalog_id,
207 component_names,
208 functions,
209 })
210}
211
212#[derive(Debug, Clone, Default)]
222pub struct ClientCapabilitiesBuilder {
223 supported_catalog_ids: Vec<String>,
224 inline_catalogs: Vec<serde_json::Value>,
225}
226
227impl ClientCapabilitiesBuilder {
228 pub fn from_catalog_ids(ids: Vec<String>) -> Self {
230 Self {
231 supported_catalog_ids: ids,
232 inline_catalogs: Vec::new(),
233 }
234 }
235
236 pub fn with_inline_catalog(mut self, json: serde_json::Value) -> Result<Self> {
241 parse_inline_catalog(&json)?;
243 self.inline_catalogs.push(json);
244 Ok(self)
245 }
246
247 pub fn build(self) -> ClientCapabilities {
249 ClientCapabilities {
250 supported_catalog_ids: self.supported_catalog_ids,
251 inline_catalogs: self.inline_catalogs,
252 }
253 }
254}
255
256#[cfg(test)]
261mod tests {
262 use super::*;
263 use serde_json::json;
264
265 const MINIMAL_CATALOG_JSON: &str = r##"{
266 "$schema": "https://json-schema.org/draft/2020-12/schema",
267 "$id": "https://a2ui.org/specification/v1_0/catalogs/minimal/catalog.json",
268 "title": "A2UI Minimal Catalog",
269 "description": "A minimal A2UI catalog for testing renderers.",
270 "catalogId": "https://a2ui.org/specification/v1_0/catalogs/minimal/catalog.json",
271 "components": {
272 "Text": {
273 "type": "object",
274 "allOf": [
275 {"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ComponentCommon"},
276 {"$ref": "#/$defs/CatalogComponentCommon"},
277 {
278 "type": "object",
279 "properties": {
280 "component": {"const": "Text"},
281 "text": {
282 "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString"
283 },
284 "variant": {
285 "type": "string",
286 "enum": ["h1", "h2", "h3", "h4", "h5", "caption", "body"]
287 }
288 },
289 "required": ["component", "text"]
290 }
291 ],
292 "unevaluatedProperties": false
293 },
294 "Row": {
295 "type": "object",
296 "allOf": [
297 {"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ComponentCommon"},
298 {"$ref": "#/$defs/CatalogComponentCommon"},
299 {
300 "type": "object",
301 "properties": {
302 "component": {"const": "Row"},
303 "children": {
304 "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ChildList"
305 },
306 "justify": {
307 "type": "string",
308 "enum": [
309 "center",
310 "end",
311 "spaceAround",
312 "spaceBetween",
313 "spaceEvenly",
314 "start",
315 "stretch"
316 ]
317 },
318 "align": {
319 "type": "string",
320 "enum": ["start", "center", "end", "stretch"]
321 }
322 },
323 "required": ["component", "children"]
324 }
325 ],
326 "unevaluatedProperties": false
327 },
328 "Column": {
329 "type": "object",
330 "allOf": [
331 {"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ComponentCommon"},
332 {"$ref": "#/$defs/CatalogComponentCommon"},
333 {
334 "type": "object",
335 "properties": {
336 "component": {"const": "Column"},
337 "children": {
338 "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ChildList"
339 },
340 "justify": {
341 "type": "string",
342 "enum": [
343 "start",
344 "center",
345 "end",
346 "spaceBetween",
347 "spaceAround",
348 "spaceEvenly",
349 "stretch"
350 ]
351 },
352 "align": {
353 "type": "string",
354 "enum": ["center", "end", "start", "stretch"]
355 }
356 },
357 "required": ["component", "children"]
358 }
359 ],
360 "unevaluatedProperties": false
361 },
362 "Button": {
363 "type": "object",
364 "allOf": [
365 {"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ComponentCommon"},
366 {"$ref": "#/$defs/CatalogComponentCommon"},
367 {"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Checkable"},
368 {
369 "type": "object",
370 "properties": {
371 "component": {"const": "Button"},
372 "child": {
373 "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ComponentId"
374 },
375 "variant": {
376 "type": "string",
377 "enum": ["primary", "borderless"]
378 },
379 "action": {
380 "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Action"
381 }
382 },
383 "required": ["component", "child", "action"]
384 }
385 ],
386 "unevaluatedProperties": false
387 },
388 "TextField": {
389 "type": "object",
390 "allOf": [
391 {"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ComponentCommon"},
392 {"$ref": "#/$defs/CatalogComponentCommon"},
393 {"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Checkable"},
394 {
395 "type": "object",
396 "properties": {
397 "component": {"const": "TextField"},
398 "label": {
399 "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString"
400 },
401 "value": {
402 "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString"
403 },
404 "variant": {
405 "type": "string",
406 "enum": ["longText", "number", "shortText", "obscured"]
407 },
408 "validationRegexp": {"type": "string"}
409 },
410 "required": ["component", "label"]
411 }
412 ],
413 "unevaluatedProperties": false
414 }
415 },
416 "functions": {
417 "capitalize": {
418 "type": "object",
419 "description": "Converts an input string to a capitalized version.",
420 "returnType": "string",
421 "properties": {
422 "call": {"const": "capitalize"},
423 "args": {
424 "type": "object",
425 "properties": {
426 "value": {
427 "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString"
428 }
429 },
430 "required": ["value"],
431 "unevaluatedProperties": false
432 }
433 },
434 "required": ["call", "args"],
435 "unevaluatedProperties": false
436 }
437 },
438 "$defs": {
439 "CatalogComponentCommon": {
440 "type": "object",
441 "properties": {
442 "weight": {"type": "number"}
443 }
444 },
445 "surfaceProperties": {
446 "type": "object",
447 "properties": {},
448 "additionalProperties": true
449 },
450 "anyComponent": {
451 "oneOf": [
452 {"$ref": "#/components/Text"},
453 {"$ref": "#/components/Row"},
454 {"$ref": "#/components/Column"},
455 {"$ref": "#/components/Button"},
456 {"$ref": "#/components/TextField"}
457 ],
458 "discriminator": {"propertyName": "component"}
459 },
460 "anyFunction": {
461 "oneOf": [{"$ref": "#/functions/capitalize"}]
462 }
463 }
464}
465"##;
466
467 #[test]
468 fn parse_minimal_catalog() {
469 let json: serde_json::Value = serde_json::from_str(MINIMAL_CATALOG_JSON).unwrap();
470 let parsed = parse_inline_catalog(&json).expect("should parse minimal catalog");
471
472 assert_eq!(
473 parsed.catalog_id,
474 "https://a2ui.org/specification/v1_0/catalogs/minimal/catalog.json"
475 );
476 assert_eq!(parsed.component_names.len(), 5);
478 assert!(parsed.component_names.contains(&"Text".to_string()));
479 assert!(parsed.component_names.contains(&"Button".to_string()));
480
481 assert_eq!(parsed.functions.len(), 1);
483 let cap = &parsed.functions[0];
484 assert_eq!(cap.name, "capitalize");
485 assert_eq!(cap.return_type, "string");
486 assert_eq!(cap.arg_names, vec!["value".to_string()]);
487 }
488
489 #[test]
490 fn reject_bad_name() {
491 let bad = json!({
492 "catalogId": "test",
493 "components": {
494 "9BadName": {}
495 }
496 });
497 let err = parse_inline_catalog(&bad).unwrap_err();
498 assert!(
499 err.to_string().contains("invalid inline catalog component name"),
500 "unexpected error: {err}"
501 );
502
503 let bad_fn = json!({
505 "catalogId": "test",
506 "functions": {
507 "has-dash": {"returnType": "string"}
508 }
509 });
510 let err = parse_inline_catalog(&bad_fn).unwrap_err();
511 assert!(
512 err.to_string().contains("invalid inline catalog function name"),
513 "unexpected error: {err}"
514 );
515 }
516
517 #[test]
518 fn reject_missing_catalog_id() {
519 let bad = json!({"components": {}});
520 assert!(parse_inline_catalog(&bad).is_err());
521 }
522
523 #[test]
524 fn reject_missing_return_type() {
525 let bad = json!({
526 "catalogId": "test",
527 "functions": {
528 "noReturn": {}
529 }
530 });
531 let err = parse_inline_catalog(&bad).unwrap_err();
532 assert!(err.to_string().contains("missing 'returnType'"));
533 }
534
535 #[test]
536 fn builder_produces_supported_catalog_ids() {
537 let ids = vec![
538 "https://a2ui.org/specification/v1_0/catalogs/minimal/catalog.json".to_string(),
539 "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json".to_string(),
540 ];
541 let caps = ClientCapabilitiesBuilder::from_catalog_ids(ids.clone()).build();
542 assert_eq!(caps.supported_catalog_ids, ids);
543 assert!(caps.inline_catalogs.is_empty());
544 }
545
546 #[test]
547 fn builder_appends_inline_catalog() {
548 let inline = json!({
549 "catalogId": "https://example.com/inline.json",
550 "components": {"Greeting": {}},
551 "functions": {
552 "shout": {
553 "returnType": "string",
554 "args": {
555 "properties": {"value": {}}
556 }
557 }
558 }
559 });
560 let caps = ClientCapabilitiesBuilder::from_catalog_ids(vec!["minimal".to_string()])
561 .with_inline_catalog(inline.clone())
562 .expect("inline catalog should be valid")
563 .build();
564 assert_eq!(caps.inline_catalogs.len(), 1);
565 assert_eq!(caps.inline_catalogs[0], inline);
566 }
567
568 #[test]
569 fn builder_rejects_invalid_inline_catalog() {
570 let bad = json!({"components": {"Bad Name": {}}});
571 let res = ClientCapabilitiesBuilder::from_catalog_ids(vec![])
572 .with_inline_catalog(bad);
573 assert!(res.is_err());
574 }
575
576 #[test]
577 fn client_capabilities_serializes_camel_case() {
578 let caps = ClientCapabilities {
579 supported_catalog_ids: vec!["a".to_string()],
580 inline_catalogs: vec![],
581 };
582 let env = ClientCapabilitiesEnvelope { v1_0: caps };
583 let json = serde_json::to_value(&env).unwrap();
584 assert!(json["v1.0"]["supportedCatalogIds"].is_array());
585 }
586
587 #[test]
588 fn server_capabilities_serializes_camel_case() {
589 let caps = ServerCapabilities {
590 supported_catalog_ids: vec!["a".to_string()],
591 accepts_inline_catalogs: true,
592 };
593 let env = ServerCapabilitiesEnvelope { v1_0: caps };
594 let json = serde_json::to_value(&env).unwrap();
595 assert_eq!(json["v1.0"]["acceptsInlineCatalogs"], true);
596 assert!(json["v1.0"]["supportedCatalogIds"].is_array());
597 }
598
599 #[test]
600 fn server_capabilities_round_trip() {
601 let raw = json!({
602 "v1.0": {
603 "supportedCatalogIds": ["x", "y"],
604 "acceptsInlineCatalogs": true
605 }
606 });
607 let env: ServerCapabilitiesEnvelope =
608 serde_json::from_value(raw.clone()).expect("should deserialize");
609 assert!(env.v1_0.accepts_inline_catalogs);
610 assert_eq!(env.v1_0.supported_catalog_ids, vec!["x", "y"]);
611 }
612}