From f7d496e87b4c2d60ab76bd5bf1d69e78e6db2974 Mon Sep 17 00:00:00 2001 From: Jay Gindin Date: Fri, 23 Jan 2026 13:41:48 -0500 Subject: [PATCH 1/4] Refactor FunctionCall to enable full validation Each function in the `functions` object fully specifies the function, including all validation requirements. Prior to this change, the `length` function (for example) could have specified an invalid type for the min or max (e.g., "five" instead of "5"), or a negative value for `min` or `max`. It is now possible to fully validate the parameters and return type of each function. A new set of tests has been created for all the existing functions. Third parties can add their own functions by following the steps in the a2ui_custom_functions.md document. --- .../v0_9/docs/a2ui_custom_functions.md | 134 ++++ specification/v0_9/json/common_types.json | 5 +- specification/v0_9/json/standard_catalog.json | 528 +++++++------ .../cases/function_catalog_validation.json | 702 ++++++++++++++++++ 4 files changed, 1159 insertions(+), 210 deletions(-) create mode 100644 specification/v0_9/docs/a2ui_custom_functions.md create mode 100644 specification/v0_9/test/cases/function_catalog_validation.json diff --git a/specification/v0_9/docs/a2ui_custom_functions.md b/specification/v0_9/docs/a2ui_custom_functions.md new file mode 100644 index 000000000..8c1711edb --- /dev/null +++ b/specification/v0_9/docs/a2ui_custom_functions.md @@ -0,0 +1,134 @@ +# Extending A2UI with Custom Functions + +A2UI functions are designed to be extensible. Third-party developers can define +their own +function catalogs while preserving strict validation for the standard set. + +This guide demonstrates how to create a `custom_catalog.json` that adds a string +`trim` function and a hardware query function (`getScreenResolution`). + +## 1. Define the Custom Catalog + +Create a JSON Schema file (e.g., `custom_catalog.json`) that defines your +function parameters. + +Use the `functions` property to define a map of function schemas, and a group in `$defs` to collect them. + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schemas/custom_catalog.json", + "title": "Custom Function Catalog", + "description": "Extension catalog adding string trimming and screen resolution functions.", + "functions": { + "trim": { + "type": "object", + "properties": { + "call": { "const": "trim" }, + "returnType": { "const": "string" }, + "args": { + "type": "array", + "minItems": 1, + "maxItems": 2, + "prefixItems": [ + { + "$ref": "https://a2ui.dev/specification/v0_9/common_types.json#/$defs/DynamicString", + "description": "The string to trim." + }, + { + "$ref": "https://a2ui.dev/specification/v0_9/common_types.json#/$defs/DynamicString", + "description": "Optional. A set of characters to remove. Defaults to whitespace." + } + ] + } + }, + "required": ["call", "args"] + }, + "getScreenResolution": { + "type": "object", + "properties": { + "call": { "const": "getScreenResolution" }, + "returnType": { "const": "array" }, + "args": { + "type": "array", + "minItems": 0, + "maxItems": 1, + "prefixItems": [ + { + "$ref": "https://a2ui.dev/specification/v0_9/common_types.json#/$defs/DynamicNumber", + "description": "Optional. The index of the screen to query. Defaults to 0 (primary screen)." + } + ] + } + }, + "required": ["call"] + } + }, + "$defs": { + "CustomFunctions": { + "oneOf": [ + { "$ref": "#/functions/trim" }, + { "$ref": "#/functions/getScreenResolution" } + ] + } + } +} +``` + +## 2. Combine with the Standard Schema + +To use this custom catalog in your application, you must define an "Application +Schema" that extends the base A2UI schema. You do this by overriding the +`FunctionCall` definition to include your new function definitions. + +Because `common_types.json` defines `FunctionCall` as a choice (`oneOf`), you +simply create a new list of choices that includes both the standard and custom +function groups. + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schemas/my_app_schema.json", + // Import definitions from the standard schema + "$defs": { + "FunctionCall": { + "description": "Invokes a standard OR custom function.", + "oneOf": [ + // 1. Allow all standard functions + { + "$ref": "https://a2ui.dev/specification/v0_9/standard_catalog.json#/$defs/StandardFunctions" + }, + // 2. Allow the custom functions + { + "$ref": "https://example.com/schemas/custom_catalog.json#/$defs/CustomFunctions" + } + + // 3. (Optional) Allow fallback for other functions if strict validation isn't desired + // { "$ref": "https://a2ui.dev/specification/v0_9/standard_catalog.json#/$defs/GenericFunction" } + ] + } + } + + // ... verify the rest of your message structure here ... +} +``` + +## How Validation Works + +When a `FunctionCall` is validated against this combined schema: + +1. **Discriminator Lookup:** The validator looks at the `call` property of the + object. +2. **Schema Matching:** + * If `call` is "length", it matches `StandardFunctions` -> `length` + and validates the arguments against the length rules. + * If `call` is "trim", it matches `CustomFunctions` -> `trim` and + validates against your custom rules. + * If `call` is "unknownFunc": + * If `GenericFunction` is NOT included, validation FAILS immediately ( + strict mode). + * If `GenericFunction` IS included, it matches the generic fallback and + PASSES (loose mode). + +This strict-by-default approach ensures typos are caught early, while the +modular structure makes it easy to add new capabilities. diff --git a/specification/v0_9/json/common_types.json b/specification/v0_9/json/common_types.json index 2a80dbdf0..5e5f0d42a 100644 --- a/specification/v0_9/json/common_types.json +++ b/specification/v0_9/json/common_types.json @@ -168,7 +168,10 @@ "default": "boolean" } }, - "required": ["call"] + "required": ["call"], + "oneOf": [ + { "$ref": "standard_catalog.json#/$defs/StandardFunctions" } + ] }, "LogicExpression": { "type": "object", diff --git a/specification/v0_9/json/standard_catalog.json b/specification/v0_9/json/standard_catalog.json index 77722f081..e6525d47a 100644 --- a/specification/v0_9/json/standard_catalog.json +++ b/specification/v0_9/json/standard_catalog.json @@ -648,241 +648,335 @@ "unevaluatedProperties": false } }, - "functions": [ - { - "name": "required", + "functions": { + "required": { + "type": "object", "description": "Checks that the value is not null, undefined, or empty.", - "returnType": "boolean", - "parameters": { - "type": "array", - "items": [{ "$ref": "common_types.json#/$defs/DynamicValue" }], - "minItems": 1 - } + "properties": { + "call": { "const": "required" }, + "returnType": { "const": "boolean" }, + "args": { + "type": "array", + "minItems": 1, + "prefixItems": [ + { "$ref": "common_types.json#/$defs/DynamicValue" } + ], + "items": false + } + }, + "required": ["call", "args"] }, - { - "name": "regex", + "regex": { + "type": "object", "description": "Checks that the value matches a regular expression string.", - "returnType": "boolean", - "parameters": { - "type": "array", - "items": [ - { "$ref": "common_types.json#/$defs/DynamicValue" }, - { - "type": "string", - "description": "The regex pattern to match against." - } - ], - "minItems": 2 - } + "properties": { + "call": { "const": "regex" }, + "returnType": { "const": "boolean" }, + "args": { + "type": "array", + "minItems": 2, + "prefixItems": [ + { "$ref": "common_types.json#/$defs/DynamicValue" }, + { + "type": "string", + "description": "The regex pattern to match against." + } + ], + "items": false + } + }, + "required": ["call", "args"] }, - { - "name": "length", + "length": { + "type": "object", "description": "Checks string length constraints.", - "returnType": "boolean", - "parameters": { - "type": "array", - "items": [ - { "$ref": "common_types.json#/$defs/DynamicValue" }, - { - "type": "object", - "properties": { - "min": { - "type": "integer", - "minimum": 0, - "description": "The minimum allowed length." + "properties": { + "call": { "const": "length" }, + "returnType": { "const": "boolean" }, + "args": { + "type": "array", + "minItems": 2, + "prefixItems": [ + { "$ref": "common_types.json#/$defs/DynamicValue" }, + { + "type": "object", + "properties": { + "min": { + "type": "integer", + "minimum": 0, + "description": "The minimum allowed length." + }, + "max": { + "type": "integer", + "minimum": 0, + "description": "The maximum allowed length." + } }, - "max": { - "type": "integer", - "minimum": 0, - "description": "The maximum allowed length." - } - }, - "anyOf": [{ "required": ["min"] }, { "required": ["max"] }] - } - ], - "minItems": 2 - } + "anyOf": [ { "required": [ "min" ] }, { "required": [ "max" ] } ] + } + ], + "items": false + } + }, + "required": ["call", "args"] }, - { - "name": "numeric", + "numeric": { + "type": "object", "description": "Checks numeric range constraints.", - "returnType": "boolean", - "parameters": { - "type": "array", - "items": [ - { "$ref": "common_types.json#/$defs/DynamicValue" }, - { - "type": "object", - "properties": { - "min": { - "type": "number", - "description": "The minimum allowed value." + "properties": { + "call": { "const": "numeric" }, + "returnType": { "const": "boolean" }, + "args": { + "type": "array", + "minItems": 2, + "prefixItems": [ + { "$ref": "common_types.json#/$defs/DynamicValue" }, + { + "type": "object", + "properties": { + "min": { + "type": "number", + "description": "The minimum allowed value." + }, + "max": { + "type": "number", + "description": "The maximum allowed value." + } }, - "max": { - "type": "number", - "description": "The maximum allowed value." - } - }, - "anyOf": [{ "required": ["min"] }, { "required": ["max"] }] - } - ], - "minItems": 2 - } + "anyOf": [ { "required": [ "min" ] }, { "required": [ "max" ] } ] + } + ], + "items": false + } + }, + "required": ["call", "args"] }, - { - "name": "email", + "email": { + "type": "object", "description": "Checks that the value is a valid email address.", - "returnType": "boolean", - "parameters": { - "type": "array", - "items": [{ "$ref": "common_types.json#/$defs/DynamicValue" }], - "minItems": 1 - } + "properties": { + "call": { "const": "email" }, + "returnType": { "const": "boolean" }, + "args": { + "type": "array", + "minItems": 1, + "prefixItems": [ + { "$ref": "common_types.json#/$defs/DynamicValue" } + ], + "items": false + } + }, + "required": ["call", "args"] }, - { - "name": "formatString", + "formatString": { + "type": "object", "description": "Performs string interpolation of data model values and other functions in the catalog functions list and returns the resulting string. The value string can contain interpolated expressions in the `${expression}` format. Supported expression types include: JSON Pointer paths to the data model (e.g., `${/absolute/path}` or `${relative/path}`), and client-side function calls (e.g., `${now()}`). Function arguments must be literals (quoted strings, numbers, booleans) or nested expressions (e.g., `${formatDate(${/currentDate}, 'MM-dd')}`). To include a literal `${` sequence, escape it as `\\${`.", - "returnType": "string", - "parameters": { - "type": "array", - "items": [{ "description": "The format string.", "type": "string" }], - "additionalItems": { "$ref": "common_types.json#/$defs/DynamicValue" }, - "minItems": 1 - } + "properties": { + "call": { "const": "formatString" }, + "returnType": { "const": "string" }, + "args": { + "type": "array", + "minItems": 1, + "prefixItems": [ + { + "description": "The format string.", + "type": "string" + } + ], + "items": { "$ref": "common_types.json#/$defs/DynamicValue" } + } + }, + "required": ["call", "args"] }, - { - "name": "formatNumber", + "formatNumber": { + "type": "object", "description": "Formats a number with the specified grouping and decimal precision.", - "returnType": "string", - "parameters": { - "type": "array", - "items": [ - { - "$ref": "common_types.json#/$defs/DynamicNumber", - "description": "The number to format." - }, - { - "$ref": "common_types.json#/$defs/DynamicNumber", - "description": "Optional. The number of decimal places to show. Defaults to 0 or 2 depending on locale." - }, - { - "$ref": "common_types.json#/$defs/DynamicBoolean", - "description": "Optional. If true, uses locale-specific grouping separators (e.g. '1,000'). If false, returns raw digits (e.g. '1000'). Defaults to true." - } - ], - "minItems": 1 - } + "properties": { + "call": { "const": "formatNumber" }, + "returnType": { "const": "string" }, + "args": { + "type": "array", + "minItems": 1, + "prefixItems": [ + { + "$ref": "common_types.json#/$defs/DynamicNumber", + "description": "The number to format." + }, + { + "$ref": "common_types.json#/$defs/DynamicNumber", + "description": "Optional. The number of decimal places to show. Defaults to 0 or 2 depending on locale." + }, + { + "$ref": "common_types.json#/$defs/DynamicBoolean", + "description": "Optional. If true, uses locale-specific grouping separators (e.g. '1,000'). If false, returns raw digits (e.g. '1000'). Defaults to true." + } + ], + "items": false + } + }, + "required": ["call", "args"] }, - { - "name": "formatCurrency", + "formatCurrency": { + "type": "object", "description": "Formats a number as a currency string.", - "returnType": "string", - "parameters": { - "type": "array", - "items": [ - { - "$ref": "common_types.json#/$defs/DynamicNumber", - "description": "The monetary amount." - }, - { - "$ref": "common_types.json#/$defs/DynamicString", - "description": "The ISO 4217 currency code (e.g., 'USD', 'EUR')." - } - ], - "minItems": 2 - } + "properties": { + "call": { "const": "formatCurrency" }, + "returnType": { "const": "string" }, + "args": { + "type": "array", + "minItems": 2, + "prefixItems": [ + { + "$ref": "common_types.json#/$defs/DynamicNumber", + "description": "The monetary amount." + }, + { + "$ref": "common_types.json#/$defs/DynamicString", + "description": "The ISO 4217 currency code (e.g., 'USD', 'EUR')." + } + ], + "items": false + } + }, + "required": ["call", "args"] }, - { - "name": "formatDate", + "formatDate": { + "type": "object", "description": "Formats a timestamp into a string using a pattern.", - "returnType": "string", - "parameters": { - "type": "array", - "items": [ - { - "$ref": "common_types.json#/$defs/DynamicValue", - "description": "The date to format." - }, - { - "$ref": "common_types.json#/$defs/DynamicString", - "description": "A Unicode TR35 date pattern string.\n\nToken Reference:\n- Year: 'yy' (26), 'yyyy' (2026)\n- Month: 'M' (1), 'MM' (01), 'MMM' (Jan), 'MMMM' (January)\n- Day: 'd' (1), 'dd' (01), 'E' (Tue), 'EEEE' (Tuesday)\n- Hour (12h): 'h' (1-12), 'hh' (01-12) - requires 'a' for AM/PM\n- Hour (24h): 'H' (0-23), 'HH' (00-23) - Military Time\n- Minute: 'mm' (00-59)\n- Second: 'ss' (00-59)\n- Period: 'a' (AM/PM)\n\nExamples:\n- 'MMM dd, yyyy' -> 'Jan 16, 2026'\n- 'HH:mm' -> '14:30' (Military)\n- 'h:mm a' -> '2:30 PM'\n- 'EEEE, d MMMM' -> 'Friday, 16 January'" - } - ], - "minItems": 2 - } + "properties": { + "call": { "const": "formatDate" }, + "returnType": { "const": "string" }, + "args": { + "type": "array", + "minItems": 2, + "prefixItems": [ + { + "$ref": "common_types.json#/$defs/DynamicValue", + "description": "The date to format." + }, + { + "$ref": "common_types.json#/$defs/DynamicString", + "description": "A Unicode TR35 date pattern string.\n\nToken Reference:\n- Year: 'yy' (26), 'yyyy' (2026)\n- Month: 'M' (1), 'MM' (01), 'MMM' (Jan), 'MMMM' (January)\n- Day: 'd' (1), 'dd' (01), 'E' (Tue), 'EEEE' (Tuesday)\n- Hour (12h): 'h' (1-12), 'hh' (01-12) - requires 'a' for AM/PM\n- Hour (24h): 'H' (0-23), 'HH' (00-23) - Military Time\n- Minute: 'mm' (00-59)\n- Second: 'ss' (00-59)\n- Period: 'a' (AM/PM)\n\nExamples:\n- 'MMM dd, yyyy' -> 'Jan 16, 2026'\n- 'HH:mm' -> '14:30' (Military)\n- 'h:mm a' -> '2:30 PM'\n- 'EEEE, d MMMM' -> 'Friday, 16 January'" + } + ], + "items": false + } + }, + "required": ["call", "args"] }, - { - "name": "pluralize", + "pluralize": { + "type": "object", "description": "Returns a localized string based on the Common Locale Data Repository (CLDR) plural category of the count (zero, one, two, few, many, other). Requires an 'other' fallback. For English, just use 'one' and 'other'.", - "returnType": "string", - "parameters": { - "type": "array", - "items": [ - { - "$ref": "common_types.json#/$defs/DynamicNumber", - "description": "The numeric value used to determine the plural category." - }, - { - "type": "object", - "description": "A map of CLDR plural categories to their corresponding strings.", - "properties": { - "zero": { - "$ref": "common_types.json#/$defs/DynamicString", - "description": "String for the 'zero' category (e.g., 0 items)." - }, - "one": { - "$ref": "common_types.json#/$defs/DynamicString", - "description": "String for the 'one' category (e.g., 1 item)." - }, - "two": { - "$ref": "common_types.json#/$defs/DynamicString", - "description": "String for the 'two' category (used in Arabic, Welsh, etc.)." - }, - "few": { - "$ref": "common_types.json#/$defs/DynamicString", - "description": "String for the 'few' category (e.g., small groups in Slavic languages)." - }, - "many": { - "$ref": "common_types.json#/$defs/DynamicString", - "description": "String for the 'many' category (e.g., large groups in various languages)." - }, - "other": { - "$ref": "common_types.json#/$defs/DynamicString", - "description": "The default/fallback string (used for general plural cases)." - } + "properties": { + "call": { "const": "pluralize" }, + "returnType": { "const": "string" }, + "args": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "prefixItems": [ + { + "$ref": "common_types.json#/$defs/DynamicNumber", + "description": "The numeric value used to determine the plural category." }, - "required": [ - "other" - ], - "additionalProperties": false - } - ], - "minItems": 2, - "maxItems": 2 - } + { + "type": "object", + "description": "A map of CLDR plural categories to their corresponding strings.", + "properties": { + "zero": { + "$ref": "common_types.json#/$defs/DynamicString", + "description": "String for the 'zero' category (e.g., 0 items)." + }, + "one": { + "$ref": "common_types.json#/$defs/DynamicString", + "description": "String for the 'one' category (e.g., 1 item)." + }, + "two": { + "$ref": "common_types.json#/$defs/DynamicString", + "description": "String for the 'two' category (used in Arabic, Welsh, etc.)." + }, + "few": { + "$ref": "common_types.json#/$defs/DynamicString", + "description": "String for the 'few' category (e.g., small groups in Slavic languages)." + }, + "many": { + "$ref": "common_types.json#/$defs/DynamicString", + "description": "String for the 'many' category (e.g., large groups in various languages)." + }, + "other": { + "$ref": "common_types.json#/$defs/DynamicString", + "description": "The default/fallback string (used for general plural cases)." + } + }, + "required": [ + "other" + ], + "additionalProperties": false + } + ] + } + }, + "required": ["call", "args"] }, - { - "name": "openUrl", + "openUrl": { + "type": "object", "description": "Opens the specified URL in a browser or handler. This function has no return value.", - "returnType": "void", - "parameters": { - "allOf": [ - { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri", - "description": "The URL to open." - } - }, - "required": ["url"] + "properties": { + "call": { "const": "openUrl" }, + "returnType": { "const": "void" }, + "args": { + "type": "array", + "items": false, + "prefixItems": [ + { + "allOf": [ + { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "The URL to open." + } + }, + "required": ["url"] + } + ], + "unevaluatedProperties": false + } + ], + "minItems": 1, + "maxItems": 1 + } + }, + "required": ["call", "args"] + }, + "generic": { + "type": "object", + "description": "Matches any function call not explicitly defined in the standard catalog, allowing for extensibility.", + "properties": { + "call": { + "type": "string", + "not": { + "enum": [ + "required", + "regex", + "length", + "numeric", + "email", + "formatString", + "formatNumber", + "formatCurrency", + "formatDate", + "pluralize", + "openUrl" + ] } - ], - "unevaluatedProperties": false - } + } + }, + "required": ["call"] } - ], + }, "theme": { "primaryColor": { "type": "string", @@ -933,6 +1027,22 @@ "discriminator": { "propertyName": "component" } + }, + "StandardFunctions": { + "oneOf": [ + { "$ref": "#/functions/required" }, + { "$ref": "#/functions/regex" }, + { "$ref": "#/functions/length" }, + { "$ref": "#/functions/numeric" }, + { "$ref": "#/functions/email" }, + { "$ref": "#/functions/formatString" }, + { "$ref": "#/functions/formatNumber" }, + { "$ref": "#/functions/formatCurrency" }, + { "$ref": "#/functions/formatDate" }, + { "$ref": "#/functions/pluralize" }, + { "$ref": "#/functions/openUrl" }, + { "$ref": "#/functions/generic" } + ] } } } diff --git a/specification/v0_9/test/cases/function_catalog_validation.json b/specification/v0_9/test/cases/function_catalog_validation.json new file mode 100644 index 000000000..87f318e55 --- /dev/null +++ b/specification/v0_9/test/cases/function_catalog_validation.json @@ -0,0 +1,702 @@ +{ + "schema": "server_to_client.json", + "tests": [ + { + "description": "required: Valid call", + "valid": true, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "tf1", + "component": "TextField", + "label": "L", + "value": "", + "checks": [ + { + "call": "required", + "args": [{ "path": "/val" }], + "returnType": "boolean", + "message": "Required" + } + ] + } + ] + } + } + }, + { + "description": "required: Invalid args (empty)", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "tf1", + "component": "TextField", + "label": "L", + "value": "", + "checks": [{ "call": "required", "args": [], "message": "Msg" }] + } + ] + } + } + }, + { + "description": "required: Invalid returnType", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "tf1", + "component": "TextField", + "label": "L", + "value": "", + "checks": [{ "call": "required", "args": [{"path": "/val"}], "returnType": "string", "message": "Msg" }] + } + ] + } + } + }, + { + "description": "regex: Valid call", + "valid": true, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "tf1", + "component": "TextField", + "label": "L", + "value": "", + "checks": [ + { + "call": "regex", + "args": [{ "path": "/val" }, "^abc$"], + "message": "Match" + } + ] + } + ] + } + } + }, + { + "description": "regex: Invalid args (missing pattern)", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "tf1", + "component": "TextField", + "label": "L", + "value": "", + "checks": [{ "call": "regex", "args": [{ "path": "/val" }], "message": "Msg" }] + } + ] + } + } + }, + { + "description": "length: Valid min", + "valid": true, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "tf1", + "component": "TextField", + "label": "L", + "value": "", + "checks": [{ "call": "length", "args": [{ "path": "/val" }, { "min": 5 }], "message": "Msg" }] + } + ] + } + } + }, + { + "description": "length: Valid max", + "valid": true, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "tf1", + "component": "TextField", + "label": "L", + "value": "", + "checks": [{ "call": "length", "args": [{ "path": "/val" }, { "max": 10 }], "message": "Msg" }] + } + ] + } + } + }, + { + "description": "length: Invalid constraint (empty object)", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "tf1", + "component": "TextField", + "label": "L", + "value": "", + "checks": [{ "call": "length", "args": [{ "path": "/val" }, {}], "message": "Msg" }] + } + ] + } + } + }, + { + "description": "numeric: Valid range", + "valid": true, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "tf1", + "component": "TextField", + "label": "L", + "value": "", + "checks": [{ "call": "numeric", "args": [{ "path": "/val" }, { "min": 0, "max": 100 }], "message": "Msg" }] + } + ] + } + } + }, + { + "description": "email: Valid call", + "valid": true, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "tf1", + "component": "TextField", + "label": "L", + "value": "", + "checks": [{ "call": "email", "args": [{ "path": "/val" }], "message": "Msg" }] + } + ] + } + } + }, + { + "description": "formatString: Valid call", + "valid": true, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "txt1", + "component": "Text", + "text": { + "call": "formatString", + "args": ["Hello ${name}", { "path": "/name" }], + "returnType": "string" + } + } + ] + } + } + }, + { + "description": "formatString: Invalid returnType", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "txt1", + "component": "Text", + "text": { + "call": "formatString", + "args": ["Hi"], + "returnType": "boolean" + } + } + ] + } + } + }, + { + "description": "formatNumber: Valid args", + "valid": true, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "txt1", + "component": "Text", + "text": { + "call": "formatNumber", + "args": [1234.567, 2, true], + "returnType": "string" + } + } + ] + } + } + }, + { + "description": "formatNumber: Invalid args (wrong type for precision)", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "txt1", + "component": "Text", + "text": { + "call": "formatNumber", + "args": [1234.567, "2"], + "returnType": "string" + } + } + ] + } + } + }, + { + "description": "formatCurrency: Valid args", + "valid": true, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "txt1", + "component": "Text", + "text": { + "call": "formatCurrency", + "args": [99.99, "USD"], + "returnType": "string" + } + } + ] + } + } + }, + { + "description": "formatCurrency: Missing currency code", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "txt1", + "component": "Text", + "text": { + "call": "formatCurrency", + "args": [99.99], + "returnType": "string" + } + } + ] + } + } + }, + { + "description": "formatDate: Valid args", + "valid": true, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "txt1", + "component": "Text", + "text": { + "call": "formatDate", + "args": ["2023-01-01", "MMM d"], + "returnType": "string" + } + } + ] + } + } + }, + { + "description": "pluralize: Valid args", + "valid": true, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "txt1", + "component": "Text", + "text": { + "call": "pluralize", + "args": [5, { "one": "item", "other": "items" }], + "returnType": "string" + } + } + ] + } + } + }, + { + "description": "pluralize: Invalid (missing 'other')", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "txt1", + "component": "Text", + "text": { + "call": "pluralize", + "args": [5, { "one": "item" }], + "returnType": "string" + } + } + ] + } + } + }, + { + "description": "openUrl: Valid call in Action", + "valid": true, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "btn1", + "component": "Button", + "child": "txt1", + "action": { + "functionCall": { + "call": "openUrl", + "args": [{ "url": "https://example.com" }], + "returnType": "void" + } + } + }, + { "id": "txt1", "component": "Text", "text": "Go" } + ] + } + } + }, + { + "description": "openUrl: Invalid args (string instead of object)", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "btn1", + "component": "Button", + "child": "txt1", + "action": { + "functionCall": { + "call": "openUrl", + "args": ["https://example.com"], + "returnType": "void" + } + } + }, + { "id": "txt1", "component": "Text", "text": "Go" } + ] + } + } + }, + { + "description": "openUrl: Invalid returnType", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "btn1", + "component": "Button", + "child": "txt1", + "action": { + "functionCall": { + "call": "openUrl", + "args": [{ "url": "https://example.com" }], + "returnType": "boolean" + } + } + }, + { "id": "txt1", "component": "Text", "text": "Go" } + ] + } + } + }, + { + "description": "Unknown function: Should be valid (extensibility)", + "valid": true, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "txt1", + "component": "Text", + "text": { + "call": "myCustomFunc", + "args": [1, 2, 3], + "returnType": "string" + } + } + ] + } + } + }, + { + "description": "length: Invalid min type (string)", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "tf1", + "component": "TextField", + "label": "L", + "value": "", + "checks": [{ "call": "length", "args": [{ "path": "/val" }, { "min": "5" }], "message": "Msg" }] + } + ] + } + } + }, + { + "description": "length: Invalid max value (negative)", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "tf1", + "component": "TextField", + "label": "L", + "value": "", + "checks": [{ "call": "length", "args": [{ "path": "/val" }, { "max": -2 }], "message": "Msg" }] + } + ] + } + } + }, + { + "description": "numeric: Invalid min type (string)", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "tf1", + "component": "TextField", + "label": "L", + "value": "", + "checks": [{ "call": "numeric", "args": [{ "path": "/val" }, { "min": "10" }], "message": "Msg" }] + } + ] + } + } + }, + { + "description": "numeric: Invalid max type (string)", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "tf1", + "component": "TextField", + "label": "L", + "value": "", + "checks": [{ "call": "numeric", "args": [{ "path": "/val" }, { "max": "20" }], "message": "Msg" }] + } + ] + } + } + }, + { + "description": "regex: Invalid pattern type (number)", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "tf1", + "component": "TextField", + "label": "L", + "value": "", + "checks": [{ "call": "regex", "args": [{ "path": "/val" }, 123], "message": "Msg" }] + } + ] + } + } + }, + { + "description": "email: Invalid args count (too many)", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "tf1", + "component": "TextField", + "label": "L", + "value": "", + "checks": [{ "call": "email", "args": [{ "path": "/val" }, "extra"], "message": "Msg" }] + } + ] + } + } + }, + { + "description": "formatString: Invalid format string type (number)", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "txt1", + "component": "Text", + "text": { + "call": "formatString", + "args": [123, { "path": "/val" }], + "returnType": "string" + } + } + ] + } + } + }, + { + "description": "formatNumber: Invalid precision type (boolean)", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "txt1", + "component": "Text", + "text": { + "call": "formatNumber", + "args": [123.456, true], + "returnType": "string" + } + } + ] + } + } + }, + { + "description": "formatCurrency: Invalid currency code type (number)", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "txt1", + "component": "Text", + "text": { + "call": "formatCurrency", + "args": [123.45, 840], + "returnType": "string" + } + } + ] + } + } + }, + { + "description": "formatDate: Invalid pattern type (null)", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "txt1", + "component": "Text", + "text": { + "call": "formatDate", + "args": ["2023-01-01", null], + "returnType": "string" + } + } + ] + } + } + }, + { + "description": "pluralize: Invalid dictionary (missing 'other')", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "txt1", + "component": "Text", + "text": { + "call": "pluralize", + "args": [5, { "one": "item" }], + "returnType": "string" + } + } + ] + } + } + }, + { + "description": "openUrl: Invalid URL format (not a URI)", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test", + "components": [ + { + "id": "btn1", + "component": "Button", + "child": "txt1", + "action": { + "functionCall": { + "call": "openUrl", + "args": [{ "url": "not a uri" }], + "returnType": "void" + } + } + }, + { "id": "txt1", "component": "Text", "text": "Go" } + ] + } + } + } + ] +} From 5d9bef162f2165370b71754dd537256e17deaab0 Mon Sep 17 00:00:00 2001 From: Jay Gindin Date: Tue, 27 Jan 2026 15:19:23 -0500 Subject: [PATCH 2/4] Address PR feedback. `StandardFunctions` --> `Functions` Remove duplicate test Modify docs --- .../v0_9/docs/a2ui_custom_functions.md | 29 +++++-------------- specification/v0_9/json/common_types.json | 2 +- specification/v0_9/json/standard_catalog.json | 5 ++-- .../cases/function_catalog_validation.json | 20 ------------- 4 files changed, 11 insertions(+), 45 deletions(-) diff --git a/specification/v0_9/docs/a2ui_custom_functions.md b/specification/v0_9/docs/a2ui_custom_functions.md index 8c1711edb..1a39ac592 100644 --- a/specification/v0_9/docs/a2ui_custom_functions.md +++ b/specification/v0_9/docs/a2ui_custom_functions.md @@ -63,22 +63,13 @@ Use the `functions` property to define a map of function schemas, and a group in }, "required": ["call"] } - }, - "$defs": { - "CustomFunctions": { - "oneOf": [ - { "$ref": "#/functions/trim" }, - { "$ref": "#/functions/getScreenResolution" } - ] - } } } ``` ## 2. Combine with the Standard Schema -To use this custom catalog in your application, you must define an "Application -Schema" that extends the base A2UI schema. You do this by overriding the +To use this custom catalog in your application, you must override the `FunctionCall` definition to include your new function definitions. Because `common_types.json` defines `FunctionCall` as a choice (`oneOf`), you @@ -87,8 +78,8 @@ function groups. ```json { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://example.com/schemas/my_app_schema.json", + // Add to the same file as created above... + // Import definitions from the standard schema "$defs": { "FunctionCall": { @@ -96,20 +87,14 @@ function groups. "oneOf": [ // 1. Allow all standard functions { - "$ref": "https://a2ui.dev/specification/v0_9/standard_catalog.json#/$defs/StandardFunctions" + "$ref": "https://a2ui.dev/specification/v0_9/standard_catalog.json#/$defs/Functions" }, // 2. Allow the custom functions - { - "$ref": "https://example.com/schemas/custom_catalog.json#/$defs/CustomFunctions" - } - - // 3. (Optional) Allow fallback for other functions if strict validation isn't desired - // { "$ref": "https://a2ui.dev/specification/v0_9/standard_catalog.json#/$defs/GenericFunction" } + { "$ref": "#/functions/trim" }, + { "$ref": "#/functions/getScreenResolution" } ] } } - - // ... verify the rest of your message structure here ... } ``` @@ -120,7 +105,7 @@ When a `FunctionCall` is validated against this combined schema: 1. **Discriminator Lookup:** The validator looks at the `call` property of the object. 2. **Schema Matching:** - * If `call` is "length", it matches `StandardFunctions` -> `length` + * If `call` is "length", it matches `Functions` -> `length` and validates the arguments against the length rules. * If `call` is "trim", it matches `CustomFunctions` -> `trim` and validates against your custom rules. diff --git a/specification/v0_9/json/common_types.json b/specification/v0_9/json/common_types.json index 5e5f0d42a..d604516d7 100644 --- a/specification/v0_9/json/common_types.json +++ b/specification/v0_9/json/common_types.json @@ -170,7 +170,7 @@ }, "required": ["call"], "oneOf": [ - { "$ref": "standard_catalog.json#/$defs/StandardFunctions" } + { "$ref": "standard_catalog.json#/$defs/Functions" } ] }, "LogicExpression": { diff --git a/specification/v0_9/json/standard_catalog.json b/specification/v0_9/json/standard_catalog.json index e6525d47a..c43cdd423 100644 --- a/specification/v0_9/json/standard_catalog.json +++ b/specification/v0_9/json/standard_catalog.json @@ -913,7 +913,8 @@ ], "additionalProperties": false } - ] + ], + "items": false } }, "required": ["call", "args"] @@ -1028,7 +1029,7 @@ "propertyName": "component" } }, - "StandardFunctions": { + "Functions": { "oneOf": [ { "$ref": "#/functions/required" }, { "$ref": "#/functions/regex" }, diff --git a/specification/v0_9/test/cases/function_catalog_validation.json b/specification/v0_9/test/cases/function_catalog_validation.json index 87f318e55..ad319115d 100644 --- a/specification/v0_9/test/cases/function_catalog_validation.json +++ b/specification/v0_9/test/cases/function_catalog_validation.json @@ -654,26 +654,6 @@ } } }, - { - "description": "pluralize: Invalid dictionary (missing 'other')", - "valid": false, - "data": { - "updateComponents": { - "surfaceId": "test", - "components": [ - { - "id": "txt1", - "component": "Text", - "text": { - "call": "pluralize", - "args": [5, { "one": "item" }], - "returnType": "string" - } - } - ] - } - } - }, { "description": "openUrl: Invalid URL format (not a URI)", "valid": false, From 4836a0645800499f7e66e29629f2859fa94e231b Mon Sep 17 00:00:00 2001 From: Jay Gindin Date: Wed, 28 Jan 2026 17:46:24 -0500 Subject: [PATCH 3/4] Rename Functions --> anyFunction for consistency with components --- specification/v0_9/docs/a2ui_custom_functions.md | 2 +- specification/v0_9/json/common_types.json | 2 +- specification/v0_9/json/standard_catalog.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/specification/v0_9/docs/a2ui_custom_functions.md b/specification/v0_9/docs/a2ui_custom_functions.md index 1a39ac592..a341406bc 100644 --- a/specification/v0_9/docs/a2ui_custom_functions.md +++ b/specification/v0_9/docs/a2ui_custom_functions.md @@ -87,7 +87,7 @@ function groups. "oneOf": [ // 1. Allow all standard functions { - "$ref": "https://a2ui.dev/specification/v0_9/standard_catalog.json#/$defs/Functions" + "$ref": "https://a2ui.dev/specification/v0_9/standard_catalog.json#/$defs/anyFunction" }, // 2. Allow the custom functions { "$ref": "#/functions/trim" }, diff --git a/specification/v0_9/json/common_types.json b/specification/v0_9/json/common_types.json index d604516d7..d7fa3e594 100644 --- a/specification/v0_9/json/common_types.json +++ b/specification/v0_9/json/common_types.json @@ -170,7 +170,7 @@ }, "required": ["call"], "oneOf": [ - { "$ref": "standard_catalog.json#/$defs/Functions" } + { "$ref": "standard_catalog.json#/$defs/anyFunction" } ] }, "LogicExpression": { diff --git a/specification/v0_9/json/standard_catalog.json b/specification/v0_9/json/standard_catalog.json index c43cdd423..345627884 100644 --- a/specification/v0_9/json/standard_catalog.json +++ b/specification/v0_9/json/standard_catalog.json @@ -1029,7 +1029,7 @@ "propertyName": "component" } }, - "Functions": { + "anyFunction": { "oneOf": [ { "$ref": "#/functions/required" }, { "$ref": "#/functions/regex" }, From 23d24d5a61715af6022ff2030e0fb97a764a5e9a Mon Sep 17 00:00:00 2001 From: Jay Gindin Date: Thu, 29 Jan 2026 10:54:19 -0500 Subject: [PATCH 4/4] Use the "abstract" catalog.json. Vastly simplifies how new functions are added as valid for the `FunctionCall`, removing the need to copy/paste `FunctionCall` in the user's schema. --- .../v0_9/docs/a2ui_custom_functions.md | 44 ++++++++++--------- specification/v0_9/json/common_types.json | 2 +- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/specification/v0_9/docs/a2ui_custom_functions.md b/specification/v0_9/docs/a2ui_custom_functions.md index a341406bc..fe395fdb6 100644 --- a/specification/v0_9/docs/a2ui_custom_functions.md +++ b/specification/v0_9/docs/a2ui_custom_functions.md @@ -12,7 +12,7 @@ This guide demonstrates how to create a `custom_catalog.json` that adds a string Create a JSON Schema file (e.g., `custom_catalog.json`) that defines your function parameters. -Use the `functions` property to define a map of function schemas, and a group in `$defs` to collect them. +Use the `functions` property to define a map of function schemas. ```json { @@ -67,31 +67,33 @@ Use the `functions` property to define a map of function schemas, and a group in } ``` -## 2. Combine with the Standard Schema +## 2. Make the functions available -To use this custom catalog in your application, you must override the -`FunctionCall` definition to include your new function definitions. - -Because `common_types.json` defines `FunctionCall` as a choice (`oneOf`), you -simply create a new list of choices that includes both the standard and custom -function groups. +The `FunctionCall` definition refers to a [catalog-agnostic reference](a2ui_protocol.md#the-standard-catalog). +In your catalog, you simply need to define the `anyFunctions` reference: +```json +{ + "$defs": { + "anyFunction": { + "oneOf": [ + {"$ref": "#/functions/trim"}, + {"$ref": "#/functions/getScreenResolution"} + ] + } + } +} +``` +If you want to incorporate functions defined in the [`standard_catalog.json`], +those can be added too: ```json { - // Add to the same file as created above... - - // Import definitions from the standard schema "$defs": { - "FunctionCall": { - "description": "Invokes a standard OR custom function.", + "anyFunction": { "oneOf": [ - // 1. Allow all standard functions - { - "$ref": "https://a2ui.dev/specification/v0_9/standard_catalog.json#/$defs/anyFunction" - }, - // 2. Allow the custom functions - { "$ref": "#/functions/trim" }, - { "$ref": "#/functions/getScreenResolution" } + {"$ref": "#/functions/trim"}, + {"$ref": "#/functions/getScreenResolution"}, + {"$ref": "catalog.json#/$defs/anyFunction" } ] } } @@ -100,7 +102,7 @@ function groups. ## How Validation Works -When a `FunctionCall` is validated against this combined schema: +When a `FunctionCall` is validated: 1. **Discriminator Lookup:** The validator looks at the `call` property of the object. diff --git a/specification/v0_9/json/common_types.json b/specification/v0_9/json/common_types.json index d7fa3e594..c2cd37713 100644 --- a/specification/v0_9/json/common_types.json +++ b/specification/v0_9/json/common_types.json @@ -170,7 +170,7 @@ }, "required": ["call"], "oneOf": [ - { "$ref": "standard_catalog.json#/$defs/anyFunction" } + { "$ref": "catalog.json#/$defs/anyFunction" } ] }, "LogicExpression": {