use serde::{Deserialize, Serialize};
use crate::error::{A2uiError, Result};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServerCapabilities {
#[serde(default, rename = "supportedCatalogIds")]
pub supported_catalog_ids: Vec<String>,
#[serde(default, rename = "acceptsInlineCatalogs")]
pub accepts_inline_catalogs: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServerCapabilitiesEnvelope {
#[serde(rename = "v1.0")]
pub v1_0: ServerCapabilities,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ClientCapabilities {
#[serde(default, rename = "supportedCatalogIds")]
pub supported_catalog_ids: Vec<String>,
#[serde(default, rename = "inlineCatalogs", skip_serializing_if = "Vec::is_empty")]
pub inline_catalogs: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ClientCapabilitiesEnvelope {
#[serde(rename = "v1.0")]
pub v1_0: ClientCapabilities,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FunctionSchema {
pub name: String,
pub return_type: String,
pub arg_names: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InlineCatalog {
pub catalog_id: String,
pub component_names: Vec<String>,
pub functions: Vec<FunctionSchema>,
}
fn validate_name<'a>(name: &'a str, kind: &str) -> Result<&'a str> {
let mut chars = name.chars();
let first = chars.next().ok_or_else(|| {
A2uiError::Validation(format!("inline catalog {kind} name must not be empty"))
})?;
if !(first.is_ascii_alphabetic() || first == '_') {
return Err(A2uiError::Validation(format!(
"invalid inline catalog {kind} name '{name}': must start with a letter or underscore"
)));
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(A2uiError::Validation(format!(
"invalid inline catalog {kind} name '{name}': may only contain letters, digits, or underscore"
)));
}
Ok(name)
}
pub fn parse_inline_catalog(json: &serde_json::Value) -> Result<InlineCatalog> {
let obj = json
.as_object()
.ok_or_else(|| A2uiError::Validation("inline catalog must be a JSON object".into()))?;
let catalog_id = obj
.get("catalogId")
.and_then(|v| v.as_str())
.ok_or_else(|| A2uiError::Validation("inline catalog missing 'catalogId'".into()))?
.to_string();
if catalog_id.is_empty() {
return Err(A2uiError::Validation(
"inline catalog 'catalogId' must not be empty".into(),
));
}
let mut component_names = Vec::new();
if let Some(components) = obj.get("components").and_then(|v| v.as_object()) {
for key in components.keys() {
validate_name(key, "component")?;
component_names.push(key.clone());
}
}
let mut functions = Vec::new();
if let Some(funcs) = obj.get("functions").and_then(|v| v.as_object()) {
for (key, fval) in funcs {
validate_name(key, "function")?;
let fobj = fval.as_object().ok_or_else(|| {
A2uiError::Validation(format!(
"inline catalog function '{key}' must be an object"
))
})?;
let return_type = fobj
.get("returnType")
.and_then(|v| v.as_str())
.ok_or_else(|| {
A2uiError::Validation(format!(
"inline catalog function '{key}' missing 'returnType'"
))
})?
.to_string();
let mut arg_names = Vec::new();
let args_obj = fobj
.get("properties")
.and_then(|p| p.get("args"))
.or_else(|| fobj.get("args"))
.and_then(|v| v.as_object());
if let Some(args) = args_obj {
if let Some(props) = args.get("properties").and_then(|v| v.as_object()) {
for arg_key in props.keys() {
validate_name(arg_key, "function argument")?;
arg_names.push(arg_key.clone());
}
}
}
functions.push(FunctionSchema {
name: key.clone(),
return_type,
arg_names,
});
}
}
Ok(InlineCatalog {
catalog_id,
component_names,
functions,
})
}
#[derive(Debug, Clone, Default)]
pub struct ClientCapabilitiesBuilder {
supported_catalog_ids: Vec<String>,
inline_catalogs: Vec<serde_json::Value>,
}
impl ClientCapabilitiesBuilder {
pub fn from_catalog_ids(ids: Vec<String>) -> Self {
Self {
supported_catalog_ids: ids,
inline_catalogs: Vec::new(),
}
}
pub fn with_inline_catalog(mut self, json: serde_json::Value) -> Result<Self> {
parse_inline_catalog(&json)?;
self.inline_catalogs.push(json);
Ok(self)
}
pub fn build(self) -> ClientCapabilities {
ClientCapabilities {
supported_catalog_ids: self.supported_catalog_ids,
inline_catalogs: self.inline_catalogs,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const MINIMAL_CATALOG_JSON: &str = r##"{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://a2ui.org/specification/v1_0/catalogs/minimal/catalog.json",
"title": "A2UI Minimal Catalog",
"description": "A minimal A2UI catalog for testing renderers.",
"catalogId": "https://a2ui.org/specification/v1_0/catalogs/minimal/catalog.json",
"components": {
"Text": {
"type": "object",
"allOf": [
{"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ComponentCommon"},
{"$ref": "#/$defs/CatalogComponentCommon"},
{
"type": "object",
"properties": {
"component": {"const": "Text"},
"text": {
"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString"
},
"variant": {
"type": "string",
"enum": ["h1", "h2", "h3", "h4", "h5", "caption", "body"]
}
},
"required": ["component", "text"]
}
],
"unevaluatedProperties": false
},
"Row": {
"type": "object",
"allOf": [
{"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ComponentCommon"},
{"$ref": "#/$defs/CatalogComponentCommon"},
{
"type": "object",
"properties": {
"component": {"const": "Row"},
"children": {
"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ChildList"
},
"justify": {
"type": "string",
"enum": [
"center",
"end",
"spaceAround",
"spaceBetween",
"spaceEvenly",
"start",
"stretch"
]
},
"align": {
"type": "string",
"enum": ["start", "center", "end", "stretch"]
}
},
"required": ["component", "children"]
}
],
"unevaluatedProperties": false
},
"Column": {
"type": "object",
"allOf": [
{"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ComponentCommon"},
{"$ref": "#/$defs/CatalogComponentCommon"},
{
"type": "object",
"properties": {
"component": {"const": "Column"},
"children": {
"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ChildList"
},
"justify": {
"type": "string",
"enum": [
"start",
"center",
"end",
"spaceBetween",
"spaceAround",
"spaceEvenly",
"stretch"
]
},
"align": {
"type": "string",
"enum": ["center", "end", "start", "stretch"]
}
},
"required": ["component", "children"]
}
],
"unevaluatedProperties": false
},
"Button": {
"type": "object",
"allOf": [
{"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ComponentCommon"},
{"$ref": "#/$defs/CatalogComponentCommon"},
{"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Checkable"},
{
"type": "object",
"properties": {
"component": {"const": "Button"},
"child": {
"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ComponentId"
},
"variant": {
"type": "string",
"enum": ["primary", "borderless"]
},
"action": {
"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Action"
}
},
"required": ["component", "child", "action"]
}
],
"unevaluatedProperties": false
},
"TextField": {
"type": "object",
"allOf": [
{"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ComponentCommon"},
{"$ref": "#/$defs/CatalogComponentCommon"},
{"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Checkable"},
{
"type": "object",
"properties": {
"component": {"const": "TextField"},
"label": {
"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString"
},
"value": {
"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString"
},
"variant": {
"type": "string",
"enum": ["longText", "number", "shortText", "obscured"]
},
"validationRegexp": {"type": "string"}
},
"required": ["component", "label"]
}
],
"unevaluatedProperties": false
}
},
"functions": {
"capitalize": {
"type": "object",
"description": "Converts an input string to a capitalized version.",
"returnType": "string",
"properties": {
"call": {"const": "capitalize"},
"args": {
"type": "object",
"properties": {
"value": {
"$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString"
}
},
"required": ["value"],
"unevaluatedProperties": false
}
},
"required": ["call", "args"],
"unevaluatedProperties": false
}
},
"$defs": {
"CatalogComponentCommon": {
"type": "object",
"properties": {
"weight": {"type": "number"}
}
},
"surfaceProperties": {
"type": "object",
"properties": {},
"additionalProperties": true
},
"anyComponent": {
"oneOf": [
{"$ref": "#/components/Text"},
{"$ref": "#/components/Row"},
{"$ref": "#/components/Column"},
{"$ref": "#/components/Button"},
{"$ref": "#/components/TextField"}
],
"discriminator": {"propertyName": "component"}
},
"anyFunction": {
"oneOf": [{"$ref": "#/functions/capitalize"}]
}
}
}
"##;
#[test]
fn parse_minimal_catalog() {
let json: serde_json::Value = serde_json::from_str(MINIMAL_CATALOG_JSON).unwrap();
let parsed = parse_inline_catalog(&json).expect("should parse minimal catalog");
assert_eq!(
parsed.catalog_id,
"https://a2ui.org/specification/v1_0/catalogs/minimal/catalog.json"
);
assert_eq!(parsed.component_names.len(), 5);
assert!(parsed.component_names.contains(&"Text".to_string()));
assert!(parsed.component_names.contains(&"Button".to_string()));
assert_eq!(parsed.functions.len(), 1);
let cap = &parsed.functions[0];
assert_eq!(cap.name, "capitalize");
assert_eq!(cap.return_type, "string");
assert_eq!(cap.arg_names, vec!["value".to_string()]);
}
#[test]
fn reject_bad_name() {
let bad = json!({
"catalogId": "test",
"components": {
"9BadName": {}
}
});
let err = parse_inline_catalog(&bad).unwrap_err();
assert!(
err.to_string().contains("invalid inline catalog component name"),
"unexpected error: {err}"
);
let bad_fn = json!({
"catalogId": "test",
"functions": {
"has-dash": {"returnType": "string"}
}
});
let err = parse_inline_catalog(&bad_fn).unwrap_err();
assert!(
err.to_string().contains("invalid inline catalog function name"),
"unexpected error: {err}"
);
}
#[test]
fn reject_missing_catalog_id() {
let bad = json!({"components": {}});
assert!(parse_inline_catalog(&bad).is_err());
}
#[test]
fn reject_missing_return_type() {
let bad = json!({
"catalogId": "test",
"functions": {
"noReturn": {}
}
});
let err = parse_inline_catalog(&bad).unwrap_err();
assert!(err.to_string().contains("missing 'returnType'"));
}
#[test]
fn builder_produces_supported_catalog_ids() {
let ids = vec![
"https://a2ui.org/specification/v1_0/catalogs/minimal/catalog.json".to_string(),
"https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json".to_string(),
];
let caps = ClientCapabilitiesBuilder::from_catalog_ids(ids.clone()).build();
assert_eq!(caps.supported_catalog_ids, ids);
assert!(caps.inline_catalogs.is_empty());
}
#[test]
fn builder_appends_inline_catalog() {
let inline = json!({
"catalogId": "https://example.com/inline.json",
"components": {"Greeting": {}},
"functions": {
"shout": {
"returnType": "string",
"args": {
"properties": {"value": {}}
}
}
}
});
let caps = ClientCapabilitiesBuilder::from_catalog_ids(vec!["minimal".to_string()])
.with_inline_catalog(inline.clone())
.expect("inline catalog should be valid")
.build();
assert_eq!(caps.inline_catalogs.len(), 1);
assert_eq!(caps.inline_catalogs[0], inline);
}
#[test]
fn builder_rejects_invalid_inline_catalog() {
let bad = json!({"components": {"Bad Name": {}}});
let res = ClientCapabilitiesBuilder::from_catalog_ids(vec![])
.with_inline_catalog(bad);
assert!(res.is_err());
}
#[test]
fn client_capabilities_serializes_camel_case() {
let caps = ClientCapabilities {
supported_catalog_ids: vec!["a".to_string()],
inline_catalogs: vec![],
};
let env = ClientCapabilitiesEnvelope { v1_0: caps };
let json = serde_json::to_value(&env).unwrap();
assert!(json["v1.0"]["supportedCatalogIds"].is_array());
}
#[test]
fn server_capabilities_serializes_camel_case() {
let caps = ServerCapabilities {
supported_catalog_ids: vec!["a".to_string()],
accepts_inline_catalogs: true,
};
let env = ServerCapabilitiesEnvelope { v1_0: caps };
let json = serde_json::to_value(&env).unwrap();
assert_eq!(json["v1.0"]["acceptsInlineCatalogs"], true);
assert!(json["v1.0"]["supportedCatalogIds"].is_array());
}
#[test]
fn server_capabilities_round_trip() {
let raw = json!({
"v1.0": {
"supportedCatalogIds": ["x", "y"],
"acceptsInlineCatalogs": true
}
});
let env: ServerCapabilitiesEnvelope =
serde_json::from_value(raw.clone()).expect("should deserialize");
assert!(env.v1_0.accepts_inline_catalogs);
assert_eq!(env.v1_0.supported_catalog_ids, vec!["x", "y"]);
}
}