From dde9d0494d23c517b48bb24fefed9b7f43ff4190 Mon Sep 17 00:00:00 2001 From: wrenj Date: Tue, 17 Feb 2026 17:30:43 -0500 Subject: [PATCH 1/2] port python validate function to java --- a2a_agents/java/pom.xml | 12 + .../src/main/java/org/a2ui/Validation.java | 350 ++++++++++++++++++ .../test/java/org/a2ui/ValidationTest.java | 349 +++++++++++++++++ 3 files changed, 711 insertions(+) create mode 100644 a2a_agents/java/src/main/java/org/a2ui/Validation.java create mode 100644 a2a_agents/java/src/test/java/org/a2ui/ValidationTest.java diff --git a/a2a_agents/java/pom.xml b/a2a_agents/java/pom.xml index d41484a8e..8c0d23087 100644 --- a/a2a_agents/java/pom.xml +++ b/a2a_agents/java/pom.xml @@ -44,6 +44,18 @@ ${a2a.sdk.version} + + + com.networknt + json-schema-validator + 1.4.3 + + + com.fasterxml.jackson.core + jackson-databind + 2.17.1 + + org.junit.jupiter diff --git a/a2a_agents/java/src/main/java/org/a2ui/Validation.java b/a2a_agents/java/src/main/java/org/a2ui/Validation.java new file mode 100644 index 000000000..7ab901921 --- /dev/null +++ b/a2a_agents/java/src/main/java/org/a2ui/Validation.java @@ -0,0 +1,350 @@ +package org.a2ui; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; + +import java.util.*; +import java.util.regex.Pattern; + +/** + * Validates A2UI JSON payloads against the provided schema and checks for integrity. + */ +public final class Validation { + + // RFC 6901 compliant regex for JSON Pointer + private static final Pattern JSON_POINTER_PATTERN = Pattern.compile("^(?:/(?:[^~/]|~[01])*)*$"); + + // Recursion Limits + private static final int MAX_GLOBAL_DEPTH = 50; + private static final int MAX_FUNC_CALL_DEPTH = 5; + + // Constants + private static final String COMPONENTS = "components"; + private static final String ID = "id"; + private static final String COMPONENT_PROPERTIES = "componentProperties"; + private static final String ROOT = "root"; + private static final String PATH = "path"; + private static final String FUNCTION_CALL = "functionCall"; + private static final String CALL = "call"; + private static final String ARGS = "args"; + + private static final ObjectMapper mapper = new ObjectMapper(); + + private Validation() {} + + /** + * Validates the A2UI JSON payload against the provided schema and checks for integrity. + * + * @param a2uiJson The JSON payload to validate (List or Map). + * @param a2uiSchema The schema object to validate against. + * @throws IllegalArgumentException if validation fails. + */ + public static void validateA2uiJson(Object a2uiJson, Map a2uiSchema) { + // 1. JSON Schema Validation using networknt + JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012); + JsonNode schemaNode = mapper.valueToTree(a2uiSchema); + JsonSchema schema = factory.getSchema(schemaNode); + + JsonNode instanceNode = mapper.valueToTree(a2uiJson); + Set errors = schema.validate(instanceNode); + if (!errors.isEmpty()) { + StringBuilder sb = new StringBuilder("JSON Schema Validation Failed:\n"); + for (ValidationMessage error : errors) { + sb.append("- ").append(error.getMessage()).append("\n"); + } + throw new IllegalArgumentException(sb.toString().trim()); + } + + // Normalize to list for iteration + List messages = new ArrayList<>(); + if (a2uiJson instanceof List) { + messages.addAll((List) a2uiJson); + } else { + messages.add(a2uiJson); + } + + for (Object msgItem : messages) { + if (!(msgItem instanceof Map)) { + continue; + } + @SuppressWarnings("unchecked") + Map message = (Map) msgItem; + + // Check for SurfaceUpdate which has 'components' + if (message.containsKey(COMPONENTS)) { + Object compsObj = message.get(COMPONENTS); + if (compsObj instanceof List) { + @SuppressWarnings("unchecked") + List> components = (List>) compsObj; + Map refMap = extractComponentRefFields(a2uiSchema); + validateComponentIntegrity(components, refMap); + validateTopology(components, refMap); + } + } + validateRecursionAndPaths(message); + } + } + + private static void validateComponentIntegrity(List> components, Map refFieldsMap) { + Set ids = new HashSet<>(); + + // 1. Collect IDs and check for duplicates + for (Map comp : components) { + Object compIdObj = comp.get(ID); + if (!(compIdObj instanceof String)) continue; + String compId = (String) compIdObj; + + if (ids.contains(compId)) { + throw new IllegalArgumentException("Duplicate component ID found: '" + compId + "'"); + } + ids.add(compId); + } + + // 2. Check for root component + if (!ids.contains(ROOT)) { + throw new IllegalArgumentException("Missing '" + ROOT + "' component: One component must have '" + ID + "' set to '" + ROOT + "'."); + } + + // 3. Check for dangling references using helper + for (Map comp : components) { + for (Map.Entry ref : getComponentReferences(comp, refFieldsMap)) { + String refId = ref.getKey(); + String fieldName = ref.getValue(); + if (!ids.contains(refId)) { + throw new IllegalArgumentException("Component '" + comp.get(ID) + "' references missing ID '" + refId + "' in field '" + fieldName + "'"); + } + } + } + } + + private static void validateTopology(List> components, Map refFieldsMap) { + Map> adjList = new HashMap<>(); + Set allIds = new HashSet<>(); + + // Build Adjacency List + for (Map comp : components) { + Object compIdObj = comp.get(ID); + if (!(compIdObj instanceof String)) continue; + String compId = (String) compIdObj; + + allIds.add(compId); + adjList.putIfAbsent(compId, new ArrayList<>()); + + for (Map.Entry ref : getComponentReferences(comp, refFieldsMap)) { + String refId = ref.getKey(); + String fieldName = ref.getValue(); + if (refId.equals(compId)) { + throw new IllegalArgumentException("Self-reference detected: Component '" + compId + "' references itself in field '" + fieldName + "'"); + } + adjList.get(compId).add(refId); + } + } + + // Detect Cycles using DFS + Set visited = new HashSet<>(); + Set recursionStack = new HashSet<>(); + + if (allIds.contains(ROOT)) { + dfs(ROOT, adjList, visited, recursionStack); + } + + // Check for Orphans + Set orphans = new HashSet<>(allIds); + orphans.removeAll(visited); + if (!orphans.isEmpty()) { + List sortedOrphans = new ArrayList<>(orphans); + Collections.sort(sortedOrphans); + throw new IllegalArgumentException("Orphaned components detected (not reachable from '" + ROOT + "'): " + sortedOrphans); + } + } + + private static void dfs(String nodeId, Map> adjList, Set visited, Set recursionStack) { + visited.add(nodeId); + recursionStack.add(nodeId); + + List neighbors = adjList.getOrDefault(nodeId, Collections.emptyList()); + for (String neighbor : neighbors) { + if (!visited.contains(neighbor)) { + dfs(neighbor, adjList, visited, recursionStack); + } else if (recursionStack.contains(neighbor)) { + throw new IllegalArgumentException("Circular reference detected involving component '" + neighbor + "'"); + } + } + + recursionStack.remove(nodeId); + } + + private static class RefFields { + Set singleRefs = new HashSet<>(); + Set listRefs = new HashSet<>(); + } + + @SuppressWarnings("unchecked") + private static Map extractComponentRefFields(Map schema) { + Map refMap = new HashMap<>(); + + Map compsSchema = getMap(schema, "properties", COMPONENTS); + if (compsSchema == null) return refMap; + + Map itemsSchema = getMap(compsSchema, "items"); + if (itemsSchema == null) return refMap; + + Map compPropsSchema = getMap(itemsSchema, "properties", COMPONENT_PROPERTIES); + if (compPropsSchema == null) return refMap; + + Map allComponents = getMap(compPropsSchema, "properties"); + if (allComponents == null) return refMap; + + for (Map.Entry compEntry : allComponents.entrySet()) { + String compName = compEntry.getKey(); + if (!(compEntry.getValue() instanceof Map)) continue; + Map compSchema = (Map) compEntry.getValue(); + + RefFields refs = new RefFields(); + Map props = getMap(compSchema, "properties"); + if (props != null) { + for (Map.Entry propEntry : props.entrySet()) { + String propName = propEntry.getKey(); + if (!(propEntry.getValue() instanceof Map)) continue; + Map propSchema = (Map) propEntry.getValue(); + + if (isComponentIdRef(propSchema)) { + refs.singleRefs.add(propName); + } else if (isChildListRef(propSchema)) { + refs.listRefs.add(propName); + } + } + } + if (!refs.singleRefs.isEmpty() || !refs.listRefs.isEmpty()) { + refMap.put(compName, refs); + } + } + return refMap; + } + + private static boolean isComponentIdRef(Map propSchema) { + Object ref = propSchema.get("$ref"); + return ref instanceof String && ((String) ref).endsWith("ComponentId"); + } + + @SuppressWarnings("unchecked") + private static boolean isChildListRef(Map propSchema) { + Object ref = propSchema.get("$ref"); + if (ref instanceof String && ((String) ref).endsWith("ChildList")) { + return true; + } + if ("array".equals(propSchema.get("type"))) { + Map items = getMap(propSchema, "items"); + if (items != null && isComponentIdRef(items)) { + return true; + } + } + return false; + } + + @SuppressWarnings("unchecked") + private static Map getMap(Map map, String... keys) { + Map current = map; + for (String key : keys) { + Object val = current.get(key); + if (val instanceof Map) { + current = (Map) val; + } else { + return null; + } + } + return current; + } + + @SuppressWarnings("unchecked") + private static List> getComponentReferences(Map component, Map refFieldsMap) { + List> references = new ArrayList<>(); + Object compPropsContainerObj = component.get(COMPONENT_PROPERTIES); + if (!(compPropsContainerObj instanceof Map)) { + return references; + } + + Map compPropsContainer = (Map) compPropsContainerObj; + + for (Map.Entry typeEntry : compPropsContainer.entrySet()) { + String compType = typeEntry.getKey(); + if (!(typeEntry.getValue() instanceof Map)) continue; + Map props = (Map) typeEntry.getValue(); + + RefFields refs = refFieldsMap.getOrDefault(compType, new RefFields()); + + for (Map.Entry propEntry : props.entrySet()) { + String key = propEntry.getKey(); + Object value = propEntry.getValue(); + + if (refs.singleRefs.contains(key) && value instanceof String) { + references.add(new AbstractMap.SimpleEntry<>((String) value, key)); + } else if (refs.listRefs.contains(key) && value instanceof List) { + for (Object item : (List) value) { + if (item instanceof String) { + references.add(new AbstractMap.SimpleEntry<>((String) item, key)); + } + } + } + } + } + return references; + } + + private static void validateRecursionAndPaths(Object data) { + traverse(data, 0, 0); + } + + @SuppressWarnings("unchecked") + private static void traverse(Object item, int globalDepth, int funcDepth) { + if (globalDepth > MAX_GLOBAL_DEPTH) { + throw new IllegalArgumentException("Global recursion limit exceeded: Depth > " + MAX_GLOBAL_DEPTH); + } + + if (item instanceof List) { + for (Object x : (List) item) { + traverse(x, globalDepth + 1, funcDepth); + } + return; + } + + if (item instanceof Map) { + Map map = (Map) item; + + // Check for path + if (map.containsKey(PATH) && map.get(PATH) instanceof String) { + String path = (String) map.get(PATH); + if (!JSON_POINTER_PATTERN.matcher(path).matches()) { + throw new IllegalArgumentException("Invalid JSON Pointer syntax: '" + path + "'"); + } + } + + // Check for FunctionCall + boolean isFunc = map.containsKey(CALL) && map.containsKey(ARGS); + + if (isFunc) { + if (funcDepth >= MAX_FUNC_CALL_DEPTH) { + throw new IllegalArgumentException("Recursion limit exceeded: " + FUNCTION_CALL + " depth > " + MAX_FUNC_CALL_DEPTH); + } + + for (Map.Entry entry : map.entrySet()) { + String k = entry.getKey(); + Object v = entry.getValue(); + if (ARGS.equals(k)) { + traverse(v, globalDepth + 1, funcDepth + 1); + } else { + traverse(v, globalDepth + 1, funcDepth); + } + } + } else { + for (Object v : map.values()) { + traverse(v, globalDepth + 1, funcDepth); + } + } + } + } +} diff --git a/a2a_agents/java/src/test/java/org/a2ui/ValidationTest.java b/a2a_agents/java/src/test/java/org/a2ui/ValidationTest.java new file mode 100644 index 000000000..6d2d3bf4a --- /dev/null +++ b/a2a_agents/java/src/test/java/org/a2ui/ValidationTest.java @@ -0,0 +1,349 @@ +package org.a2ui; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.*; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class ValidationTest { + + private Map schema; + + @BeforeEach + void setUp() { + schema = new HashMap<>(); + schema.put("type", "object"); + + Map defs = new HashMap<>(); + Map componentId = new HashMap<>(); + componentId.put("type", "string"); + defs.put("ComponentId", componentId); + + Map childList = new HashMap<>(); + childList.put("type", "array"); + Map childListItems = new HashMap<>(); + childListItems.put("$ref", "#/$defs/ComponentId"); + childList.put("items", childListItems); + defs.put("ChildList", childList); + + schema.put("$defs", defs); + + Map properties = new HashMap<>(); + Map componentsProp = new HashMap<>(); + componentsProp.put("type", "array"); + + Map componentsItems = new HashMap<>(); + componentsItems.put("type", "object"); + componentsItems.put("required", Collections.singletonList("id")); + + Map itemProps = new HashMap<>(); + Map idSchema = new HashMap<>(); + idSchema.put("$ref", "#/$defs/ComponentId"); + itemProps.put("id", idSchema); + + Map componentProperties = new HashMap<>(); + componentProperties.put("type", "object"); + Map componentTypes = new HashMap<>(); + + componentTypes.put("Column", createContainerSchema()); + componentTypes.put("Row", createContainerSchema()); + componentTypes.put("Container", createContainerSchema()); + + Map cardSchema = new HashMap<>(); + cardSchema.put("type", "object"); + Map cardProps = new HashMap<>(); + Map cardChild = new HashMap<>(); + cardChild.put("$ref", "#/$defs/ComponentId"); + cardProps.put("child", cardChild); + cardSchema.put("properties", cardProps); + componentTypes.put("Card", cardSchema); + + Map buttonSchema = new HashMap<>(); + buttonSchema.put("type", "object"); + Map buttonProps = new HashMap<>(); + Map buttonChild = new HashMap<>(); + buttonChild.put("$ref", "#/$defs/ComponentId"); + buttonProps.put("child", buttonChild); + + Map actionSchema = new HashMap<>(); + Map actionProps = new HashMap<>(); + Map funcCallSchema = new HashMap<>(); + Map funcCallProps = new HashMap<>(); + funcCallProps.put("call", Map.of("type", "string")); + funcCallProps.put("args", Map.of("type", "object")); + funcCallSchema.put("properties", funcCallProps); + actionProps.put("functionCall", funcCallSchema); + actionSchema.put("properties", actionProps); + buttonProps.put("action", actionSchema); + buttonSchema.put("properties", buttonProps); + componentTypes.put("Button", buttonSchema); + + Map textSchema = new HashMap<>(); + textSchema.put("type", "object"); + Map textProps = new HashMap<>(); + Map textText = new HashMap<>(); + textText.put("oneOf", Arrays.asList(Map.of("type", "string"), Map.of("type", "object"))); + textProps.put("text", textText); + textSchema.put("properties", textProps); + componentTypes.put("Text", textSchema); + + componentProperties.put("properties", componentTypes); + itemProps.put("componentProperties", componentProperties); + + componentsItems.put("properties", itemProps); + componentsProp.put("items", componentsItems); + properties.put("components", componentsProp); + schema.put("properties", properties); + } + + private Map createContainerSchema() { + Map container = new HashMap<>(); + container.put("type", "object"); + Map props = new HashMap<>(); + Map children = new HashMap<>(); + children.put("$ref", "#/$defs/ChildList"); + props.put("children", children); + container.put("properties", props); + return container; + } + + private Map createComponent(String id, String type, Map props) { + Map comp = new HashMap<>(); + comp.put("id", id); + Map compProps = new HashMap<>(); + if (type != null) { + compProps.put(type, props); + } + comp.put("componentProperties", compProps); + return comp; + } + + @Test + void testValidateA2uiJsonValidIntegrity() { + Map payload = new HashMap<>(); + payload.put("components", Arrays.asList( + createComponent("root", "Column", Map.of("children", Collections.singletonList("child1"))), + createComponent("child1", "Text", Map.of("text", "Hello")) + )); + assertDoesNotThrow(() -> Validation.validateA2uiJson(payload, schema)); + } + + @Test + void testValidateA2uiJsonDuplicateIds() { + Map payload = new HashMap<>(); + payload.put("components", Arrays.asList( + createComponent("root", null, new HashMap<>()), + createComponent("root", null, new HashMap<>()) + )); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> Validation.validateA2uiJson(payload, schema)); + assertTrue(exception.getMessage().contains("Duplicate component ID found: 'root'")); + } + + @Test + void testValidateA2uiJsonMissingRoot() { + Map payload = new HashMap<>(); + payload.put("components", Collections.singletonList( + createComponent("not-root", null, new HashMap<>()) + )); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> Validation.validateA2uiJson(payload, schema)); + assertTrue(exception.getMessage().contains("Missing 'root' component")); + } + + static Stream danglingReferencesProvider() { + return Stream.of( + Arguments.of("Card", "child", "missing_child"), + Arguments.of("Column", "children", Arrays.asList("child1", "missing_child")) + ); + } + + @ParameterizedTest + @MethodSource("danglingReferencesProvider") + void testValidateA2uiJsonDanglingReferences(String componentType, String fieldName, Object idsToRef) { + Map payload = new HashMap<>(); + List> components = new ArrayList<>(); + components.add(createComponent("root", componentType, Map.of(fieldName, idsToRef))); + + if (idsToRef instanceof List) { + for (Object childIdObj : (List) idsToRef) { + String childId = (String) childIdObj; + if (!"missing_child".equals(childId)) { + components.add(createComponent(childId, null, new HashMap<>())); + } + } + } + payload.put("components", components); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> Validation.validateA2uiJson(payload, schema)); + assertTrue(exception.getMessage().contains("Component 'root' references missing ID 'missing_child' in field '" + fieldName + "'")); + } + + @Test + void testValidateA2uiJsonSelfReference() { + Map payload = new HashMap<>(); + payload.put("components", Collections.singletonList( + createComponent("root", "Container", Map.of("children", Collections.singletonList("root"))) + )); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> Validation.validateA2uiJson(payload, schema)); + assertTrue(exception.getMessage().contains("Self-reference detected: Component 'root' references itself in field 'children'")); + } + + @Test + void testValidateA2uiJsonCircularReference() { + Map payload = new HashMap<>(); + payload.put("components", Arrays.asList( + createComponent("root", "Container", Map.of("children", Collections.singletonList("child1"))), + createComponent("child1", "Container", Map.of("children", Collections.singletonList("root"))) + )); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> Validation.validateA2uiJson(payload, schema)); + assertTrue(exception.getMessage().contains("Circular reference detected involving component")); + } + + @Test + void testValidateA2uiJsonOrphanedComponent() { + Map payload = new HashMap<>(); + payload.put("components", Arrays.asList( + createComponent("root", "Container", Map.of("children", Collections.emptyList())), + createComponent("orphan", null, new HashMap<>()) + )); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> Validation.validateA2uiJson(payload, schema)); + assertTrue(exception.getMessage().contains("Orphaned components detected (not reachable from 'root'): [orphan]")); + } + + @Test + void testValidateA2uiJsonValidTopologyComplex() { + Map payload = new HashMap<>(); + payload.put("components", Arrays.asList( + createComponent("root", "Container", Map.of("children", Arrays.asList("child1", "child2"))), + createComponent("child1", "Text", Map.of("text", "Hello")), + createComponent("child2", "Container", Map.of("children", Collections.singletonList("child3"))), + createComponent("child3", "Text", Map.of("text", "World")) + )); + assertDoesNotThrow(() -> Validation.validateA2uiJson(payload, schema)); + } + + @Test + void testValidateRecursionLimitExceeded() { + Map args = new HashMap<>(); + Map current = args; + for (int i = 0; i < 5; i++) { + Map argMap = new HashMap<>(); + argMap.put("call", "fn" + i); + Map nextArgs = new HashMap<>(); + argMap.put("args", nextArgs); + current.put("arg", argMap); + current = nextArgs; + } + + Map buttonProps = new HashMap<>(); + buttonProps.put("label", "Click me"); + buttonProps.put("action", Map.of("functionCall", Map.of("call", "fn_top", "args", args))); + + Map payload = new HashMap<>(); + payload.put("components", Collections.singletonList( + createComponent("root", "Button", buttonProps) + )); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> Validation.validateA2uiJson(payload, schema)); + assertTrue(exception.getMessage().contains("Recursion limit exceeded")); + } + + @Test + void testValidateRecursionLimitValid() { + Map args = new HashMap<>(); + Map current = args; + for (int i = 0; i < 4; i++) { + Map argMap = new HashMap<>(); + argMap.put("call", "fn" + i); + Map nextArgs = new HashMap<>(); + argMap.put("args", nextArgs); + current.put("arg", argMap); + current = nextArgs; + } + + Map buttonProps = new HashMap<>(); + buttonProps.put("label", "Click me"); + buttonProps.put("action", Map.of("functionCall", Map.of("call", "fn_top", "args", args))); + + Map payload = new HashMap<>(); + payload.put("components", Collections.singletonList( + createComponent("root", "Button", buttonProps) + )); + assertDoesNotThrow(() -> Validation.validateA2uiJson(payload, schema)); + } + + static Stream invalidPathsProvider() { + return Stream.of( + Arguments.of(Map.of("updateDataModel", Map.of("surfaceId", "surface1", "path", "invalid//path", "value", "data"))), + Arguments.of(Map.of("components", Collections.singletonList(Map.of("id", "root", "componentProperties", Map.of("Text", Map.of("text", Map.of("path", "invalid path with spaces"))))))), + Arguments.of(Map.of("updateDataModel", Map.of("surfaceId", "surface1", "path", "/invalid/escape/~2", "value", "data"))) + ); + } + + @ParameterizedTest + @MethodSource("invalidPathsProvider") + void testValidateInvalidPaths(Map payload) { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> Validation.validateA2uiJson(payload, schema)); + assertTrue(exception.getMessage().contains("Invalid JSON Pointer syntax")); + } + + @Test + void testValidateGlobalRecursionLimitExceeded() { + Map deepPayload = new HashMap<>(); + deepPayload.put("level", 0); + Map current = deepPayload; + for (int i = 0; i < 55; i++) { + Map next = new HashMap<>(); + next.put("level", i + 1); + current.put("next", next); + current = next; + } + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> Validation.validateA2uiJson(deepPayload, schema)); + assertTrue(exception.getMessage().contains("Global recursion limit exceeded")); + } + + @Test + void testValidateCustomSchemaReference() { + Map customSchema = new HashMap<>(schema); + + Map properties = (Map) customSchema.get("properties"); + Map componentsProp = (Map) properties.get("components"); + Map componentsItems = (Map) componentsProp.get("items"); + Map itemProps = (Map) componentsItems.get("properties"); + Map componentProperties = (Map) itemProps.get("componentProperties"); + Map componentTypes = new HashMap<>((Map) componentProperties.get("properties")); + + Map customLinkSchema = new HashMap<>(); + customLinkSchema.put("type", "object"); + Map customLinkProps = new HashMap<>(); + Map linkedComponentId = new HashMap<>(); + linkedComponentId.put("$ref", "#/$defs/ComponentId"); + customLinkProps.put("linkedComponentId", linkedComponentId); + customLinkSchema.put("properties", customLinkProps); + componentTypes.put("CustomLink", customLinkSchema); + + componentProperties.put("properties", componentTypes); + + Map payload = new HashMap<>(); + payload.put("components", Collections.singletonList( + createComponent("root", "CustomLink", Map.of("linkedComponentId", "missing_target")) + )); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> Validation.validateA2uiJson(payload, customSchema)); + assertTrue(exception.getMessage().contains("Component 'root' references missing ID 'missing_target' in field 'linkedComponentId'")); + } +} From 750f61c66c3736ad230b2c32a6322b994e04048f Mon Sep 17 00:00:00 2001 From: wrenj Date: Tue, 17 Feb 2026 17:34:10 -0500 Subject: [PATCH 2/2] rename --- ...Validation.java => A2uiJsonValidator.java} | 70 ++-- .../java/org/a2ui/A2uiJsonValidatorTest.java | 357 ++++++++++++++++++ .../test/java/org/a2ui/ValidationTest.java | 349 ----------------- 3 files changed, 402 insertions(+), 374 deletions(-) rename a2a_agents/java/src/main/java/org/a2ui/{Validation.java => A2uiJsonValidator.java} (85%) create mode 100644 a2a_agents/java/src/test/java/org/a2ui/A2uiJsonValidatorTest.java delete mode 100644 a2a_agents/java/src/test/java/org/a2ui/ValidationTest.java diff --git a/a2a_agents/java/src/main/java/org/a2ui/Validation.java b/a2a_agents/java/src/main/java/org/a2ui/A2uiJsonValidator.java similarity index 85% rename from a2a_agents/java/src/main/java/org/a2ui/Validation.java rename to a2a_agents/java/src/main/java/org/a2ui/A2uiJsonValidator.java index 7ab901921..95f2f1567 100644 --- a/a2a_agents/java/src/main/java/org/a2ui/Validation.java +++ b/a2a_agents/java/src/main/java/org/a2ui/A2uiJsonValidator.java @@ -11,9 +11,10 @@ import java.util.regex.Pattern; /** - * Validates A2UI JSON payloads against the provided schema and checks for integrity. + * Validates A2UI JSON payloads against the provided schema and checks for + * integrity. */ -public final class Validation { +public final class A2uiJsonValidator { // RFC 6901 compliant regex for JSON Pointer private static final Pattern JSON_POINTER_PATTERN = Pattern.compile("^(?:/(?:[^~/]|~[01])*)*$"); @@ -34,16 +35,18 @@ public final class Validation { private static final ObjectMapper mapper = new ObjectMapper(); - private Validation() {} + private A2uiJsonValidator() { + } /** - * Validates the A2UI JSON payload against the provided schema and checks for integrity. + * Validates the A2UI JSON payload against the provided schema and checks for + * integrity. * - * @param a2uiJson The JSON payload to validate (List or Map). + * @param a2uiJson The JSON payload to validate (List or Map). * @param a2uiSchema The schema object to validate against. * @throws IllegalArgumentException if validation fails. */ - public static void validateA2uiJson(Object a2uiJson, Map a2uiSchema) { + public static void validate(Object a2uiJson, Map a2uiSchema) { // 1. JSON Schema Validation using networknt JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012); JsonNode schemaNode = mapper.valueToTree(a2uiSchema); @@ -89,13 +92,15 @@ public static void validateA2uiJson(Object a2uiJson, Map a2uiSch } } - private static void validateComponentIntegrity(List> components, Map refFieldsMap) { + private static void validateComponentIntegrity(List> components, + Map refFieldsMap) { Set ids = new HashSet<>(); // 1. Collect IDs and check for duplicates for (Map comp : components) { Object compIdObj = comp.get(ID); - if (!(compIdObj instanceof String)) continue; + if (!(compIdObj instanceof String)) + continue; String compId = (String) compIdObj; if (ids.contains(compId)) { @@ -106,7 +111,8 @@ private static void validateComponentIntegrity(List> compone // 2. Check for root component if (!ids.contains(ROOT)) { - throw new IllegalArgumentException("Missing '" + ROOT + "' component: One component must have '" + ID + "' set to '" + ROOT + "'."); + throw new IllegalArgumentException( + "Missing '" + ROOT + "' component: One component must have '" + ID + "' set to '" + ROOT + "'."); } // 3. Check for dangling references using helper @@ -115,7 +121,8 @@ private static void validateComponentIntegrity(List> compone String refId = ref.getKey(); String fieldName = ref.getValue(); if (!ids.contains(refId)) { - throw new IllegalArgumentException("Component '" + comp.get(ID) + "' references missing ID '" + refId + "' in field '" + fieldName + "'"); + throw new IllegalArgumentException("Component '" + comp.get(ID) + "' references missing ID '" + + refId + "' in field '" + fieldName + "'"); } } } @@ -128,7 +135,8 @@ private static void validateTopology(List> components, Map comp : components) { Object compIdObj = comp.get(ID); - if (!(compIdObj instanceof String)) continue; + if (!(compIdObj instanceof String)) + continue; String compId = (String) compIdObj; allIds.add(compId); @@ -138,7 +146,8 @@ private static void validateTopology(List> components, Map> components, Map sortedOrphans = new ArrayList<>(orphans); Collections.sort(sortedOrphans); - throw new IllegalArgumentException("Orphaned components detected (not reachable from '" + ROOT + "'): " + sortedOrphans); + throw new IllegalArgumentException( + "Orphaned components detected (not reachable from '" + ROOT + "'): " + sortedOrphans); } } - private static void dfs(String nodeId, Map> adjList, Set visited, Set recursionStack) { + private static void dfs(String nodeId, Map> adjList, Set visited, + Set recursionStack) { visited.add(nodeId); recursionStack.add(nodeId); @@ -171,7 +182,8 @@ private static void dfs(String nodeId, Map> adjList, Set listRefs = new HashSet<>(); } - @SuppressWarnings("unchecked") private static Map extractComponentRefFields(Map schema) { Map refMap = new HashMap<>(); Map compsSchema = getMap(schema, "properties", COMPONENTS); - if (compsSchema == null) return refMap; + if (compsSchema == null) + return refMap; Map itemsSchema = getMap(compsSchema, "items"); - if (itemsSchema == null) return refMap; + if (itemsSchema == null) + return refMap; Map compPropsSchema = getMap(itemsSchema, "properties", COMPONENT_PROPERTIES); - if (compPropsSchema == null) return refMap; + if (compPropsSchema == null) + return refMap; Map allComponents = getMap(compPropsSchema, "properties"); - if (allComponents == null) return refMap; + if (allComponents == null) + return refMap; for (Map.Entry compEntry : allComponents.entrySet()) { String compName = compEntry.getKey(); - if (!(compEntry.getValue() instanceof Map)) continue; + if (!(compEntry.getValue() instanceof Map)) + continue; Map compSchema = (Map) compEntry.getValue(); RefFields refs = new RefFields(); @@ -209,7 +225,8 @@ private static Map extractComponentRefFields(Map propEntry : props.entrySet()) { String propName = propEntry.getKey(); - if (!(propEntry.getValue() instanceof Map)) continue; + if (!(propEntry.getValue() instanceof Map)) + continue; Map propSchema = (Map) propEntry.getValue(); if (isComponentIdRef(propSchema)) { @@ -261,7 +278,8 @@ private static Map getMap(Map map, String... key } @SuppressWarnings("unchecked") - private static List> getComponentReferences(Map component, Map refFieldsMap) { + private static List> getComponentReferences(Map component, + Map refFieldsMap) { List> references = new ArrayList<>(); Object compPropsContainerObj = component.get(COMPONENT_PROPERTIES); if (!(compPropsContainerObj instanceof Map)) { @@ -272,7 +290,8 @@ private static List> getComponentReferences(Map typeEntry : compPropsContainer.entrySet()) { String compType = typeEntry.getKey(); - if (!(typeEntry.getValue() instanceof Map)) continue; + if (!(typeEntry.getValue() instanceof Map)) + continue; Map props = (Map) typeEntry.getValue(); RefFields refs = refFieldsMap.getOrDefault(compType, new RefFields()); @@ -328,7 +347,8 @@ private static void traverse(Object item, int globalDepth, int funcDepth) { if (isFunc) { if (funcDepth >= MAX_FUNC_CALL_DEPTH) { - throw new IllegalArgumentException("Recursion limit exceeded: " + FUNCTION_CALL + " depth > " + MAX_FUNC_CALL_DEPTH); + throw new IllegalArgumentException( + "Recursion limit exceeded: " + FUNCTION_CALL + " depth > " + MAX_FUNC_CALL_DEPTH); } for (Map.Entry entry : map.entrySet()) { diff --git a/a2a_agents/java/src/test/java/org/a2ui/A2uiJsonValidatorTest.java b/a2a_agents/java/src/test/java/org/a2ui/A2uiJsonValidatorTest.java new file mode 100644 index 000000000..4ca8efbec --- /dev/null +++ b/a2a_agents/java/src/test/java/org/a2ui/A2uiJsonValidatorTest.java @@ -0,0 +1,357 @@ +package org.a2ui; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.*; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class A2uiJsonValidatorTest { + + private Map schema; + + @BeforeEach + void setUp() { + schema = new HashMap<>(); + schema.put("type", "object"); + + Map defs = new HashMap<>(); + Map componentId = new HashMap<>(); + componentId.put("type", "string"); + defs.put("ComponentId", componentId); + + Map childList = new HashMap<>(); + childList.put("type", "array"); + Map childListItems = new HashMap<>(); + childListItems.put("$ref", "#/$defs/ComponentId"); + childList.put("items", childListItems); + defs.put("ChildList", childList); + + schema.put("$defs", defs); + + Map properties = new HashMap<>(); + Map componentsProp = new HashMap<>(); + componentsProp.put("type", "array"); + + Map componentsItems = new HashMap<>(); + componentsItems.put("type", "object"); + componentsItems.put("required", Collections.singletonList("id")); + + Map itemProps = new HashMap<>(); + Map idSchema = new HashMap<>(); + idSchema.put("$ref", "#/$defs/ComponentId"); + itemProps.put("id", idSchema); + + Map componentProperties = new HashMap<>(); + componentProperties.put("type", "object"); + Map componentTypes = new HashMap<>(); + + componentTypes.put("Column", createContainerSchema()); + componentTypes.put("Row", createContainerSchema()); + componentTypes.put("Container", createContainerSchema()); + + Map cardSchema = new HashMap<>(); + cardSchema.put("type", "object"); + Map cardProps = new HashMap<>(); + Map cardChild = new HashMap<>(); + cardChild.put("$ref", "#/$defs/ComponentId"); + cardProps.put("child", cardChild); + cardSchema.put("properties", cardProps); + componentTypes.put("Card", cardSchema); + + Map buttonSchema = new HashMap<>(); + buttonSchema.put("type", "object"); + Map buttonProps = new HashMap<>(); + Map buttonChild = new HashMap<>(); + buttonChild.put("$ref", "#/$defs/ComponentId"); + buttonProps.put("child", buttonChild); + + Map actionSchema = new HashMap<>(); + Map actionProps = new HashMap<>(); + Map funcCallSchema = new HashMap<>(); + Map funcCallProps = new HashMap<>(); + funcCallProps.put("call", Map.of("type", "string")); + funcCallProps.put("args", Map.of("type", "object")); + funcCallSchema.put("properties", funcCallProps); + actionProps.put("functionCall", funcCallSchema); + actionSchema.put("properties", actionProps); + buttonProps.put("action", actionSchema); + buttonSchema.put("properties", buttonProps); + componentTypes.put("Button", buttonSchema); + + Map textSchema = new HashMap<>(); + textSchema.put("type", "object"); + Map textProps = new HashMap<>(); + Map textText = new HashMap<>(); + textText.put("oneOf", Arrays.asList(Map.of("type", "string"), Map.of("type", "object"))); + textProps.put("text", textText); + textSchema.put("properties", textProps); + componentTypes.put("Text", textSchema); + + componentProperties.put("properties", componentTypes); + itemProps.put("componentProperties", componentProperties); + + componentsItems.put("properties", itemProps); + componentsProp.put("items", componentsItems); + properties.put("components", componentsProp); + schema.put("properties", properties); + } + + private Map createContainerSchema() { + Map container = new HashMap<>(); + container.put("type", "object"); + Map props = new HashMap<>(); + Map children = new HashMap<>(); + children.put("$ref", "#/$defs/ChildList"); + props.put("children", children); + container.put("properties", props); + return container; + } + + private Map createComponent(String id, String type, Map props) { + Map comp = new HashMap<>(); + comp.put("id", id); + Map compProps = new HashMap<>(); + if (type != null) { + compProps.put(type, props); + } + comp.put("componentProperties", compProps); + return comp; + } + + @Test + void testValidateA2uiJsonValidIntegrity() { + Map payload = new HashMap<>(); + payload.put("components", Arrays.asList( + createComponent("root", "Column", + Map.of("children", Collections.singletonList("child1"))), + createComponent("child1", "Text", Map.of("text", "Hello")))); + assertDoesNotThrow(() -> A2uiJsonValidator.validate(payload, schema)); + } + + @Test + void testValidateA2uiJsonDuplicateIds() { + Map payload = new HashMap<>(); + payload.put("components", Arrays.asList( + createComponent("root", null, new HashMap<>()), + createComponent("root", null, new HashMap<>()))); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> A2uiJsonValidator.validate(payload, schema)); + assertTrue(exception.getMessage().contains("Duplicate component ID found: 'root'")); + } + + @Test + void testValidateA2uiJsonMissingRoot() { + Map payload = new HashMap<>(); + payload.put("components", Collections.singletonList( + createComponent("not-root", null, new HashMap<>()))); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> A2uiJsonValidator.validate(payload, schema)); + assertTrue(exception.getMessage().contains("Missing 'root' component")); + } + + static Stream danglingReferencesProvider() { + return Stream.of( + Arguments.of("Card", "child", "missing_child"), + Arguments.of("Column", "children", Arrays.asList("child1", "missing_child"))); + } + + @ParameterizedTest + @MethodSource("danglingReferencesProvider") + void testValidateA2uiJsonDanglingReferences(String componentType, String fieldName, Object idsToRef) { + Map payload = new HashMap<>(); + List> components = new ArrayList<>(); + components.add(createComponent("root", componentType, Map.of(fieldName, idsToRef))); + + if (idsToRef instanceof List) { + for (Object childIdObj : (List) idsToRef) { + String childId = (String) childIdObj; + if (!"missing_child".equals(childId)) { + components.add(createComponent(childId, null, new HashMap<>())); + } + } + } + payload.put("components", components); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> A2uiJsonValidator.validate(payload, schema)); + assertTrue(exception.getMessage() + .contains("Component 'root' references missing ID 'missing_child' in field '" + + fieldName + "'")); + } + + @Test + void testValidateA2uiJsonSelfReference() { + Map payload = new HashMap<>(); + payload.put("components", Collections.singletonList( + createComponent("root", "Container", + Map.of("children", Collections.singletonList("root"))))); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> A2uiJsonValidator.validate(payload, schema)); + assertTrue(exception.getMessage() + .contains("Self-reference detected: Component 'root' references itself in field 'children'")); + } + + @Test + void testValidateA2uiJsonCircularReference() { + Map payload = new HashMap<>(); + payload.put("components", Arrays.asList( + createComponent("root", "Container", + Map.of("children", Collections.singletonList("child1"))), + createComponent("child1", "Container", + Map.of("children", Collections.singletonList("root"))))); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> A2uiJsonValidator.validate(payload, schema)); + assertTrue(exception.getMessage().contains("Circular reference detected involving component")); + } + + @Test + void testValidateA2uiJsonOrphanedComponent() { + Map payload = new HashMap<>(); + payload.put("components", Arrays.asList( + createComponent("root", "Container", Map.of("children", Collections.emptyList())), + createComponent("orphan", null, new HashMap<>()))); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> A2uiJsonValidator.validate(payload, schema)); + assertTrue( + exception.getMessage().contains( + "Orphaned components detected (not reachable from 'root'): [orphan]")); + } + + @Test + void testValidateA2uiJsonValidTopologyComplex() { + Map payload = new HashMap<>(); + payload.put("components", Arrays.asList( + createComponent("root", "Container", + Map.of("children", Arrays.asList("child1", "child2"))), + createComponent("child1", "Text", Map.of("text", "Hello")), + createComponent("child2", "Container", + Map.of("children", Collections.singletonList("child3"))), + createComponent("child3", "Text", Map.of("text", "World")))); + assertDoesNotThrow(() -> A2uiJsonValidator.validate(payload, schema)); + } + + @Test + void testValidateRecursionLimitExceeded() { + Map args = new HashMap<>(); + Map current = args; + for (int i = 0; i < 5; i++) { + Map argMap = new HashMap<>(); + argMap.put("call", "fn" + i); + Map nextArgs = new HashMap<>(); + argMap.put("args", nextArgs); + current.put("arg", argMap); + current = nextArgs; + } + + Map buttonProps = new HashMap<>(); + buttonProps.put("label", "Click me"); + buttonProps.put("action", Map.of("functionCall", Map.of("call", "fn_top", "args", args))); + + Map payload = new HashMap<>(); + payload.put("components", Collections.singletonList( + createComponent("root", "Button", buttonProps))); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> A2uiJsonValidator.validate(payload, schema)); + assertTrue(exception.getMessage().contains("Recursion limit exceeded")); + } + + @Test + void testValidateRecursionLimitValid() { + Map args = new HashMap<>(); + Map current = args; + for (int i = 0; i < 4; i++) { + Map argMap = new HashMap<>(); + argMap.put("call", "fn" + i); + Map nextArgs = new HashMap<>(); + argMap.put("args", nextArgs); + current.put("arg", argMap); + current = nextArgs; + } + + Map buttonProps = new HashMap<>(); + buttonProps.put("label", "Click me"); + buttonProps.put("action", Map.of("functionCall", Map.of("call", "fn_top", "args", args))); + + Map payload = new HashMap<>(); + payload.put("components", Collections.singletonList( + createComponent("root", "Button", buttonProps))); + assertDoesNotThrow(() -> A2uiJsonValidator.validate(payload, schema)); + } + + static Stream invalidPathsProvider() { + return Stream.of( + Arguments.of(Map.of("updateDataModel", + Map.of("surfaceId", "surface1", "path", "invalid//path", "value", + "data"))), + Arguments.of(Map.of("components", + Collections.singletonList(Map.of("id", "root", "componentProperties", + Map.of("Text", Map.of("text", Map.of("path", + "invalid path with spaces"))))))), + Arguments.of(Map.of("updateDataModel", + Map.of("surfaceId", "surface1", "path", "/invalid/escape/~2", "value", + "data")))); + } + + @ParameterizedTest + @MethodSource("invalidPathsProvider") + void testValidateInvalidPaths(Map payload) { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> A2uiJsonValidator.validate(payload, schema)); + assertTrue(exception.getMessage().contains("Invalid JSON Pointer syntax")); + } + + @Test + void testValidateGlobalRecursionLimitExceeded() { + Map deepPayload = new HashMap<>(); + deepPayload.put("level", 0); + Map current = deepPayload; + for (int i = 0; i < 55; i++) { + Map next = new HashMap<>(); + next.put("level", i + 1); + current.put("next", next); + current = next; + } + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> A2uiJsonValidator.validate(deepPayload, schema)); + assertTrue(exception.getMessage().contains("Global recursion limit exceeded")); + } + + @Test + void testValidateCustomSchemaReference() { + Map customSchema = new HashMap<>(schema); + + Map properties = (Map) customSchema.get("properties"); + Map componentsProp = (Map) properties.get("components"); + Map componentsItems = (Map) componentsProp.get("items"); + Map itemProps = (Map) componentsItems.get("properties"); + Map componentProperties = (Map) itemProps.get("componentProperties"); + Map componentTypes = new HashMap<>( + (Map) componentProperties.get("properties")); + + Map customLinkSchema = new HashMap<>(); + customLinkSchema.put("type", "object"); + Map customLinkProps = new HashMap<>(); + Map linkedComponentId = new HashMap<>(); + linkedComponentId.put("$ref", "#/$defs/ComponentId"); + customLinkProps.put("linkedComponentId", linkedComponentId); + customLinkSchema.put("properties", customLinkProps); + componentTypes.put("CustomLink", customLinkSchema); + + componentProperties.put("properties", componentTypes); + + Map payload = new HashMap<>(); + payload.put("components", Collections.singletonList( + createComponent("root", "CustomLink", Map.of("linkedComponentId", "missing_target")))); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> A2uiJsonValidator.validate(payload, customSchema)); + assertTrue(exception.getMessage() + .contains("Component 'root' references missing ID 'missing_target' in field 'linkedComponentId'")); + } +} diff --git a/a2a_agents/java/src/test/java/org/a2ui/ValidationTest.java b/a2a_agents/java/src/test/java/org/a2ui/ValidationTest.java deleted file mode 100644 index 6d2d3bf4a..000000000 --- a/a2a_agents/java/src/test/java/org/a2ui/ValidationTest.java +++ /dev/null @@ -1,349 +0,0 @@ -package org.a2ui; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -import java.util.*; -import java.util.stream.Stream; - -import static org.junit.jupiter.api.Assertions.*; - -class ValidationTest { - - private Map schema; - - @BeforeEach - void setUp() { - schema = new HashMap<>(); - schema.put("type", "object"); - - Map defs = new HashMap<>(); - Map componentId = new HashMap<>(); - componentId.put("type", "string"); - defs.put("ComponentId", componentId); - - Map childList = new HashMap<>(); - childList.put("type", "array"); - Map childListItems = new HashMap<>(); - childListItems.put("$ref", "#/$defs/ComponentId"); - childList.put("items", childListItems); - defs.put("ChildList", childList); - - schema.put("$defs", defs); - - Map properties = new HashMap<>(); - Map componentsProp = new HashMap<>(); - componentsProp.put("type", "array"); - - Map componentsItems = new HashMap<>(); - componentsItems.put("type", "object"); - componentsItems.put("required", Collections.singletonList("id")); - - Map itemProps = new HashMap<>(); - Map idSchema = new HashMap<>(); - idSchema.put("$ref", "#/$defs/ComponentId"); - itemProps.put("id", idSchema); - - Map componentProperties = new HashMap<>(); - componentProperties.put("type", "object"); - Map componentTypes = new HashMap<>(); - - componentTypes.put("Column", createContainerSchema()); - componentTypes.put("Row", createContainerSchema()); - componentTypes.put("Container", createContainerSchema()); - - Map cardSchema = new HashMap<>(); - cardSchema.put("type", "object"); - Map cardProps = new HashMap<>(); - Map cardChild = new HashMap<>(); - cardChild.put("$ref", "#/$defs/ComponentId"); - cardProps.put("child", cardChild); - cardSchema.put("properties", cardProps); - componentTypes.put("Card", cardSchema); - - Map buttonSchema = new HashMap<>(); - buttonSchema.put("type", "object"); - Map buttonProps = new HashMap<>(); - Map buttonChild = new HashMap<>(); - buttonChild.put("$ref", "#/$defs/ComponentId"); - buttonProps.put("child", buttonChild); - - Map actionSchema = new HashMap<>(); - Map actionProps = new HashMap<>(); - Map funcCallSchema = new HashMap<>(); - Map funcCallProps = new HashMap<>(); - funcCallProps.put("call", Map.of("type", "string")); - funcCallProps.put("args", Map.of("type", "object")); - funcCallSchema.put("properties", funcCallProps); - actionProps.put("functionCall", funcCallSchema); - actionSchema.put("properties", actionProps); - buttonProps.put("action", actionSchema); - buttonSchema.put("properties", buttonProps); - componentTypes.put("Button", buttonSchema); - - Map textSchema = new HashMap<>(); - textSchema.put("type", "object"); - Map textProps = new HashMap<>(); - Map textText = new HashMap<>(); - textText.put("oneOf", Arrays.asList(Map.of("type", "string"), Map.of("type", "object"))); - textProps.put("text", textText); - textSchema.put("properties", textProps); - componentTypes.put("Text", textSchema); - - componentProperties.put("properties", componentTypes); - itemProps.put("componentProperties", componentProperties); - - componentsItems.put("properties", itemProps); - componentsProp.put("items", componentsItems); - properties.put("components", componentsProp); - schema.put("properties", properties); - } - - private Map createContainerSchema() { - Map container = new HashMap<>(); - container.put("type", "object"); - Map props = new HashMap<>(); - Map children = new HashMap<>(); - children.put("$ref", "#/$defs/ChildList"); - props.put("children", children); - container.put("properties", props); - return container; - } - - private Map createComponent(String id, String type, Map props) { - Map comp = new HashMap<>(); - comp.put("id", id); - Map compProps = new HashMap<>(); - if (type != null) { - compProps.put(type, props); - } - comp.put("componentProperties", compProps); - return comp; - } - - @Test - void testValidateA2uiJsonValidIntegrity() { - Map payload = new HashMap<>(); - payload.put("components", Arrays.asList( - createComponent("root", "Column", Map.of("children", Collections.singletonList("child1"))), - createComponent("child1", "Text", Map.of("text", "Hello")) - )); - assertDoesNotThrow(() -> Validation.validateA2uiJson(payload, schema)); - } - - @Test - void testValidateA2uiJsonDuplicateIds() { - Map payload = new HashMap<>(); - payload.put("components", Arrays.asList( - createComponent("root", null, new HashMap<>()), - createComponent("root", null, new HashMap<>()) - )); - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> Validation.validateA2uiJson(payload, schema)); - assertTrue(exception.getMessage().contains("Duplicate component ID found: 'root'")); - } - - @Test - void testValidateA2uiJsonMissingRoot() { - Map payload = new HashMap<>(); - payload.put("components", Collections.singletonList( - createComponent("not-root", null, new HashMap<>()) - )); - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> Validation.validateA2uiJson(payload, schema)); - assertTrue(exception.getMessage().contains("Missing 'root' component")); - } - - static Stream danglingReferencesProvider() { - return Stream.of( - Arguments.of("Card", "child", "missing_child"), - Arguments.of("Column", "children", Arrays.asList("child1", "missing_child")) - ); - } - - @ParameterizedTest - @MethodSource("danglingReferencesProvider") - void testValidateA2uiJsonDanglingReferences(String componentType, String fieldName, Object idsToRef) { - Map payload = new HashMap<>(); - List> components = new ArrayList<>(); - components.add(createComponent("root", componentType, Map.of(fieldName, idsToRef))); - - if (idsToRef instanceof List) { - for (Object childIdObj : (List) idsToRef) { - String childId = (String) childIdObj; - if (!"missing_child".equals(childId)) { - components.add(createComponent(childId, null, new HashMap<>())); - } - } - } - payload.put("components", components); - - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> Validation.validateA2uiJson(payload, schema)); - assertTrue(exception.getMessage().contains("Component 'root' references missing ID 'missing_child' in field '" + fieldName + "'")); - } - - @Test - void testValidateA2uiJsonSelfReference() { - Map payload = new HashMap<>(); - payload.put("components", Collections.singletonList( - createComponent("root", "Container", Map.of("children", Collections.singletonList("root"))) - )); - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> Validation.validateA2uiJson(payload, schema)); - assertTrue(exception.getMessage().contains("Self-reference detected: Component 'root' references itself in field 'children'")); - } - - @Test - void testValidateA2uiJsonCircularReference() { - Map payload = new HashMap<>(); - payload.put("components", Arrays.asList( - createComponent("root", "Container", Map.of("children", Collections.singletonList("child1"))), - createComponent("child1", "Container", Map.of("children", Collections.singletonList("root"))) - )); - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> Validation.validateA2uiJson(payload, schema)); - assertTrue(exception.getMessage().contains("Circular reference detected involving component")); - } - - @Test - void testValidateA2uiJsonOrphanedComponent() { - Map payload = new HashMap<>(); - payload.put("components", Arrays.asList( - createComponent("root", "Container", Map.of("children", Collections.emptyList())), - createComponent("orphan", null, new HashMap<>()) - )); - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> Validation.validateA2uiJson(payload, schema)); - assertTrue(exception.getMessage().contains("Orphaned components detected (not reachable from 'root'): [orphan]")); - } - - @Test - void testValidateA2uiJsonValidTopologyComplex() { - Map payload = new HashMap<>(); - payload.put("components", Arrays.asList( - createComponent("root", "Container", Map.of("children", Arrays.asList("child1", "child2"))), - createComponent("child1", "Text", Map.of("text", "Hello")), - createComponent("child2", "Container", Map.of("children", Collections.singletonList("child3"))), - createComponent("child3", "Text", Map.of("text", "World")) - )); - assertDoesNotThrow(() -> Validation.validateA2uiJson(payload, schema)); - } - - @Test - void testValidateRecursionLimitExceeded() { - Map args = new HashMap<>(); - Map current = args; - for (int i = 0; i < 5; i++) { - Map argMap = new HashMap<>(); - argMap.put("call", "fn" + i); - Map nextArgs = new HashMap<>(); - argMap.put("args", nextArgs); - current.put("arg", argMap); - current = nextArgs; - } - - Map buttonProps = new HashMap<>(); - buttonProps.put("label", "Click me"); - buttonProps.put("action", Map.of("functionCall", Map.of("call", "fn_top", "args", args))); - - Map payload = new HashMap<>(); - payload.put("components", Collections.singletonList( - createComponent("root", "Button", buttonProps) - )); - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> Validation.validateA2uiJson(payload, schema)); - assertTrue(exception.getMessage().contains("Recursion limit exceeded")); - } - - @Test - void testValidateRecursionLimitValid() { - Map args = new HashMap<>(); - Map current = args; - for (int i = 0; i < 4; i++) { - Map argMap = new HashMap<>(); - argMap.put("call", "fn" + i); - Map nextArgs = new HashMap<>(); - argMap.put("args", nextArgs); - current.put("arg", argMap); - current = nextArgs; - } - - Map buttonProps = new HashMap<>(); - buttonProps.put("label", "Click me"); - buttonProps.put("action", Map.of("functionCall", Map.of("call", "fn_top", "args", args))); - - Map payload = new HashMap<>(); - payload.put("components", Collections.singletonList( - createComponent("root", "Button", buttonProps) - )); - assertDoesNotThrow(() -> Validation.validateA2uiJson(payload, schema)); - } - - static Stream invalidPathsProvider() { - return Stream.of( - Arguments.of(Map.of("updateDataModel", Map.of("surfaceId", "surface1", "path", "invalid//path", "value", "data"))), - Arguments.of(Map.of("components", Collections.singletonList(Map.of("id", "root", "componentProperties", Map.of("Text", Map.of("text", Map.of("path", "invalid path with spaces"))))))), - Arguments.of(Map.of("updateDataModel", Map.of("surfaceId", "surface1", "path", "/invalid/escape/~2", "value", "data"))) - ); - } - - @ParameterizedTest - @MethodSource("invalidPathsProvider") - void testValidateInvalidPaths(Map payload) { - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> Validation.validateA2uiJson(payload, schema)); - assertTrue(exception.getMessage().contains("Invalid JSON Pointer syntax")); - } - - @Test - void testValidateGlobalRecursionLimitExceeded() { - Map deepPayload = new HashMap<>(); - deepPayload.put("level", 0); - Map current = deepPayload; - for (int i = 0; i < 55; i++) { - Map next = new HashMap<>(); - next.put("level", i + 1); - current.put("next", next); - current = next; - } - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> Validation.validateA2uiJson(deepPayload, schema)); - assertTrue(exception.getMessage().contains("Global recursion limit exceeded")); - } - - @Test - void testValidateCustomSchemaReference() { - Map customSchema = new HashMap<>(schema); - - Map properties = (Map) customSchema.get("properties"); - Map componentsProp = (Map) properties.get("components"); - Map componentsItems = (Map) componentsProp.get("items"); - Map itemProps = (Map) componentsItems.get("properties"); - Map componentProperties = (Map) itemProps.get("componentProperties"); - Map componentTypes = new HashMap<>((Map) componentProperties.get("properties")); - - Map customLinkSchema = new HashMap<>(); - customLinkSchema.put("type", "object"); - Map customLinkProps = new HashMap<>(); - Map linkedComponentId = new HashMap<>(); - linkedComponentId.put("$ref", "#/$defs/ComponentId"); - customLinkProps.put("linkedComponentId", linkedComponentId); - customLinkSchema.put("properties", customLinkProps); - componentTypes.put("CustomLink", customLinkSchema); - - componentProperties.put("properties", componentTypes); - - Map payload = new HashMap<>(); - payload.put("components", Collections.singletonList( - createComponent("root", "CustomLink", Map.of("linkedComponentId", "missing_target")) - )); - - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> Validation.validateA2uiJson(payload, customSchema)); - assertTrue(exception.getMessage().contains("Component 'root' references missing ID 'missing_target' in field 'linkedComponentId'")); - } -}