Files
clawdbot/vendor/a2ui/specification/0.8/eval/src/validator.ts
2025-12-17 11:35:06 +01:00

522 lines
14 KiB
TypeScript

/*
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}'.`,
);
}
}