From fb02e66ce516673d2e2f8f6216f4dd10d4bbef3c Mon Sep 17 00:00:00 2001 From: Vsevolod Tolstopyatov Date: Wed, 11 May 2022 17:13:57 +0600 Subject: [PATCH] Incorporate JsonPath into exception messages (#1841) Fixes #1817 Fixes #1137 --- core/api/kotlinx-serialization-core.api | 4 +- .../serialization/encoding/AbstractDecoder.kt | 4 +- docs/basic-serialization.md | 8 +- .../json/internal/JsonExceptions.kt | 32 ++-- .../json/internal/JsonNamesMap.kt | 4 +- .../serialization/json/internal/JsonPath.kt | 141 +++++++++++++++ .../json/internal/StreamingJsonDecoder.kt | 42 ++++- .../json/internal/lexer/AbstractJsonLexer.kt | 16 +- .../src/kotlinx/serialization/JsonPathTest.kt | 164 ++++++++++++++++++ ...eamFlowTest.kt => JsonLazySequenceTest.kt} | 2 +- .../features/JsonSequencePathTest.kt | 41 +++++ guide/test/BasicSerializationTest.kt | 8 +- 12 files changed, 427 insertions(+), 39 deletions(-) create mode 100644 formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonPath.kt create mode 100644 formats/json/commonTest/src/kotlinx/serialization/JsonPathTest.kt rename formats/json/jvmTest/src/kotlinx/serialization/features/{JsonStreamFlowTest.kt => JsonLazySequenceTest.kt} (99%) create mode 100644 formats/json/jvmTest/src/kotlinx/serialization/features/JsonSequencePathTest.kt diff --git a/core/api/kotlinx-serialization-core.api b/core/api/kotlinx-serialization-core.api index 4aab661b6..ede49f69c 100644 --- a/core/api/kotlinx-serialization-core.api +++ b/core/api/kotlinx-serialization-core.api @@ -339,7 +339,7 @@ public abstract class kotlinx/serialization/encoding/AbstractDecoder : kotlinx/s public fun decodeFloat ()F public final fun decodeFloatElement (Lkotlinx/serialization/descriptors/SerialDescriptor;I)F public fun decodeInline (Lkotlinx/serialization/descriptors/SerialDescriptor;)Lkotlinx/serialization/encoding/Decoder; - public final fun decodeInlineElement (Lkotlinx/serialization/descriptors/SerialDescriptor;I)Lkotlinx/serialization/encoding/Decoder; + public fun decodeInlineElement (Lkotlinx/serialization/descriptors/SerialDescriptor;I)Lkotlinx/serialization/encoding/Decoder; public fun decodeInt ()I public final fun decodeIntElement (Lkotlinx/serialization/descriptors/SerialDescriptor;I)I public fun decodeLong ()J @@ -349,7 +349,7 @@ public abstract class kotlinx/serialization/encoding/AbstractDecoder : kotlinx/s public final fun decodeNullableSerializableElement (Lkotlinx/serialization/descriptors/SerialDescriptor;ILkotlinx/serialization/DeserializationStrategy;Ljava/lang/Object;)Ljava/lang/Object; public fun decodeNullableSerializableValue (Lkotlinx/serialization/DeserializationStrategy;)Ljava/lang/Object; public fun decodeSequentially ()Z - public final fun decodeSerializableElement (Lkotlinx/serialization/descriptors/SerialDescriptor;ILkotlinx/serialization/DeserializationStrategy;Ljava/lang/Object;)Ljava/lang/Object; + public fun decodeSerializableElement (Lkotlinx/serialization/descriptors/SerialDescriptor;ILkotlinx/serialization/DeserializationStrategy;Ljava/lang/Object;)Ljava/lang/Object; public fun decodeSerializableValue (Lkotlinx/serialization/DeserializationStrategy;)Ljava/lang/Object; public fun decodeSerializableValue (Lkotlinx/serialization/DeserializationStrategy;Ljava/lang/Object;)Ljava/lang/Object; public static synthetic fun decodeSerializableValue$default (Lkotlinx/serialization/encoding/AbstractDecoder;Lkotlinx/serialization/DeserializationStrategy;Ljava/lang/Object;ILjava/lang/Object;)Ljava/lang/Object; diff --git a/core/commonMain/src/kotlinx/serialization/encoding/AbstractDecoder.kt b/core/commonMain/src/kotlinx/serialization/encoding/AbstractDecoder.kt index e231d5585..8e2799c95 100644 --- a/core/commonMain/src/kotlinx/serialization/encoding/AbstractDecoder.kt +++ b/core/commonMain/src/kotlinx/serialization/encoding/AbstractDecoder.kt @@ -57,12 +57,12 @@ public abstract class AbstractDecoder : Decoder, CompositeDecoder { final override fun decodeCharElement(descriptor: SerialDescriptor, index: Int): Char = decodeChar() final override fun decodeStringElement(descriptor: SerialDescriptor, index: Int): String = decodeString() - final override fun decodeInlineElement( + override fun decodeInlineElement( descriptor: SerialDescriptor, index: Int ): Decoder = decodeInline(descriptor.getElementDescriptor(index)) - final override fun decodeSerializableElement( + override fun decodeSerializableElement( descriptor: SerialDescriptor, index: Int, deserializer: DeserializationStrategy, diff --git a/docs/basic-serialization.md b/docs/basic-serialization.md index 14a3e82de..7425be7a6 100644 --- a/docs/basic-serialization.md +++ b/docs/basic-serialization.md @@ -297,7 +297,7 @@ fun main() { It produces the exception: ```text -Exception in thread "main" kotlinx.serialization.MissingFieldException: Field 'language' is required for type with serial name 'example.exampleClasses04.Project', but it was missing +Exception in thread "main" kotlinx.serialization.MissingFieldException: Field 'language' is required for type with serial name 'example.exampleClasses04.Project', but it was missing at path: $ ``` @@ -383,7 +383,7 @@ fun main() { We get the following exception. ```text -Exception in thread "main" kotlinx.serialization.MissingFieldException: Field 'language' is required for type with serial name 'example.exampleClasses07.Project', but it was missing +Exception in thread "main" kotlinx.serialization.MissingFieldException: Field 'language' is required for type with serial name 'example.exampleClasses07.Project', but it was missing at path: $ ``` @@ -411,7 +411,7 @@ Attempts to explicitly specify its value in the serial format, even if the speci value is equal to the default one, produces the following exception. ```text -Exception in thread "main" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 42: Encountered an unknown key 'language'. +Exception in thread "main" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 42: Encountered an unknown key 'language' at path: $.name Use 'ignoreUnknownKeys = true' in 'Json {}' builder to ignore unknown keys. ``` @@ -493,7 +493,7 @@ Even though the `language` property has a default value, it is still an error to the `null` value to it. ```text -Exception in thread "main" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 52: Expected string literal but 'null' literal was found. +Exception in thread "main" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 52: Expected string literal but 'null' literal was found at path: $.language Use 'coerceInputValues = true' in 'Json {}` builder to coerce nulls to default values. ``` diff --git a/formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonExceptions.kt b/formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonExceptions.kt index 1f57de47b..d1698db2f 100644 --- a/formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonExceptions.kt +++ b/formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonExceptions.kt @@ -38,20 +38,29 @@ internal fun InvalidFloatingPointEncoded(value: Number, output: String) = JsonEn "Current output: ${output.minify()}" ) -internal fun InvalidFloatingPointEncoded(value: Number, key: String, output: String) = - JsonEncodingException(unexpectedFpErrorMessage(value, key, output)) - -internal fun InvalidFloatingPointDecoded(value: Number, key: String, output: String) = - JsonDecodingException(-1, unexpectedFpErrorMessage(value, key, output)) // Extension on JSON reader and fail immediately internal fun AbstractJsonLexer.throwInvalidFloatingPointDecoded(result: Number): Nothing { fail("Unexpected special floating-point value $result. By default, " + - "non-finite floating point values are prohibited because they do not conform JSON specification. " + - specialFlowingValuesHint - ) + "non-finite floating point values are prohibited because they do not conform JSON specification", + hint = specialFlowingValuesHint) } +@OptIn(ExperimentalSerializationApi::class) +internal fun InvalidKeyKindException(keyDescriptor: SerialDescriptor) = JsonEncodingException( + "Value of type '${keyDescriptor.serialName}' can't be used in JSON as a key in the map. " + + "It should have either primitive or enum kind, but its kind is '${keyDescriptor.kind}'.\n" + + allowStructuredMapKeysHint +) + +// Exceptions for tree-based decoder + +internal fun InvalidFloatingPointEncoded(value: Number, key: String, output: String) = + JsonEncodingException(unexpectedFpErrorMessage(value, key, output)) + +internal fun InvalidFloatingPointDecoded(value: Number, key: String, output: String) = + JsonDecodingException(-1, unexpectedFpErrorMessage(value, key, output)) + private fun unexpectedFpErrorMessage(value: Number, key: String, output: String): String { return "Unexpected special floating-point value $value with key $key. By default, " + "non-finite floating point values are prohibited because they do not conform JSON specification. " + @@ -66,13 +75,6 @@ internal fun UnknownKeyException(key: String, input: String) = JsonDecodingExcep "Current input: ${input.minify()}" ) -@OptIn(ExperimentalSerializationApi::class) -internal fun InvalidKeyKindException(keyDescriptor: SerialDescriptor) = JsonEncodingException( - "Value of type '${keyDescriptor.serialName}' can't be used in JSON as a key in the map. " + - "It should have either primitive or enum kind, but its kind is '${keyDescriptor.kind}'.\n" + - allowStructuredMapKeysHint -) - private fun CharSequence.minify(offset: Int = -1): CharSequence { if (length < 200) return this if (offset == -1) { diff --git a/formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonNamesMap.kt b/formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonNamesMap.kt index a8d289972..93e604a72 100644 --- a/formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonNamesMap.kt +++ b/formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonNamesMap.kt @@ -57,10 +57,10 @@ internal fun SerialDescriptor.getJsonNameIndex(json: Json, name: String): Int { * Throws on [CompositeDecoder.UNKNOWN_NAME] */ @OptIn(ExperimentalSerializationApi::class) -internal fun SerialDescriptor.getJsonNameIndexOrThrow(json: Json, name: String): Int { +internal fun SerialDescriptor.getJsonNameIndexOrThrow(json: Json, name: String, suffix: String = ""): Int { val index = getJsonNameIndex(json, name) if (index == CompositeDecoder.UNKNOWN_NAME) - throw SerializationException("$serialName does not contain element with name '$name'") + throw SerializationException("$serialName does not contain element with name '$name'$suffix") return index } diff --git a/formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonPath.kt b/formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonPath.kt new file mode 100644 index 000000000..4e055b234 --- /dev/null +++ b/formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonPath.kt @@ -0,0 +1,141 @@ +package kotlinx.serialization.json.internal + +import kotlinx.serialization.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.internal.* + +/** + * Internal representation of the current JSON path. + * It is stored as the array of serial descriptors (for regular classes) + * and `Any?` in case of Map keys. + * + * Example of the state when decoding the list + * ``` + * class Foo(val a: Int, val l: List) + * + * // {"l": ["a", "b", "c"] } + * + * Current path when decoding array elements: + * Foo.descriptor, List(String).descriptor + * 1 (index of the 'l'), 2 (index of currently being decoded "c") + * ``` + */ +internal class JsonPath { + + // Tombstone indicates that we are within a map, but the map key is currently being decoded. + // It is also used to overwrite a previous map key to avoid memory leaks and misattribution. + object Tombstone + + /* + * Serial descriptor, map key or the tombstone for map key + */ + private var currentObjectPath = arrayOfNulls(8) + /* + * Index is a small state-machine used to determine the state of the path: + * >=0 -> index of the element being decoded with the outer class currentObjectPath[currentDepth] + * -1 -> nested elements are not yet decoded + * -2 -> the map is being decoded and both its descriptor AND the last key were added to the path. + * + * -2 is effectively required to specify that two slots has been claimed and both should be + * cleaned up when the decoding is done. + * The cleanup is essential in order to avoid memory leaks for huge strings and structured keys. + */ + private var indicies = IntArray(8) { -1 } + private var currentDepth = -1 + + // Invoked when class is started being decoded + fun pushDescriptor(sd: SerialDescriptor) { + val depth = ++currentDepth + if (depth == currentObjectPath.size) { + resize() + } + currentObjectPath[depth] = sd + } + + // Invoked when index-th element of the current descriptor is being decoded + fun updateDescriptorIndex(index: Int) { + indicies[currentDepth] = index + } + + /* + * For maps we cannot use indicies and should use the key as an element of the path instead. + * The key can be even an object (e.g. in a case of 'allowStructuredMapKeys') where + * 'toString' is way too heavy or have side-effects. + * For that we are storing the key instead. + */ + fun updateCurrentMapKey(key: Any?) { + // idx != -2 -> this is the very first key being added + if (indicies[currentDepth] != -2 && ++currentDepth == currentObjectPath.size) { + resize() + } + currentObjectPath[currentDepth] = key + indicies[currentDepth] = -2 + } + + /** Used to indicate that we are in the process of decoding the key itself and can't specify it in path */ + fun resetCurrentMapKey() { + if (indicies[currentDepth] == -2) { + currentObjectPath[currentDepth] = Tombstone + } + } + + fun popDescriptor() { + // When we are ending map, we pop the last key and the outer field as well + val depth = currentDepth + if (indicies[depth] == -2) { + indicies[depth] = -1 + currentDepth-- + } + // Guard against top-level maps + if (currentDepth != -1) { + // No need to clean idx up as it was already cleaned by updateDescriptorIndex(DECODE_DONE) + currentDepth-- + } + } + + @OptIn(ExperimentalSerializationApi::class) + fun getPath(): String { + return buildString { + append("$") + repeat(currentDepth + 1) { + val element = currentObjectPath[it] + if (element is SerialDescriptor) { + if (element.kind == StructureKind.LIST) { + if (indicies[it] != -1) { + append("[") + append(indicies[it]) + append("]") + } + } else { + val idx = indicies[it] + // If an actual element is being decoded + if (idx >= 0) { + append(".") + append(element.getElementName(idx)) + } + } + } else if (element !== Tombstone) { + append("[") + // All non-indicies should be properly quoted by JsonPath convention + append("'") + // Else -- map key + append(element) + append("'") + append("]") + } + } + } + } + + + @OptIn(ExperimentalSerializationApi::class) + private fun prettyString(it: Any?) = (it as? SerialDescriptor)?.serialName ?: it.toString() + + private fun resize() { + val newSize = currentDepth * 2 + currentObjectPath = currentObjectPath.copyOf(newSize) + indicies = indicies.copyOf(newSize) + } + + override fun toString(): String = getPath() +} diff --git a/formats/json/commonMain/src/kotlinx/serialization/json/internal/StreamingJsonDecoder.kt b/formats/json/commonMain/src/kotlinx/serialization/json/internal/StreamingJsonDecoder.kt index 5d6b5bf66..bf2290440 100644 --- a/formats/json/commonMain/src/kotlinx/serialization/json/internal/StreamingJsonDecoder.kt +++ b/formats/json/commonMain/src/kotlinx/serialization/json/internal/StreamingJsonDecoder.kt @@ -32,12 +32,18 @@ internal open class StreamingJsonDecoder( override fun decodeJsonElement(): JsonElement = JsonTreeReader(json.configuration, lexer).read() + @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") override fun decodeSerializableValue(deserializer: DeserializationStrategy): T { - return decodeSerializableValuePolymorphic(deserializer) + try { + return decodeSerializableValuePolymorphic(deserializer) + } catch (e: MissingFieldException) { + throw MissingFieldException(e.message + " at path: " + lexer.path.getPath(), e) + } } override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder { val newMode = json.switchMode(descriptor) + lexer.path.pushDescriptor(descriptor) lexer.consumeNextToken(newMode.begin) checkLeadingComma() return when (newMode) { @@ -63,7 +69,10 @@ internal open class StreamingJsonDecoder( if (json.configuration.ignoreUnknownKeys && descriptor.elementsCount == 0) { skipLeftoverElements(descriptor) } + // First consume the object so we know it's correct lexer.consumeNextToken(mode.end) + // Then cleanup the path + lexer.path.popDescriptor() } private fun skipLeftoverElements(descriptor: SerialDescriptor) { @@ -87,12 +96,37 @@ internal open class StreamingJsonDecoder( } } + override fun decodeSerializableElement( + descriptor: SerialDescriptor, + index: Int, + deserializer: DeserializationStrategy, + previousValue: T? + ): T { + val isMapKey = mode == WriteMode.MAP && index and 1 == 0 + // Reset previous key + if (isMapKey) { + lexer.path.resetCurrentMapKey() + } + // Deserialize the key + val value = super.decodeSerializableElement(descriptor, index, deserializer, previousValue) + // Put the key to the path + if (isMapKey) { + lexer.path.updateCurrentMapKey(value) + } + return value + } + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { - return when (mode) { + val index = when (mode) { WriteMode.OBJ -> decodeObjectIndex(descriptor) WriteMode.MAP -> decodeMapIndex() else -> decodeListIndex() // Both for LIST and default polymorphic } + // The element of the next index that will be decoded + if (mode != WriteMode.MAP) { + lexer.path.updateDescriptorIndex(index) + } + return index } private fun decodeMapIndex(): Int { @@ -162,6 +196,8 @@ internal open class StreamingJsonDecoder( if (configuration.ignoreUnknownKeys) { lexer.skipElement(configuration.isLenient) } else { + // Here we cannot properly update json path indicies + // as we do not have a proper SerialDecriptor in our hands lexer.failOnUnknownKey(key) } return lexer.tryConsumeComma() @@ -262,7 +298,7 @@ internal open class StreamingJsonDecoder( else super.decodeInline(inlineDescriptor) override fun decodeEnum(enumDescriptor: SerialDescriptor): Int { - return enumDescriptor.getJsonNameIndexOrThrow(json, decodeString()) + return enumDescriptor.getJsonNameIndexOrThrow(json, decodeString(), " at path " + lexer.path.getPath()) } } diff --git a/formats/json/commonMain/src/kotlinx/serialization/json/internal/lexer/AbstractJsonLexer.kt b/formats/json/commonMain/src/kotlinx/serialization/json/internal/lexer/AbstractJsonLexer.kt index 82881ef71..5e1eee7c3 100644 --- a/formats/json/commonMain/src/kotlinx/serialization/json/internal/lexer/AbstractJsonLexer.kt +++ b/formats/json/commonMain/src/kotlinx/serialization/json/internal/lexer/AbstractJsonLexer.kt @@ -137,6 +137,9 @@ internal abstract class AbstractJsonLexer { @JvmField protected var currentPosition: Int = 0 // position in source + @JvmField + val path = JsonPath() + open fun ensureHaveChars() {} fun isNotEof(): Boolean = peekNextToken() != TC_EOF @@ -199,7 +202,7 @@ internal abstract class AbstractJsonLexer { protected fun unexpectedToken(expected: Char) { --currentPosition // To properly handle null if (currentPosition >= 0 && expected == STRING && consumeStringLenient() == NULL) { - fail("Expected string literal but 'null' literal was found.\n$coerceInputValuesHint", currentPosition - 4) + fail("Expected string literal but 'null' literal was found", currentPosition - 4, coerceInputValuesHint) } fail(charToTokenClass(expected)) } @@ -488,7 +491,7 @@ internal abstract class AbstractJsonLexer { TC_END_LIST -> { if (tokenStack.last() != TC_BEGIN_LIST) throw JsonDecodingException( currentPosition, - "found ] instead of }", + "found ] instead of } at path: $path", source ) tokenStack.removeLast() @@ -496,7 +499,7 @@ internal abstract class AbstractJsonLexer { TC_END_OBJ -> { if (tokenStack.last() != TC_BEGIN_OBJ) throw JsonDecodingException( currentPosition, - "found } instead of ]", + "found } instead of ] at path: $path", source ) tokenStack.removeLast() @@ -517,11 +520,12 @@ internal abstract class AbstractJsonLexer { // but still would like an error to point to the beginning of the key, so we are backtracking it val processed = substring(0, currentPosition) val lastIndexOf = processed.lastIndexOf(key) - fail("Encountered an unknown key '$key'.\n$ignoreUnknownKeysHint", lastIndexOf) + fail("Encountered an unknown key '$key'", lastIndexOf, ignoreUnknownKeysHint) } - fun fail(message: String, position: Int = currentPosition): Nothing { - throw JsonDecodingException(position, message, source) + fun fail(message: String, position: Int = currentPosition, hint: String = ""): Nothing { + val hintMessage = if (hint.isEmpty()) "" else "\n$hint" + throw JsonDecodingException(position, message + " at path: " + path.getPath() + hintMessage, source) } fun consumeNumericLiteral(): Long { diff --git a/formats/json/commonTest/src/kotlinx/serialization/JsonPathTest.kt b/formats/json/commonTest/src/kotlinx/serialization/JsonPathTest.kt new file mode 100644 index 000000000..8d31ba227 --- /dev/null +++ b/formats/json/commonTest/src/kotlinx/serialization/JsonPathTest.kt @@ -0,0 +1,164 @@ +/* + * Copyright 2017-2022 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.serialization + +import kotlinx.serialization.json.* +import kotlinx.serialization.test.* +import kotlin.test.* + +class JsonPathTest : JsonTestBase() { + + @Serializable + class Outer(val a: Int, val i: Inner) + + @Serializable + class Inner(val a: Int, val b: String, val c: List, val d: Map) + + @Serializable + class Box(val s: String) + + @Test + fun testBasicError() { + expectPath("$.a") { Json.decodeFromString("""{"a":foo}""") } + expectPath("$.i") { Json.decodeFromString("""{"a":42, "i":[]}""") } + expectPath("$.i.b") { Json.decodeFromString("""{"a":42, "i":{"a":43, "b":42}""") } + expectPath("$.i.b") { Json.decodeFromString("""{"a":42, "i":{"b":42}""") } + } + + @Test + fun testMissingKey() { + expectPath("$.i.d['1']") { Json.decodeFromString("""{"a":42, "i":{"d":{1:{}}""") } + } + + @Test + fun testUnknownKeyIsProperlyReported() { + expectPath("$.i") { Json.decodeFromString("""{"a":42, "i":{"foo":42}""") } + expectPath("$") { Json.decodeFromString("""{"x":{}, "a": 42}""") } + // The only place we have misattribution in + // Json.decodeFromString("""{"a":42, "x":{}}""") + } + + @Test + fun testMalformedRootObject() { + expectPath("$") { Json.decodeFromString("""{{""") } + } + + @Test + fun testArrayIndex() { + expectPath("$.i.c[1]") { Json.decodeFromString("""{"a":42, "i":{ "c": ["a", 2]}""") } + expectPath("$[2]") { Json.decodeFromString>("""["a", "2", 3]""") } + } + + @Test + fun testArrayIndexMalformedArray() { + // Also zeroes as we cannot distinguish what exactly wen wrong is such cases + expectPath("$.i.c[0]") { Json.decodeFromString("""{"a":42, "i":{ "c": [[""") } + expectPath("$[0]") { Json.decodeFromString>("""[[""") } + // But we can here + expectPath("$.i.c\n") { Json.decodeFromString("""{"a":42, "i":{ "c": {}}}""") } + expectPath("$\n") { Json.decodeFromString>("""{""") } + } + + @Test + fun testMapKey() { + expectPath("$.i.d\n") { Json.decodeFromString("""{"a":42, "i":{ "d": {"foo": {}}""") } + expectPath("$.i.d\n") { Json.decodeFromString("""{"a":42, "i":{ "d": {42: {"s":"s"}, 42.0:{}}""") } + expectPath("$\n") { Json.decodeFromString>("""{"foo":"bar"}""") } + expectPath("$\n") { Json.decodeFromString>("""{42:"bar", "foo":"bar"}""") } + expectPath("$['42']['foo']") { Json.decodeFromString>>("""{42: {"foo":"bar"}""") } + } + + @Test + fun testMalformedMap() { + expectPath("$.i.d\n") { Json.decodeFromString("""{"a":42, "i":{ "d": []""") } + expectPath("$\n") { Json.decodeFromString>("""[]""") } + } + + @Test + fun testMapValue() { + expectPath("$.i.d['42']\n") { Json.decodeFromString("""{"a":42, "i":{ "d": {42: {"xx":"bar"}}""") } + expectPath("$.i.d['43']\n") { Json.decodeFromString("""{"a":42, "i":{ "d": {42: {"s":"s"}, 43: {"xx":"bar"}}}""") } + expectPath("$['239']") { Json.decodeFromString>("""{239:bar}""") } + } + + @Serializable + class Fp(val d: Double) + + @Test + fun testInvalidFp() { + expectPath("$.d") { Json.decodeFromString("""{"d": NaN}""") } + } + + @Serializable + class EH(val e: E) + enum class E + + @Test + fun testUnknownEnum() { + expectPath("$.e") { Json.decodeFromString("""{"e": "foo"}""") } + } + + @Serializable + @SerialName("f") + sealed class Sealed { + + @Serializable + @SerialName("n") + class Nesting(val f: Sealed) : Sealed() + + @Serializable + @SerialName("b") + class Box(val s: String) : Sealed() + + @Serializable + @SerialName("d") + class DoubleNesting(val f: Sealed, val f2: Sealed) : Sealed() + } + + // TODO use non-array polymorphism when https://github.com/Kotlin/kotlinx.serialization/issues/1839 is fixed + @Test + fun testHugeNestingToCheckResize() = jvmOnly { + val json = Json { useArrayPolymorphism = true } + var outer = Sealed.Nesting(Sealed.Box("value")) + repeat(100) { + outer = Sealed.Nesting(outer) + } + val str = json.encodeToString(Sealed.serializer(), outer) + // throw-away data + json.decodeFromString(Sealed.serializer(), str) + + val malformed = str.replace("\"value\"", "42") + val expectedPath = "$" + ".value.f".repeat(101) + ".value.s" + expectPath(expectedPath) { json.decodeFromString(Sealed.serializer(), malformed) } + } + + @Test + fun testDoubleNesting() = jvmOnly { + val json = Json { useArrayPolymorphism = true } + var outer1 = Sealed.Nesting(Sealed.Box("correct")) + repeat(64) { + outer1 = Sealed.Nesting(outer1) + } + + var outer2 = Sealed.Nesting(Sealed.Box("incorrect")) + repeat(33) { + outer2 = Sealed.Nesting(outer2) + } + + val str = json.encodeToString(Sealed.serializer(), Sealed.DoubleNesting(outer1, outer2)) + // throw-away data + json.decodeFromString(Sealed.serializer(), str) + + val malformed = str.replace("\"incorrect\"", "42") + val expectedPath = "$.value.f2" + ".value.f".repeat(34) + ".value.s" + expectPath(expectedPath) { json.decodeFromString(Sealed.serializer(), malformed) } + } + + private inline fun expectPath(path: String, block: () -> Unit) { + val message = runCatching { block() } + .exceptionOrNull()!!.message!! + assertContains(message, path) + } +} diff --git a/formats/json/jvmTest/src/kotlinx/serialization/features/JsonStreamFlowTest.kt b/formats/json/jvmTest/src/kotlinx/serialization/features/JsonLazySequenceTest.kt similarity index 99% rename from formats/json/jvmTest/src/kotlinx/serialization/features/JsonStreamFlowTest.kt rename to formats/json/jvmTest/src/kotlinx/serialization/features/JsonLazySequenceTest.kt index 3de5a615f..aad9a0f51 100644 --- a/formats/json/jvmTest/src/kotlinx/serialization/features/JsonStreamFlowTest.kt +++ b/formats/json/jvmTest/src/kotlinx/serialization/features/JsonLazySequenceTest.kt @@ -18,7 +18,7 @@ import org.junit.Test import java.io.* import kotlin.test.* -class JsonStreamFlowTest { +class JsonLazySequenceTest { val json = Json private suspend inline fun Flow.writeToStream(os: OutputStream) { diff --git a/formats/json/jvmTest/src/kotlinx/serialization/features/JsonSequencePathTest.kt b/formats/json/jvmTest/src/kotlinx/serialization/features/JsonSequencePathTest.kt new file mode 100644 index 000000000..287e44388 --- /dev/null +++ b/formats/json/jvmTest/src/kotlinx/serialization/features/JsonSequencePathTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2017-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.serialization.features + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.features.sealed.SealedChild +import kotlinx.serialization.features.sealed.SealedParent +import kotlinx.serialization.json.* +import kotlinx.serialization.json.internal.JsonDecodingException +import kotlinx.serialization.test.assertFailsWithMessage +import org.junit.Test +import java.io.* +import kotlin.test.* + +class JsonSequencePathTest { + + @Serializable + class NestedData(val s: String) + + @Serializable + class Data(val data: NestedData) + + @Test + fun testFailure() { + val source = """{"data":{"s":"value"}}{"data":{"s":42}}{notevenreached}""".toStream() + val iterator = Json.decodeToSequence(source).iterator() + iterator.next() // Ignore + assertFailsWithMessage( + "Expected quotation mark '\"', but had '2' instead at path: \$.data.s" + ) { iterator.next() } + } + + private fun String.toStream() = ByteArrayInputStream(encodeToByteArray()) +} diff --git a/guide/test/BasicSerializationTest.kt b/guide/test/BasicSerializationTest.kt index 88c01857c..0024159db 100644 --- a/guide/test/BasicSerializationTest.kt +++ b/guide/test/BasicSerializationTest.kt @@ -51,7 +51,7 @@ class BasicSerializationTest { @Test fun testExampleClasses04() { captureOutput("ExampleClasses04") { example.exampleClasses04.main() }.verifyOutputLinesStart( - "Exception in thread \"main\" kotlinx.serialization.MissingFieldException: Field 'language' is required for type with serial name 'example.exampleClasses04.Project', but it was missing" + "Exception in thread \"main\" kotlinx.serialization.MissingFieldException: Field 'language' is required for type with serial name 'example.exampleClasses04.Project', but it was missing at path: $" ) } @@ -72,14 +72,14 @@ class BasicSerializationTest { @Test fun testExampleClasses07() { captureOutput("ExampleClasses07") { example.exampleClasses07.main() }.verifyOutputLinesStart( - "Exception in thread \"main\" kotlinx.serialization.MissingFieldException: Field 'language' is required for type with serial name 'example.exampleClasses07.Project', but it was missing" + "Exception in thread \"main\" kotlinx.serialization.MissingFieldException: Field 'language' is required for type with serial name 'example.exampleClasses07.Project', but it was missing at path: $" ) } @Test fun testExampleClasses08() { captureOutput("ExampleClasses08") { example.exampleClasses08.main() }.verifyOutputLinesStart( - "Exception in thread \"main\" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 42: Encountered an unknown key 'language'.", + "Exception in thread \"main\" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 42: Encountered an unknown key 'language' at path: $.name", "Use 'ignoreUnknownKeys = true' in 'Json {}' builder to ignore unknown keys." ) } @@ -101,7 +101,7 @@ class BasicSerializationTest { @Test fun testExampleClasses11() { captureOutput("ExampleClasses11") { example.exampleClasses11.main() }.verifyOutputLinesStart( - "Exception in thread \"main\" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 52: Expected string literal but 'null' literal was found.", + "Exception in thread \"main\" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 52: Expected string literal but 'null' literal was found at path: $.language", "Use 'coerceInputValues = true' in 'Json {}` builder to coerce nulls to default values." ) }