feat(macos): add Canvas A2UI renderer
This commit is contained in:
521
vendor/a2ui/specification/0.8/eval/src/validator.ts
vendored
Normal file
521
vendor/a2ui/specification/0.8/eval/src/validator.ts
vendored
Normal file
@@ -0,0 +1,521 @@
|
||||
/*
|
||||
Copyright 2025 Google LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { SurfaceUpdateSchemaMatcher } from "./surface_update_schema_matcher";
|
||||
import { SchemaMatcher } from "./schema_matcher";
|
||||
|
||||
export function validateSchema(
|
||||
data: any,
|
||||
schemaName: string,
|
||||
matchers?: SchemaMatcher[],
|
||||
): string[] {
|
||||
const errors: string[] = [];
|
||||
if (data.surfaceUpdate) {
|
||||
validateSurfaceUpdate(data.surfaceUpdate, errors);
|
||||
} else if (data.dataModelUpdate) {
|
||||
validateDataModelUpdate(data.dataModelUpdate, errors);
|
||||
} else if (data.beginRendering) {
|
||||
validateBeginRendering(data.beginRendering, errors);
|
||||
} else if (data.deleteSurface) {
|
||||
validateDeleteSurface(data.deleteSurface, errors);
|
||||
} else {
|
||||
errors.push(
|
||||
"A2UI Protocol message must have one of: surfaceUpdate, dataModelUpdate, beginRendering, deleteSurface.",
|
||||
);
|
||||
}
|
||||
|
||||
if (matchers) {
|
||||
for (const matcher of matchers) {
|
||||
const result = matcher.validate(data);
|
||||
if (!result.success) {
|
||||
errors.push(result.error!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateDeleteSurface(data: any, errors: string[]) {
|
||||
if (data.surfaceId === undefined) {
|
||||
errors.push("DeleteSurface must have a 'surfaceId' property.");
|
||||
}
|
||||
const allowed = ["surfaceId"];
|
||||
for (const key in data) {
|
||||
if (!allowed.includes(key)) {
|
||||
errors.push(`DeleteSurface has unexpected property: ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateSurfaceUpdate(data: any, errors: string[]) {
|
||||
if (data.surfaceId === undefined) {
|
||||
errors.push("SurfaceUpdate must have a 'surfaceId' property.");
|
||||
}
|
||||
if (!data.components || !Array.isArray(data.components)) {
|
||||
errors.push("SurfaceUpdate must have a 'components' array.");
|
||||
return;
|
||||
}
|
||||
|
||||
const componentIds = new Set<string>();
|
||||
for (const c of data.components) {
|
||||
if (c.id) {
|
||||
if (componentIds.has(c.id)) {
|
||||
errors.push(`Duplicate component ID found: ${c.id}`);
|
||||
}
|
||||
componentIds.add(c.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const component of data.components) {
|
||||
validateComponent(component, componentIds, errors);
|
||||
}
|
||||
}
|
||||
|
||||
function validateDataModelUpdate(data: any, errors: string[]) {
|
||||
if (data.surfaceId === undefined) {
|
||||
errors.push("DataModelUpdate must have a 'surfaceId' property.");
|
||||
}
|
||||
|
||||
const allowedTopLevel = ["surfaceId", "path", "contents"];
|
||||
for (const key in data) {
|
||||
if (!allowedTopLevel.includes(key)) {
|
||||
errors.push(`DataModelUpdate has unexpected property: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.contents)) {
|
||||
errors.push("DataModelUpdate must have a 'contents' array.");
|
||||
return;
|
||||
}
|
||||
|
||||
const validateValueProperty = (
|
||||
item: any,
|
||||
itemErrors: string[],
|
||||
prefix: string,
|
||||
) => {
|
||||
const valueProps = [
|
||||
"valueString",
|
||||
"valueNumber",
|
||||
"valueBoolean",
|
||||
"valueMap",
|
||||
];
|
||||
let valueCount = 0;
|
||||
let foundValueProp = "";
|
||||
for (const prop of valueProps) {
|
||||
if (item[prop] !== undefined) {
|
||||
valueCount++;
|
||||
foundValueProp = prop;
|
||||
}
|
||||
}
|
||||
if (valueCount !== 1) {
|
||||
itemErrors.push(
|
||||
`${prefix} must have exactly one value property (${valueProps.join(", ")}), found ${valueCount}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (foundValueProp === "valueMap") {
|
||||
if (!Array.isArray(item.valueMap)) {
|
||||
itemErrors.push(`${prefix} 'valueMap' must be an array.`);
|
||||
return;
|
||||
}
|
||||
item.valueMap.forEach((mapItem: any, index: number) => {
|
||||
if (!mapItem.key) {
|
||||
itemErrors.push(
|
||||
`${prefix} 'valueMap' item at index ${index} is missing a 'key'.`,
|
||||
);
|
||||
}
|
||||
const mapValueProps = ["valueString", "valueNumber", "valueBoolean"];
|
||||
let mapValueCount = 0;
|
||||
for (const prop of mapValueProps) {
|
||||
if (mapItem[prop] !== undefined) {
|
||||
mapValueCount++;
|
||||
}
|
||||
}
|
||||
if (mapValueCount !== 1) {
|
||||
itemErrors.push(
|
||||
`${prefix} 'valueMap' item at index ${index} must have exactly one value property (${mapValueProps.join(", ")}), found ${mapValueCount}.`,
|
||||
);
|
||||
}
|
||||
const allowedMapKeys = ["key", ...mapValueProps];
|
||||
for (const key in mapItem) {
|
||||
if (!allowedMapKeys.includes(key)) {
|
||||
itemErrors.push(
|
||||
`${prefix} 'valueMap' item at index ${index} has unexpected property: ${key}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
data.contents.forEach((item: any, index: number) => {
|
||||
if (!item.key) {
|
||||
errors.push(
|
||||
`DataModelUpdate 'contents' item at index ${index} is missing a 'key'.`,
|
||||
);
|
||||
}
|
||||
validateValueProperty(
|
||||
item,
|
||||
errors,
|
||||
`DataModelUpdate 'contents' item at index ${index}`,
|
||||
);
|
||||
const allowedKeys = [
|
||||
"key",
|
||||
"valueString",
|
||||
"valueNumber",
|
||||
"valueBoolean",
|
||||
"valueMap",
|
||||
];
|
||||
for (const key in item) {
|
||||
if (!allowedKeys.includes(key)) {
|
||||
errors.push(
|
||||
`DataModelUpdate 'contents' item at index ${index} has unexpected property: ${key}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateBeginRendering(data: any, errors: string[]) {
|
||||
if (data.surfaceId === undefined) {
|
||||
errors.push("BeginRendering message must have a 'surfaceId' property.");
|
||||
}
|
||||
if (!data.root) {
|
||||
errors.push("BeginRendering message must have a 'root' property.");
|
||||
}
|
||||
}
|
||||
|
||||
function validateBoundValue(
|
||||
prop: any,
|
||||
propName: string,
|
||||
componentId: string,
|
||||
componentType: string,
|
||||
errors: string[],
|
||||
) {
|
||||
if (typeof prop !== "object" || prop === null || Array.isArray(prop)) {
|
||||
errors.push(
|
||||
`Component '${componentId}' of type '${componentType}' property '${propName}' must be an object.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const keys = Object.keys(prop);
|
||||
const allowedKeys = [
|
||||
"literalString",
|
||||
"literalNumber",
|
||||
"literalBoolean",
|
||||
"path",
|
||||
];
|
||||
let validKeyCount = 0;
|
||||
for (const key of keys) {
|
||||
if (allowedKeys.includes(key)) {
|
||||
validKeyCount++;
|
||||
}
|
||||
}
|
||||
if (validKeyCount !== 1 || keys.length !== 1) {
|
||||
errors.push(
|
||||
`Component '${componentId}' of type '${componentType}' property '${propName}' must have exactly one key from [${allowedKeys.join(", ")}]. Found: ${keys.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateComponent(
|
||||
component: any,
|
||||
allIds: Set<string>,
|
||||
errors: string[],
|
||||
) {
|
||||
if (!component.id) {
|
||||
errors.push(`Component is missing an 'id'.`);
|
||||
return;
|
||||
}
|
||||
if (!component.component) {
|
||||
errors.push(`Component '${component.id}' is missing 'component'.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const componentTypes = Object.keys(component.component);
|
||||
if (componentTypes.length !== 1) {
|
||||
errors.push(
|
||||
`Component '${component.id}' must have exactly one property in 'component', but found ${componentTypes.length}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const componentType = componentTypes[0];
|
||||
const properties = component.component[componentType];
|
||||
|
||||
const checkRequired = (props: string[]) => {
|
||||
for (const prop of props) {
|
||||
if (properties[prop] === undefined) {
|
||||
errors.push(
|
||||
`Component '${component.id}' of type '${componentType}' is missing required property '${prop}'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const checkRefs = (ids: (string | undefined)[]) => {
|
||||
for (const id of ids) {
|
||||
if (id && !allIds.has(id)) {
|
||||
errors.push(
|
||||
`Component '${component.id}' references non-existent component ID '${id}'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
switch (componentType) {
|
||||
case "Heading":
|
||||
checkRequired(["text"]);
|
||||
if (properties.text)
|
||||
validateBoundValue(
|
||||
properties.text,
|
||||
"text",
|
||||
component.id,
|
||||
componentType,
|
||||
errors,
|
||||
);
|
||||
break;
|
||||
case "Text":
|
||||
checkRequired(["text"]);
|
||||
if (properties.text)
|
||||
validateBoundValue(
|
||||
properties.text,
|
||||
"text",
|
||||
component.id,
|
||||
componentType,
|
||||
errors,
|
||||
);
|
||||
break;
|
||||
case "Image":
|
||||
checkRequired(["url"]);
|
||||
if (properties.url)
|
||||
validateBoundValue(
|
||||
properties.url,
|
||||
"url",
|
||||
component.id,
|
||||
componentType,
|
||||
errors,
|
||||
);
|
||||
break;
|
||||
case "Video":
|
||||
checkRequired(["url"]);
|
||||
if (properties.url)
|
||||
validateBoundValue(
|
||||
properties.url,
|
||||
"url",
|
||||
component.id,
|
||||
componentType,
|
||||
errors,
|
||||
);
|
||||
break;
|
||||
case "AudioPlayer":
|
||||
checkRequired(["url"]);
|
||||
if (properties.url)
|
||||
validateBoundValue(
|
||||
properties.url,
|
||||
"url",
|
||||
component.id,
|
||||
componentType,
|
||||
errors,
|
||||
);
|
||||
if (properties.description)
|
||||
validateBoundValue(
|
||||
properties.description,
|
||||
"description",
|
||||
component.id,
|
||||
componentType,
|
||||
errors,
|
||||
);
|
||||
break;
|
||||
case "TextField":
|
||||
checkRequired(["label"]);
|
||||
if (properties.label)
|
||||
validateBoundValue(
|
||||
properties.label,
|
||||
"label",
|
||||
component.id,
|
||||
componentType,
|
||||
errors,
|
||||
);
|
||||
if (properties.text)
|
||||
validateBoundValue(
|
||||
properties.text,
|
||||
"text",
|
||||
component.id,
|
||||
componentType,
|
||||
errors,
|
||||
);
|
||||
break;
|
||||
case "DateTimeInput":
|
||||
checkRequired(["value"]);
|
||||
if (properties.value)
|
||||
validateBoundValue(
|
||||
properties.value,
|
||||
"value",
|
||||
component.id,
|
||||
componentType,
|
||||
errors,
|
||||
);
|
||||
break;
|
||||
case "MultipleChoice":
|
||||
checkRequired(["selections", "options"]);
|
||||
if (properties.selections) {
|
||||
if (
|
||||
typeof properties.selections !== "object" ||
|
||||
properties.selections === null ||
|
||||
(!properties.selections.literalArray && !properties.selections.path)
|
||||
) {
|
||||
errors.push(
|
||||
`Component '${component.id}' of type '${componentType}' property 'selections' must have either 'literalArray' or 'path'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(properties.options)) {
|
||||
properties.options.forEach((option: any, index: number) => {
|
||||
if (!option.label)
|
||||
errors.push(
|
||||
`Component '${component.id}' option at index ${index} missing 'label'.`,
|
||||
);
|
||||
if (option.label)
|
||||
validateBoundValue(
|
||||
option.label,
|
||||
"label",
|
||||
component.id,
|
||||
componentType,
|
||||
errors,
|
||||
);
|
||||
if (!option.value)
|
||||
errors.push(
|
||||
`Component '${component.id}' option at index ${index} missing 'value'.`,
|
||||
);
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "Slider":
|
||||
checkRequired(["value"]);
|
||||
if (properties.value)
|
||||
validateBoundValue(
|
||||
properties.value,
|
||||
"value",
|
||||
component.id,
|
||||
componentType,
|
||||
errors,
|
||||
);
|
||||
break;
|
||||
case "CheckBox":
|
||||
checkRequired(["value", "label"]);
|
||||
if (properties.value)
|
||||
validateBoundValue(
|
||||
properties.value,
|
||||
"value",
|
||||
component.id,
|
||||
componentType,
|
||||
errors,
|
||||
);
|
||||
if (properties.label)
|
||||
validateBoundValue(
|
||||
properties.label,
|
||||
"label",
|
||||
component.id,
|
||||
componentType,
|
||||
errors,
|
||||
);
|
||||
break;
|
||||
case "Row":
|
||||
case "Column":
|
||||
case "List":
|
||||
checkRequired(["children"]);
|
||||
if (properties.children && Array.isArray(properties.children)) {
|
||||
const hasExplicit = !!properties.children.explicitList;
|
||||
const hasTemplate = !!properties.children.template;
|
||||
if ((hasExplicit && hasTemplate) || (!hasExplicit && !hasTemplate)) {
|
||||
errors.push(
|
||||
`Component '${component.id}' must have either 'explicitList' or 'template' in children, but not both or neither.`,
|
||||
);
|
||||
}
|
||||
if (hasExplicit) {
|
||||
checkRefs(properties.children.explicitList);
|
||||
}
|
||||
if (hasTemplate) {
|
||||
checkRefs([properties.children.template?.componentId]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "Card":
|
||||
checkRequired(["child"]);
|
||||
checkRefs([properties.child]);
|
||||
break;
|
||||
case "Tabs":
|
||||
checkRequired(["tabItems"]);
|
||||
if (properties.tabItems && Array.isArray(properties.tabItems)) {
|
||||
properties.tabItems.forEach((tab: any) => {
|
||||
if (!tab.title) {
|
||||
errors.push(
|
||||
`Tab item in component '${component.id}' is missing a 'title'.`,
|
||||
);
|
||||
}
|
||||
if (!tab.child) {
|
||||
errors.push(
|
||||
`Tab item in component '${component.id}' is missing a 'child'.`,
|
||||
);
|
||||
}
|
||||
checkRefs([tab.child]);
|
||||
if (tab.title)
|
||||
validateBoundValue(
|
||||
tab.title,
|
||||
"title",
|
||||
component.id,
|
||||
componentType,
|
||||
errors,
|
||||
);
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "Modal":
|
||||
checkRequired(["entryPointChild", "contentChild"]);
|
||||
checkRefs([properties.entryPointChild, properties.contentChild]);
|
||||
break;
|
||||
case "Button":
|
||||
checkRequired(["child", "action"]);
|
||||
checkRefs([properties.child]);
|
||||
if (!properties.action || !properties.action.name) {
|
||||
errors.push(
|
||||
`Component '${component.id}' Button action is missing a 'name'.`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "Divider":
|
||||
// No required properties
|
||||
break;
|
||||
case "Icon":
|
||||
checkRequired(["name"]);
|
||||
if (properties.name)
|
||||
validateBoundValue(
|
||||
properties.name,
|
||||
"name",
|
||||
component.id,
|
||||
componentType,
|
||||
errors,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
errors.push(
|
||||
`Unknown component type '${componentType}' in component '${component.id}'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user