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) + } +}