522 lines
14 KiB
TypeScript
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}'.`,
|
|
);
|
|
}
|
|
}
|