Skip to main content

a2ui_base/
capabilities.rs

1//! Capabilities negotiation and inline-catalog parsing.
2//!
3//! This module implements the A2UI v1.0 capabilities handshake types and a
4//! lightweight parser for *inline catalogs* — catalog JSON embedded directly in
5//! a client's capabilities payload rather than fetched from a URL.
6//!
7//! Inline catalog functions are **schema-only**: they describe their argument
8//! and return shapes but have no native Rust implementation. They are registered
9//! into a [`Catalog`](crate::catalog::Catalog) via
10//! [`SchemaOnlyFunction`](crate::catalog::schema_only::SchemaOnlyFunction)
11//! so that the existing `handle_call_function` path can discover and reject
12//! execution attempts uniformly. Inline catalog *components* have no native
13//! renderer and are drawn at render time by the generic fallback renderer
14//! (GenericComponent).
15
16use serde::{Deserialize, Serialize};
17
18use crate::error::{A2uiError, Result};
19
20// ---------------------------------------------------------------------------
21// Capability envelopes — mirror the v1.0 spec JSON-Schema shapes exactly.
22// ---------------------------------------------------------------------------
23
24/// Server-side capabilities advertised by an A2UI agent/server.
25///
26/// Mirrors the `a2uiServerCapabilities.v1.0` object from the spec.
27#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
28pub struct ServerCapabilities {
29    /// The catalog IDs the server can generate/send. Not necessarily resolvable URIs.
30    #[serde(default, rename = "supportedCatalogIds")]
31    pub supported_catalog_ids: Vec<String>,
32    /// Whether the server can accept an `inlineCatalogs` array in the client's
33    /// capabilities. Defaults to `false` per the spec.
34    #[serde(default, rename = "acceptsInlineCatalogs")]
35    pub accepts_inline_catalogs: bool,
36}
37
38/// The full server capabilities payload, keyed under a `v1.0` protocol version.
39#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
40pub struct ServerCapabilitiesEnvelope {
41    /// The capabilities for protocol version 1.0.
42    #[serde(rename = "v1.0")]
43    pub v1_0: ServerCapabilities,
44}
45
46/// Client-side capabilities sent to the server during the handshake.
47///
48/// Mirrors the `a2uiClientCapabilities.v1.0` object from the spec.
49#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
50pub struct ClientCapabilities {
51    /// The URIs of each component/function catalog the client supports.
52    #[serde(default, rename = "supportedCatalogIds")]
53    pub supported_catalog_ids: Vec<String>,
54    /// Inline catalog definitions. Only meaningful if the server declared
55    /// `acceptsInlineCatalogs: true`. Stored as raw JSON values so the parser
56    /// can extract schema metadata without losing the original definition.
57    #[serde(default, rename = "inlineCatalogs", skip_serializing_if = "Vec::is_empty")]
58    pub inline_catalogs: Vec<serde_json::Value>,
59}
60
61/// The full client capabilities payload, keyed under a `v1.0` protocol version.
62#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
63pub struct ClientCapabilitiesEnvelope {
64    /// The capabilities for protocol version 1.0.
65    #[serde(rename = "v1.0")]
66    pub v1_0: ClientCapabilities,
67}
68
69// ---------------------------------------------------------------------------
70// Parsed inline catalog representation
71// ---------------------------------------------------------------------------
72
73/// The schema (argument shape + return type) of one inline-catalog function,
74/// extracted for registration as a [`SchemaOnlyFunction`].
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct FunctionSchema {
77    /// The function name as it appears in the catalog's `functions` map key.
78    pub name: String,
79    /// The declared return type (one of: string, number, boolean, array,
80    /// object, any, void).
81    pub return_type: String,
82    /// The names of declared arguments (keys of `args.properties`).
83    pub arg_names: Vec<String>,
84}
85
86/// A parsed inline catalog — enough metadata to register its functions and to
87/// know which component names should fall back to the generic renderer.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct InlineCatalog {
90    /// The catalog's unique identifier (`catalogId`).
91    pub catalog_id: String,
92    /// Names of components declared in the catalog (no native renderer).
93    pub component_names: Vec<String>,
94    /// Functions declared in the catalog (schema-only).
95    pub functions: Vec<FunctionSchema>,
96}
97
98// ---------------------------------------------------------------------------
99// Parser
100// ---------------------------------------------------------------------------
101
102/// Validate that `name` matches the UAX#31 approximation
103/// `^[A-Za-z_][A-Za-z0-9_]*$`. Returns the name on success.
104fn 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
122/// Parse an inline catalog from a raw JSON value.
123///
124/// Extracts:
125/// - `catalogId` (required, must be a non-empty string),
126/// - the keys of the `components` map (validated component names),
127/// - for each entry in the `functions` map: its name, `returnType` (required),
128///   and the keys of `args.properties` (argument names).
129///
130/// Every component and function name is validated against
131/// `^[A-Za-z_][A-Za-z0-9_]*$` and duplicate names within their respective maps
132/// are rejected.
133pub 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    // --- components ---
150    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    // --- functions ---
159    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            // Argument names live under `args.properties`. Per the spec's
179            // FunctionCallValidationSchema, `args` is itself nested under
180            // `properties` (alongside `call`), so we look there first and fall
181            // back to a top-level `args` for simpler inline definitions.
182            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// ---------------------------------------------------------------------------
213// Client capabilities builder
214// ---------------------------------------------------------------------------
215
216/// Builder for [`ClientCapabilities`].
217///
218/// Construct with [`ClientCapabilitiesBuilder::from_catalog_ids`] (the IDs the
219/// client natively supports), then optionally append validated inline catalogs
220/// with [`.with_inline_catalog`](Self::with_inline_catalog).
221#[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    /// Start a builder whose `supportedCatalogIds` is the given list.
229    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    /// Validate and append an inline catalog JSON definition.
237    ///
238    /// The catalog is parsed (and thus validated) eagerly so malformed inline
239    /// catalogs are rejected at build time, not at render time.
240    pub fn with_inline_catalog(mut self, json: serde_json::Value) -> Result<Self> {
241        // Validate by parsing; discard the result (we store the raw JSON).
242        parse_inline_catalog(&json)?;
243        self.inline_catalogs.push(json);
244        Ok(self)
245    }
246
247    /// Finalize into a [`ClientCapabilities`].
248    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// ---------------------------------------------------------------------------
257// Tests
258// ---------------------------------------------------------------------------
259
260#[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        // Text, Row, Column, Button, TextField
477        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        // capitalize
482        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        // Also test a function-name violation.
504        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}