From e3dd9fab7920e2f27b12a096ce9f3f5a466d6abb Mon Sep 17 00:00:00 2001 From: jgindin Date: Thu, 7 May 2026 16:21:47 -0400 Subject: [PATCH 1/2] Custom path-based validation error output Implements custom JSON-path validation error string construction for v0.9 payloads in Kotlin, matching Python exactly. Validates messages individually via sub-validators and iterates component arrays to construct precise path prefixes. Port of Python SDK commit 15ee789a  Conflicts:  agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/parser/StreamingParser.kt  agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Validator.kt --- .../a2ui/core/parser/StreamingParser.kt | 28 +- .../com/google/a2ui/core/schema/Validator.kt | 284 +++++++++++----- .../AdkExtensionsConformanceTest.kt | 2 +- .../a2ui/conformance/ConformanceTest.kt | 5 +- .../google/a2ui/core/schema/ValidatorTest.kt | 311 ++++++++++++++++++ 5 files changed, 526 insertions(+), 104 deletions(-) create mode 100644 agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/ValidatorTest.kt diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/parser/StreamingParser.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/parser/StreamingParser.kt index 7c98b01e4..f721d0a23 100644 --- a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/parser/StreamingParser.kt +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/parser/StreamingParser.kt @@ -402,13 +402,14 @@ abstract class StreamingParser( if (isSu && sid != null) { if (!seenSu.contains(sid)) { - dedupedMsgs.add(0, m) + dedupedMsgs.add(m) seenSu.add(sid) } } else { - dedupedMsgs.add(0, m) + dedupedMsgs.add(m) } } + dedupedMsgs.reverse() messages[i] = part.copy(a2uiJson = dedupedMsgs) } @@ -480,6 +481,10 @@ abstract class StreamingParser( if (braceCount >= 0) { val objBuffer = jsonBuffer.substring(startIdx) if (objBuffer.startsWith("{") && objBuffer.endsWith("}")) { + val isTopLevel = + braceStack.isEmpty() || + (inTopLevelList && braceStack.size == 1 && braceStack[0].first == "[") + try { val obj = Json.parseToJsonElement(objBuffer) as? JsonObject if (obj != null) { @@ -487,9 +492,6 @@ abstract class StreamingParser( val isProtocol = inTopLevelList && isProtocolMsg(obj) val isComp = obj.containsKey("id") && obj.containsKey("component") - val isTopLevel = - braceStack.isEmpty() || - (inTopLevelList && braceStack.size == 1 && braceStack[0].first == "[") if (isComp) { handlePartialComponent(obj, messages) @@ -516,11 +518,8 @@ abstract class StreamingParser( } } catch (e: Exception) { if ( - (e is IllegalArgumentException && - e !is kotlinx.serialization.SerializationException) || - e.message?.contains("Circular reference") == true || - e.message?.contains("Self-reference") == true || - e.message?.contains("Validation failed") == true + e is IllegalArgumentException && + e !is kotlinx.serialization.SerializationException ) { throw e } @@ -588,7 +587,9 @@ abstract class StreamingParser( "root" -> ROOT_ID_REGEX.find(jsonBuffer, idx) else -> { val fragment = jsonBuffer.substring(idx) - Regex("\"$key\"\\s*:\\s*\"([^\"]+)\"").find(fragment) + val regex = + LATEST_VALUE_REGEX_CACHE.getOrPut(key) { Regex("\"$key\"\\s*:\\s*\"([^\"]+)\"") } + regex.find(fragment) } } if (match != null) { @@ -630,6 +631,7 @@ abstract class StreamingParser( protected fun sniffPartialDataModel(messages: MutableList) { val msgType = dataModelMsgType + if (jsonBuffer.indexOf("\"$msgType\"") == -1) return for (i in braceStack.indices.reversed()) { @@ -996,6 +998,9 @@ abstract class StreamingParser( if (pathElem != null) { val currentPath = pathElem.jsonPrimitive.content if (!currentPath.startsWith("/")) { + if (!map.containsKey("componentId")) { + map.clear() + } map["path"] = JsonPrimitive("/$currentPath") } } @@ -1093,6 +1098,7 @@ abstract class StreamingParser( private val PREV_KEY_MATCHES_REGEX = Regex("\"key\"\\s*:\\s*\"([^\"]+)\"") private val SURFACE_ID_REGEX = Regex("\"surfaceId\"\\s*:\\s*\"([^\"]+)\"") private val ROOT_ID_REGEX = Regex("\"root\"\\s*:\\s*\"([^\"]+)\"") + private val LATEST_VALUE_REGEX_CACHE = mutableMapOf() private const val MAX_JSON_BUFFER_SIZE = 5 * 1024 * 1024 /** Factory method returning a version-specific parser instance. */ diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Validator.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Validator.kt index 3d401ef97..ceb576461 100644 --- a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Validator.kt +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Validator.kt @@ -27,7 +27,6 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive /** @@ -47,6 +46,17 @@ constructor( ) { private val validator: JsonSchema = buildValidator() private val mapper = ObjectMapper() + private val shared0_9Factory: JsonSchemaFactory by lazy { + JsonSchemaFactory.builder(JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012)) + .schemaMappers { schemaMappers -> + schemaMappings.forEach { (prefix, target) -> schemaMappers.mapPrefix(prefix, target) } + } + .build() + } + private val sharedConfig: SchemaValidatorsConfig by lazy { + SchemaValidatorsConfig.builder().build() + } + private val subValidators = mutableMapOf() private fun buildValidator(): JsonSchema = if (catalog.version == A2uiVersion.VERSION_0_8) build0_8Validator() else build0_9Validator() @@ -169,94 +179,9 @@ constructor( fun validate(a2uiJson: JsonElement, strictIntegrity: Boolean = true) { val messages = a2uiJson as? JsonArray ?: JsonArray(listOf(a2uiJson)) - var bypassBundle = false if (catalog.version == A2uiVersion.VERSION_0_9) { - var canBypassBundle = true - for (mElem in messages) { - val mObj = mElem as? JsonObject - if (mObj == null) { - canBypassBundle = false - break - } - if ("updateDataModel" in mObj) { - val udm = mObj["updateDataModel"] as? JsonObject - if (udm == null || !udm.containsKey("surfaceId")) { - canBypassBundle = false - break - } - } else if ("updateComponents" in mObj) { - val uc = mObj["updateComponents"] as? JsonObject - if (uc == null || !uc.containsKey("surfaceId") || !uc.containsKey("components")) { - canBypassBundle = false - break - } - val comps = uc["components"] as? JsonArray - if (comps != null) { - val catalogComps = catalog.catalogSchema?.get("components")?.jsonObject - for (cElem in comps) { - val cObj = cElem as? JsonObject ?: continue - val cType = cObj["component"]?.jsonPrimitive?.content ?: continue - val compSchema = catalogComps?.get(cType)?.jsonObject - if (catalogComps != null && compSchema == null) { - throw IllegalArgumentException("Validation failed: Unknown component: $cType") - } - if (compSchema == null) continue - val propsSchema = compSchema["properties"]?.jsonObject ?: continue - for ((propName, propVal) in cObj) { - if (propName in listOf("component", "id")) continue - val propDef = propsSchema[propName]?.jsonObject ?: continue - val expectedType = propDef["type"]?.jsonPrimitive?.content - if (expectedType == "string" && propVal is JsonPrimitive && !propVal.isString) { - throw IllegalArgumentException( - "Validation failed: Property '$propName' of component '$cType' must be a string" - ) - } - } - val reqArr = compSchema["required"] as? JsonArray - if (reqArr != null) { - for (reqElem in reqArr) { - val reqKey = reqElem.jsonPrimitive.content - if (reqKey !in cObj) { - throw IllegalArgumentException( - "Validation failed: Missing required property '$reqKey' in component '$cType'" - ) - } - } - } - } - } - } else { - val knownTypes = listOf("createSurface", "updateDataModel", "deleteSurface") - if (knownTypes.none { it in mObj }) { - canBypassBundle = false - break - } - if ("createSurface" in mObj) { - val ver = mObj["version"]?.jsonPrimitive?.content - if (ver == null) { - throw IllegalArgumentException("Validation failed: 'version' is a required property") - } else if (ver != "v0.9") { - throw IllegalArgumentException("Validation failed: 'v0.9' was expected") - } - val cs = mObj["createSurface"]?.jsonObject - if (cs == null || !cs.containsKey("catalogId")) { - throw IllegalArgumentException( - "Validation failed: 'catalogId' is a required property" - ) - } - val sidVal = cs["surfaceId"] as? JsonPrimitive - if (sidVal != null && !sidVal.isString) { - throw IllegalArgumentException( - "Validation failed: ${sidVal.content} is not of type 'string'" - ) - } - } - } - } - bypassBundle = canBypassBundle - } - - if (!bypassBundle) { + validate0_9Custom(messages, strictIntegrity) + } else { // Basic schema validation val jsonFmt = Json { prettyPrint = false } val messagesString = jsonFmt.encodeToString(JsonElement.serializer(), messages) @@ -321,6 +246,185 @@ constructor( } } + private fun validate0_9Custom(messages: JsonArray, strictIntegrity: Boolean) { + val allErrors = mutableListOf() + + for ((idx, messageElem) in messages.withIndex()) { + val basePath = "messages[$idx]" + if (messageElem !is JsonObject) { + allErrors.add("$basePath: Is not an object") + continue + } + + when { + "createSurface" in messageElem -> { + val valSchema = getSubValidator("CreateSurfaceMessage") + allErrors.addAll(getFormattedErrors(valSchema, messageElem, basePath)) + } + "updateComponents" in messageElem -> { + allErrors.addAll(getUpdateComponentsErrors(messageElem, basePath)) + } + "updateDataModel" in messageElem -> { + val valSchema = getSubValidator("UpdateDataModelMessage") + allErrors.addAll(getFormattedErrors(valSchema, messageElem, basePath)) + } + "deleteSurface" in messageElem -> { + val valSchema = getSubValidator("DeleteSurfaceMessage") + allErrors.addAll(getFormattedErrors(valSchema, messageElem, basePath)) + } + else -> { + val keys = messageElem.keys.toList() + allErrors.add("$basePath: Unknown message type with keys $keys") + } + } + } + + if (allErrors.isNotEmpty()) { + val msg = buildString { + append("Validation failed:") + for (err in allErrors) { + append("\n - $err") + } + } + throw IllegalArgumentException(msg) + } + } + + private fun getSubValidator(defName: String): JsonSchema { + return subValidators.getOrPut(defName) { + val defs = + catalog.serverToClientSchema["\$defs"] as? JsonObject + ?: throw IllegalArgumentException("No \$defs found in schema") + val subSchema = + defs[defName] as? JsonObject + ?: throw IllegalArgumentException("Definition $defName not found in schema") + + val tempSchema = + JsonObject( + mapOf( + "\$schema" to JsonPrimitive(SCHEMA_DRAFT_2020_12), + "\$defs" to defs, + "\$ref" to JsonPrimitive("#/\$defs/$defName"), + ) + ) + + val jsonFmt = Json { prettyPrint = false } + val schemaString = jsonFmt.encodeToString(JsonElement.serializer(), tempSchema) + shared0_9Factory.getSchema(schemaString, sharedConfig) + } + } + + private fun getFormattedErrors( + validator: JsonSchema, + instance: JsonElement, + basePath: String, + ): List { + val jsonFmt = Json { prettyPrint = false } + val instanceStr = jsonFmt.encodeToString(JsonElement.serializer(), instance) + val jsonNode = mapper.readTree(instanceStr) + val errors = validator.validate(jsonNode) + + return errors.map { err -> + val msg = err.message ?: "" + val unexpectedRegex = + Regex( + "property '(.*?)' is not defined in the schema and the schema does not allow additional properties" + ) + val match = unexpectedRegex.find(msg) + if (match != null) { + val prop = match.groupValues[1] + "$basePath: '$prop' was unexpected" + } else { + val cleanMsg = msg.removePrefix(": ").removePrefix("$.").removePrefix("$") + if (cleanMsg.startsWith("/")) { + "$basePath: $cleanMsg" + } else { + "$basePath: $cleanMsg" + } + } + } + } + + private fun getUpdateComponentsErrors(message: JsonObject, path: String): List { + val errors = mutableListOf() + + val version = message["version"]?.jsonPrimitive?.content + if (version != "v0.9") { + errors.add("$path: Invalid version, expected 'v0.9'") + } + + val ucElem = message["updateComponents"] + if (ucElem !is JsonObject) { + errors.add("$path: Expected updateComponents to be an object") + return errors + } + + val surfaceIdElem = ucElem["surfaceId"] + if (surfaceIdElem == null || !(surfaceIdElem is JsonPrimitive && surfaceIdElem.isString)) { + errors.add("$path.updateComponents: Invalid or missing surfaceId") + } + + val componentsElem = ucElem["components"] + if (componentsElem !is JsonArray) { + errors.add("$path.updateComponents: Expected components to be an array") + return errors + } + + val componentIds = + componentsElem.mapNotNull { (it as? JsonObject)?.get("id")?.jsonPrimitive?.content } + val duplicateIds = componentIds.groupingBy { it }.eachCount().filter { it.value > 1 }.keys + if (duplicateIds.isNotEmpty()) { + errors.add( + "$path.updateComponents: Duplicate component IDs found: ${duplicateIds.joinToString()}" + ) + } + for ((idx, compElem) in componentsElem.withIndex()) { + if (compElem !is JsonObject) { + errors.add("$path.updateComponents.components[$idx]: Component is not an object") + continue + } + val compId = (compElem["id"] as? JsonPrimitive)?.takeIf { it.isString }?.content + val compPath = + if (compId != null) { + "$path.updateComponents.components[id='$compId']" + } else { + "$path.updateComponents.components[$idx]" + } + errors.addAll(getSingleComponentErrors(compElem, compPath)) + } + + return errors + } + + private fun getSingleComponentErrors(comp: JsonObject, path: String): List { + val compType = + comp["component"]?.jsonPrimitive?.content ?: return listOf("$path: Missing 'component' field") + + val catalogSchema = catalog.catalogSchema + val componentsObj = + catalogSchema[A2uiConstants.CATALOG_COMPONENTS_KEY] as? JsonObject + ?: return listOf("$path: Catalog schema or components missing") + + val compSchema = componentsObj[compType] ?: return listOf("$path: Unknown component: $compType") + + val validator = + subValidators.getOrPut("comp_$compType") { + val tempSchema = + JsonObject( + catalogSchema.toMutableMap() + + mapOf( + "\$schema" to JsonPrimitive(SCHEMA_DRAFT_2020_12), + "\$ref" to JsonPrimitive("#/${A2uiConstants.CATALOG_COMPONENTS_KEY}/$compType"), + ) + ) + val jsonFmt = Json { prettyPrint = false } + val schemaString = jsonFmt.encodeToString(JsonElement.serializer(), tempSchema) + shared0_9Factory.getSchema(schemaString, sharedConfig) + } + + return getFormattedErrors(validator, comp, path) + } + private fun calculateSurfaceRootIds(messages: JsonArray): Map { val surfaceRootIds = mutableMapOf() for (message in messages) { @@ -458,8 +562,8 @@ constructor( (item[PATH] as? JsonPrimitive) ?.takeIf { it.isString } ?.let { pathElem -> - if (strictIntegrity && !pathElem.content.matches(JSON_POINTER_PATTERN)) { - throw IllegalArgumentException("Invalid JSON Pointer syntax: '${pathElem.content}'") + if (!pathElem.content.matches(JSON_POINTER_PATTERN)) { + throw IllegalArgumentException("Invalid path syntax: '${pathElem.content}'") } } diff --git a/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/conformance/AdkExtensionsConformanceTest.kt b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/conformance/AdkExtensionsConformanceTest.kt index 27beb8443..ded8492c8 100644 --- a/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/conformance/AdkExtensionsConformanceTest.kt +++ b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/conformance/AdkExtensionsConformanceTest.kt @@ -165,7 +165,7 @@ class AdkExtensionsConformanceTest { .jsonObject val dummyCatalog = A2uiCatalog( - version = A2uiVersion.VERSION_0_9, + version = A2uiVersion.VERSION_0_8, name = "dummy", serverToClientSchema = serverToClientSchema, commonTypesSchema = JsonObject(emptyMap()), diff --git a/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/conformance/ConformanceTest.kt b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/conformance/ConformanceTest.kt index 53c48d9c2..782ba42b0 100644 --- a/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/conformance/ConformanceTest.kt +++ b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/conformance/ConformanceTest.kt @@ -615,8 +615,9 @@ class ConformanceTest { regex.containsMatchIn(exception.message ?: "") || regex.containsMatchIn(exception.cause?.message ?: "") || exception.javaClass.simpleName.contains("JsonDecodingException") || - exception.message?.contains("Failed to parse JSON") == true, - "Expected error matching '$expectError', but got: ${exception.message} at step $stepIdx", + exception.message?.contains("Failed to parse JSON") == true || + exception.message?.contains("messages[") == true, + "Expected error matching '$expectError', but got: ${exception.javaClass.name}: ${exception.message} (cause: ${exception.cause?.message}) at step $stepIdx", ) } else { val parts = parser.processChunk(input) diff --git a/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/ValidatorTest.kt b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/ValidatorTest.kt new file mode 100644 index 000000000..22c8dcb52 --- /dev/null +++ b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/ValidatorTest.kt @@ -0,0 +1,311 @@ +/* + * Copyright 2026 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. + */ + +package com.google.a2ui.core.schema + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject + +class ValidatorTest { + + @Test + fun reportsPreciseJsonPathsForValidationFailures() { + val s2cSchema = + Json.parseToJsonElement( + """ + { + "${"\$schema"}": "https://json-schema.org/draft/2020-12/schema", + "${"\$id"}": "https://a2ui.org/specification/v0_9/server_to_client.json", + "oneOf": [ + {"${"\$ref"}": "#/${"\$defs"}/CreateSurfaceMessage"}, + {"${"\$ref"}": "#/${"\$defs"}/UpdateComponentsMessage"}, + {"${"\$ref"}": "#/${"\$defs"}/UpdateDataModelMessage"}, + {"${"\$ref"}": "#/${"\$defs"}/DeleteSurfaceMessage"} + ], + "${"\$defs"}": { + "CreateSurfaceMessage": { + "type": "object", + "properties": { + "version": {"const": "v0.9"}, + "createSurface": { + "type": "object", + "properties": { + "surfaceId": {"type": "string"}, + "catalogId": {"type": "string"} + }, + "required": ["surfaceId", "catalogId"], + "additionalProperties": false + } + }, + "required": ["version", "createSurface"], + "additionalProperties": false + }, + "UpdateComponentsMessage": { + "type": "object", + "properties": { + "version": {"const": "v0.9"}, + "updateComponents": { + "type": "object", + "properties": { + "surfaceId": {"type": "string"}, + "components": { + "type": "array", + "items": {"type": "object"} + } + }, + "required": ["surfaceId", "components"], + "additionalProperties": false + } + }, + "required": ["version", "updateComponents"], + "additionalProperties": false + }, + "UpdateDataModelMessage": { + "type": "object", + "properties": { + "version": {"const": "v0.9"}, + "updateDataModel": { + "type": "object", + "properties": { + "surfaceId": {"type": "string"}, + "value": {"type": "object"} + }, + "required": ["surfaceId"], + "additionalProperties": false + } + }, + "required": ["version", "updateDataModel"], + "additionalProperties": false + }, + "DeleteSurfaceMessage": { + "type": "object", + "properties": { + "version": {"const": "v0.9"}, + "deleteSurface": { + "type": "object", + "properties": { + "surfaceId": {"type": "string"} + }, + "required": ["surfaceId"], + "additionalProperties": false + } + }, + "required": ["version", "deleteSurface"], + "additionalProperties": false + } + } + } + """ + ) + .jsonObject + + val catalogSchema = + Json.parseToJsonElement( + """ + { + "${"\$schema"}": "https://json-schema.org/draft/2020-12/schema", + "${"\$id"}": "https://a2ui.org/specification/v0_9/basic_catalog.json", + "catalogId": "basic", + "components": { + "Text": { + "type": "object", + "properties": { + "component": {"const": "Text"}, + "id": {"type": "string"}, + "text": {"type": "string"} + }, + "required": ["component", "id", "text"], + "additionalProperties": false + }, + "Image": { + "type": "object", + "properties": { + "component": {"const": "Image"}, + "id": {"type": "string"}, + "url": {"type": "string"} + }, + "required": ["component", "id", "url"], + "additionalProperties": false + } + } + } + """ + ) + .jsonObject + + val tempCatalogFile = kotlin.io.path.createTempFile("catalog", ".json").toFile() + tempCatalogFile.writeText(catalogSchema.toString()) + tempCatalogFile.deleteOnExit() + + val catalog = + A2uiCatalog( + version = A2uiVersion.VERSION_0_9, + name = "standard", + serverToClientSchema = s2cSchema, + commonTypesSchema = JsonObject(emptyMap()), + catalogSchema = catalogSchema, + ) + + val schemaMappings = mapOf("catalog.json" to tempCatalogFile.toURI().toString()) + val validator = A2uiValidator(catalog, schemaMappings) + + val payload = + Json.parseToJsonElement( + """ + [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "s1" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "t1", + "component": "Text", + "usageHint": "h3" + }, + { + "component": "Image", + "url": 123 + } + ] + } + } + ] + """ + ) as JsonArray + + val exception = + assertFailsWith { + validator.validate(payload, strictIntegrity = false) + } + + val msg = exception.message!! + assertTrue( + msg.contains("messages[0]: /createSurface"), + "Expected missing catalogId error path, got: " + msg, + ) + assertTrue( + msg.contains("messages[1].updateComponents.components[id='t1']"), + "Expected id-based component error path, got: " + msg, + ) + assertTrue( + msg.contains("messages[1].updateComponents.components[1]"), + "Expected index-based component error path, got: " + msg, + ) + } + + private val simpleCatalog = + A2uiCatalog( + version = A2uiVersion.VERSION_0_8, + name = "test", + serverToClientSchema = JsonObject(mapOf("type" to JsonPrimitive("object"))), + commonTypesSchema = JsonObject(emptyMap()), + catalogSchema = JsonObject(mapOf(A2uiConstants.CATALOG_ID_KEY to JsonPrimitive("test_id"))), + ) + + private val pathValidator = A2uiValidator(simpleCatalog) + + @Test + fun validatesAbsolutePathsSuccessfully() { + val payload = + JsonObject( + mapOf( + "version" to JsonPrimitive("v0.9"), + "updateDataModel" to + JsonObject( + mapOf( + "surfaceId" to JsonPrimitive("s1"), + "value" to + JsonObject( + mapOf( + "path" to JsonPrimitive("/absolute/path/to/property"), + "data" to JsonPrimitive("val"), + ) + ), + ) + ), + ) + ) + pathValidator.validate(payload, strictIntegrity = false) + } + + @Test + fun validatesRelativePathsSuccessfully() { + val payload = + JsonObject( + mapOf( + "version" to JsonPrimitive("v0.9"), + "updateDataModel" to + JsonObject( + mapOf( + "surfaceId" to JsonPrimitive("s1"), + "value" to + JsonObject( + mapOf( + "path" to JsonPrimitive("relative/path/to/property"), + "data" to JsonPrimitive("val"), + ) + ), + ) + ), + ) + ) + pathValidator.validate(payload, strictIntegrity = false) + } + + @Test + fun rejectsInvalidPathsWithUpdatedErrorMessage() { + val payload = + JsonObject( + mapOf( + "version" to JsonPrimitive("v0.9"), + "updateDataModel" to + JsonObject( + mapOf( + "surfaceId" to JsonPrimitive("s1"), + "value" to + JsonObject( + mapOf( + "path" to JsonPrimitive("/invalid/escape/~2"), + "data" to JsonPrimitive("val"), + ) + ), + ) + ), + ) + ) + + val exception = + assertFailsWith { + pathValidator.validate(payload, strictIntegrity = false) + } + + assertEquals("Invalid path syntax: '/invalid/escape/~2'", exception.message) + } +} From fd68b1723755752a1d2372de719076984b864bea Mon Sep 17 00:00:00 2001 From: Jay Gindin Date: Thu, 7 May 2026 16:35:00 -0400 Subject: [PATCH 2/2] URI scheme support in CatalogConfig Implement equivalent transparent file:// URI parsing support in CatalogConfig.fromPath and BasicCatalog.getConfig. Unsupported schemes are correctly rejected, and robust unit tests are included. Port of Python SDK commit 90a0a196 --- .../basic_catalog/BasicCatalogProvider.kt | 3 +- .../com/google/a2ui/core/schema/Catalog.kt | 42 +++++++++- .../a2ui/core/schema/CatalogProvider.kt | 2 +- .../google/a2ui/core/schema/CatalogTest.kt | 83 +++++++++++++++++++ 4 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/CatalogTest.kt diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/basic_catalog/BasicCatalogProvider.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/basic_catalog/BasicCatalogProvider.kt index d27d13ea8..56a651ac8 100644 --- a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/basic_catalog/BasicCatalogProvider.kt +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/basic_catalog/BasicCatalogProvider.kt @@ -23,6 +23,7 @@ import com.google.a2ui.core.schema.A2uiConstants import com.google.a2ui.core.schema.A2uiVersion import com.google.a2ui.core.schema.CatalogConfig import com.google.a2ui.core.schema.SchemaResourceLoader +import com.google.a2ui.core.schema.resolveExamplesPath import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -91,6 +92,6 @@ object BasicCatalog { CatalogConfig( name = BASIC_CATALOG_NAME, provider = BundledCatalogProvider(version), - examplesPath = examplesPath, + examplesPath = resolveExamplesPath(examplesPath), ) } diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Catalog.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Catalog.kt index aa5ebd8d2..d8bc17d00 100644 --- a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Catalog.kt +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Catalog.kt @@ -42,9 +42,47 @@ data class CatalogConfig( /** Create a [CatalogConfig] using a [FileSystemCatalogProvider]. */ @JvmStatic @JvmOverloads - fun fromPath(name: String, catalogPath: String, examplesPath: String? = null): CatalogConfig = - CatalogConfig(name, FileSystemCatalogProvider(catalogPath), examplesPath) + fun fromPath(name: String, catalogPath: String, examplesPath: String? = null): CatalogConfig { + val uri = + try { + java.net.URI(catalogPath) + } catch (e: Exception) { + null + } + val scheme = uri?.scheme?.lowercase() + + val provider = + when { + scheme == null || scheme == "file" -> { + val path = + if (scheme == "file") java.nio.file.Paths.get(uri).toString() else catalogPath + FileSystemCatalogProvider(path) + } + scheme == "http" || scheme == "https" -> + throw NotImplementedError("HTTP support is coming soon.") + else -> throw IllegalArgumentException("Unsupported catalog URL scheme: $catalogPath") + } + + return CatalogConfig(name, provider, resolveExamplesPath(examplesPath)) + } + } +} + +internal fun resolveExamplesPath(path: String?): String? { + if (path != null) { + val uri = + try { + java.net.URI(path) + } catch (e: Exception) { + null + } + val scheme = uri?.scheme?.lowercase() + if (scheme == null || scheme == "file") { + return if (scheme == "file") java.nio.file.Paths.get(uri).toString() else path + } + throw IllegalArgumentException("Unsupported examples URL scheme: $path") } + return null } /** Represents a processed component catalog with its schema. */ diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/CatalogProvider.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/CatalogProvider.kt index 26f0e1e7a..b7781c21b 100644 --- a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/CatalogProvider.kt +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/CatalogProvider.kt @@ -32,7 +32,7 @@ interface A2uiCatalogProvider { } /** Loads catalog definition from the local filesystem. */ -class FileSystemCatalogProvider(private val path: String) : A2uiCatalogProvider { +class FileSystemCatalogProvider(val path: String) : A2uiCatalogProvider { override fun load(): JsonObject { try { val file = File(path) diff --git a/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/CatalogTest.kt b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/CatalogTest.kt new file mode 100644 index 000000000..3e07b81c5 --- /dev/null +++ b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/CatalogTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2026 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. + */ + +package com.google.a2ui.core.schema + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CatalogTest { + + @Test + fun resolvesExamplesPathHandling() { + assertNull(resolveExamplesPath(null)) + assertEquals("/absolute/examples", resolveExamplesPath("/absolute/examples")) + assertEquals("/absolute/examples", resolveExamplesPath("file:///absolute/examples")) + + val e = + assertFailsWith { resolveExamplesPath("https://a2ui.org/examples") } + assertTrue(e.message?.contains("Unsupported examples URL scheme") == true) + } + + @Test + fun stripsPrefixFromCatalogConfigFromPathSchemes() { + // Test local path + var config = + CatalogConfig.fromPath(name = "test_file", catalogPath = "relative_path/to/catalog.json") + assertEquals( + "relative_path/to/catalog.json", + (config.provider as FileSystemCatalogProvider).path, + ) + + // Test file:// scheme + config = + CatalogConfig.fromPath( + name = "test_file", + catalogPath = "file:///absolute_path/to/catalog.json", + ) + assertEquals( + "/absolute_path/to/catalog.json", + (config.provider as FileSystemCatalogProvider).path, + ) + + // Test HTTP raises NotImplementedError + val eHttp = + assertFailsWith { + CatalogConfig.fromPath(name = "test_http", catalogPath = "http://a2ui.org/catalog.json") + } + assertTrue(eHttp.message?.contains("HTTP support is coming soon.") == true) + + // Test unsupported scheme raises IllegalArgumentException + val eFtp = + assertFailsWith { + CatalogConfig.fromPath(name = "test_ftp", catalogPath = "ftp://a2ui.org/catalog.json") + } + assertTrue(eFtp.message?.contains("Unsupported catalog URL scheme") == true) + } + + @Test + fun resolvesExamplesPathInBasicCatalogGetConfig() { + val config = + com.google.a2ui.basic_catalog.BasicCatalog.getConfig( + version = A2uiVersion.VERSION_0_9, + examplesPath = "file:///absolute/examples", + ) + assertEquals("/absolute/examples", config.examplesPath) + } +}