diff --git a/core/commonMain/src/kotlinx/serialization/internal/AbstractPolymorphicSerializer.kt b/core/commonMain/src/kotlinx/serialization/internal/AbstractPolymorphicSerializer.kt index 8604bbc94..26d3b5e27 100644 --- a/core/commonMain/src/kotlinx/serialization/internal/AbstractPolymorphicSerializer.kt +++ b/core/commonMain/src/kotlinx/serialization/internal/AbstractPolymorphicSerializer.kt @@ -58,8 +58,8 @@ public abstract class AbstractPolymorphicSerializer internal constructo } else -> throw SerializationException( "Invalid index in polymorphic deserialization of " + - (klassName ?: "unknown class") + - "\n Expected 0, 1 or DECODE_DONE(-1), but found $index" + (klassName ?: "unknown class") + + "\n Expected 0, 1 or DECODE_DONE(-1), but found $index" ) } } @@ -98,14 +98,14 @@ public abstract class AbstractPolymorphicSerializer internal constructo @JvmName("throwSubtypeNotRegistered") internal fun throwSubtypeNotRegistered(subClassName: String?, baseClass: KClass<*>): Nothing { - val scope = "in the scope of '${baseClass.simpleName}'" + val scope = "in the polymorphic scope of '${baseClass.simpleName}'" throw SerializationException( if (subClassName == null) - "Class discriminator was missing and no default polymorphic serializers were registered $scope" + "Class discriminator was missing and no default serializers were registered $scope." else - "Class '$subClassName' is not registered for polymorphic serialization $scope.\n" + - "To be registered automatically, class '$subClassName' has to be '@Serializable', and the base class '${baseClass.simpleName}' has to be sealed and '@Serializable'.\n" + - "Alternatively, register the serializer for '$subClassName' explicitly in a corresponding SerializersModule." + "Serializer for subclass '$subClassName' is not found $scope.\n" + + "Check if class with serial name '$subClassName' exists and serializer is registered in a corresponding SerializersModule.\n" + + "To be registered automatically, class '$subClassName' has to be '@Serializable', and the base class '${baseClass.simpleName}' has to be sealed and '@Serializable'." ) } diff --git a/docs/polymorphism.md b/docs/polymorphism.md index 29d023b67..b7ea31f4b 100644 --- a/docs/polymorphism.md +++ b/docs/polymorphism.md @@ -123,9 +123,9 @@ fun main() { This is close to the best design for a serializable hierarchy of classes, but running it produces the following error: ```text -Exception in thread "main" kotlinx.serialization.SerializationException: Class 'OwnedProject' is not registered for polymorphic serialization in the scope of 'Project'. +Exception in thread "main" kotlinx.serialization.SerializationException: Serializer for subclass 'OwnedProject' is not found in the polymorphic scope of 'Project'. +Check if class with serial name 'OwnedProject' exists and serializer is registered in a corresponding SerializersModule. To be registered automatically, class 'OwnedProject' has to be '@Serializable', and the base class 'Project' has to be sealed and '@Serializable'. -Alternatively, register the serializer for 'OwnedProject' explicitly in a corresponding SerializersModule. ``` @@ -832,7 +832,8 @@ fun main() { We get the following exception. ```text -Exception in thread "main" kotlinx.serialization.json.internal.JsonDecodingException: Polymorphic serializer was not found for class discriminator 'unknown' +Exception in thread "main" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 0: Serializer for subclass 'unknown' is not found in the polymorphic scope of 'Project' at path: $ +Check if class with serial name 'unknown' exists and serializer is registered in a corresponding SerializersModule. ``` diff --git a/formats/json-tests/commonTest/src/kotlinx/serialization/features/PolymorphicDeserializationErrorMessagesTest.kt b/formats/json-tests/commonTest/src/kotlinx/serialization/features/PolymorphicDeserializationErrorMessagesTest.kt new file mode 100644 index 000000000..2b2f1f702 --- /dev/null +++ b/formats/json-tests/commonTest/src/kotlinx/serialization/features/PolymorphicDeserializationErrorMessagesTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2017-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.serialization.features + +import kotlinx.serialization.* +import kotlinx.serialization.json.* +import kotlin.test.* + +class PolymorphicDeserializationErrorMessagesTest : JsonTestBase() { + @Serializable + class DummyData(@Polymorphic val a: Any) + + @Serializable + class Holder(val d: DummyData) + + // TODO: remove this after #2480 is merged + private fun checkSerializationException(action: () -> Unit, assertions: SerializationException.(String) -> Unit) { + val e = assertFailsWith(SerializationException::class, action) + assertNotNull(e.message) + e.assertions(e.message!!) + } + + @Test + fun testNotRegisteredMessage() = parametrizedTest { mode -> + val input = """{"d":{"a":{"type":"my.Class", "value":42}}}""" + checkSerializationException({ + default.decodeFromString(input, mode) + }, { message -> + // ReaderJsonLexer.peekLeadingMatchingValue is not implemented, so first-key optimization is not working for streaming yet. + if (mode == JsonTestingMode.STREAMING) + assertContains(message, "Unexpected JSON token at offset 10: Serializer for subclass 'my.Class' is not found in the polymorphic scope of 'Any' at path: \$.d.a") + else + assertContains(message, "Serializer for subclass 'my.Class' is not found in the polymorphic scope of 'Any'") + }) + } + + @Test + fun testDiscriminatorMissingNoDefaultMessage() = parametrizedTest { mode -> + val input = """{"d":{"a":{"value":42}}}""" + checkSerializationException({ + default.decodeFromString(input, mode) + }, { message -> + // Always slow path when discriminator is missing, so no position and path + assertContains(message, "Class discriminator was missing and no default serializers were registered in the polymorphic scope of 'Any'") + }) + } + + @Test + fun testClassDiscriminatorIsNull() = parametrizedTest { mode -> + val input = """{"d":{"a":{"type":null, "value":42}}}""" + checkSerializationException({ + default.decodeFromString(input, mode) + }, { message -> + // Always slow path when discriminator is missing, so no position and path + assertContains(message, "Class discriminator was missing and no default serializers were registered in the polymorphic scope of 'Any'") + }) + } +} diff --git a/formats/json-tests/commonTest/src/kotlinx/serialization/features/PolymorphismWithAnyTest.kt b/formats/json-tests/commonTest/src/kotlinx/serialization/features/PolymorphismWithAnyTest.kt index e1d38fdd5..07b6e31ad 100644 --- a/formats/json-tests/commonTest/src/kotlinx/serialization/features/PolymorphismWithAnyTest.kt +++ b/formats/json-tests/commonTest/src/kotlinx/serialization/features/PolymorphismWithAnyTest.kt @@ -5,13 +5,13 @@ package kotlinx.serialization.features import kotlinx.serialization.* -import kotlinx.serialization.json.Json +import kotlinx.serialization.json.* import kotlinx.serialization.modules.* import kotlinx.serialization.modules.plus import kotlinx.serialization.test.assertStringFormAndRestored import kotlin.test.* -class PolymorphismWithAnyTest { +class PolymorphismWithAnyTest: JsonTestBase() { @Serializable data class MyPolyData(val data: Map) @@ -28,19 +28,20 @@ class PolymorphismWithAnyTest { val className = className.substringAfterLast('.') val scopeName = scopeName.substringAfterLast('.') val expectedText = - "Class '$className' is not registered for polymorphic serialization in the scope of '$scopeName'" + "Serializer for subclass '$className' is not found in the polymorphic scope of '$scopeName'" assertTrue(exception.message!!.startsWith(expectedText), "Found $exception, but expected to start with: $expectedText") } @Test - fun testFailWithoutModulesWithCustomClass() { + fun testFailWithoutModulesWithCustomClass() = parametrizedTest { mode -> checkNotRegisteredMessage( "kotlinx.serialization.IntData", "kotlin.Any", assertFailsWith("not registered") { Json.encodeToString( MyPolyData.serializer(), - MyPolyData(mapOf("a" to IntData(42))) + MyPolyData(mapOf("a" to IntData(42))), + mode ) } ) @@ -51,11 +52,11 @@ class PolymorphismWithAnyTest { val json = Json { serializersModule = SerializersModule { polymorphic(Any::class) { subclass(IntData.serializer()) } } } - assertStringFormAndRestored( + assertJsonFormAndRestored( expected = """{"data":{"a":{"type":"kotlinx.serialization.IntData","intV":42}}}""", - original = MyPolyData(mapOf("a" to IntData(42))), + data = MyPolyData(mapOf("a" to IntData(42))), serializer = MyPolyData.serializer(), - format = json + json = json ) } @@ -63,14 +64,15 @@ class PolymorphismWithAnyTest { * This test should fail because PolyDerived registered in the scope of PolyBase, not kotlin.Any */ @Test - fun testFailWithModulesNotInAnyScope() { + fun testFailWithModulesNotInAnyScope() = parametrizedTest { mode -> val json = Json { serializersModule = BaseAndDerivedModule } checkNotRegisteredMessage( "kotlinx.serialization.PolyDerived", "kotlin.Any", assertFailsWith { json.encodeToString( MyPolyData.serializer(), - MyPolyData(mapOf("a" to PolyDerived("foo"))) + MyPolyData(mapOf("a" to PolyDerived("foo"))), + mode ) } ) @@ -86,11 +88,11 @@ class PolymorphismWithAnyTest { @Test fun testRebindModules() { val json = Json { serializersModule = baseAndDerivedModuleAtAny } - assertStringFormAndRestored( + assertJsonFormAndRestored( expected = """{"data":{"a":{"type":"kotlinx.serialization.PolyDerived","id":1,"s":"foo"}}}""", - original = MyPolyData(mapOf("a" to PolyDerived("foo"))), + data = MyPolyData(mapOf("a" to PolyDerived("foo"))), serializer = MyPolyData.serializer(), - format = json + json = json ) } @@ -98,7 +100,7 @@ class PolymorphismWithAnyTest { * This test should fail because PolyDerived registered in the scope of kotlin.Any, not PolyBase */ @Test - fun testFailWithModulesNotInParticularScope() { + fun testFailWithModulesNotInParticularScope() = parametrizedTest { mode -> val json = Json { serializersModule = baseAndDerivedModuleAtAny } checkNotRegisteredMessage( "kotlinx.serialization.PolyDerived", "kotlinx.serialization.PolyBase", @@ -108,7 +110,8 @@ class PolymorphismWithAnyTest { MyPolyDataWithPolyBase( mapOf("a" to PolyDerived("foo")), PolyDerived("foo") - ) + ), + mode ) } ) @@ -117,17 +120,30 @@ class PolymorphismWithAnyTest { @Test fun testBindModules() { val json = Json { serializersModule = (baseAndDerivedModuleAtAny + BaseAndDerivedModule) } - assertStringFormAndRestored( + assertJsonFormAndRestored( expected = """{"data":{"a":{"type":"kotlinx.serialization.PolyDerived","id":1,"s":"foo"}}, |"polyBase":{"type":"kotlinx.serialization.PolyDerived","id":1,"s":"foo"}}""".trimMargin().lines().joinToString( "" ), - original = MyPolyDataWithPolyBase( + data = MyPolyDataWithPolyBase( mapOf("a" to PolyDerived("foo")), PolyDerived("foo") ), serializer = MyPolyDataWithPolyBase.serializer(), - format = json + json = json ) } + + @Test + fun testTypeKeyLastInInput() = parametrizedTest { mode -> + val json = Json { serializersModule = (baseAndDerivedModuleAtAny + BaseAndDerivedModule) } + val input = """{"data":{"a":{"id":1,"s":"foo","type":"kotlinx.serialization.PolyDerived"}}, + |"polyBase":{"id":1,"s":"foo","type":"kotlinx.serialization.PolyDerived"}}""".trimMargin().lines().joinToString( + "") + val data = MyPolyDataWithPolyBase( + mapOf("a" to PolyDerived("foo")), + PolyDerived("foo") + ) + assertEquals(data, json.decodeFromString(MyPolyDataWithPolyBase.serializer(), input, mode)) + } } 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 ad5011a93..cd97eb486 100644 --- a/formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonExceptions.kt +++ b/formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonExceptions.kt @@ -75,7 +75,7 @@ internal fun UnknownKeyException(key: String, input: String) = JsonDecodingExcep "Current input: ${input.minify()}" ) -private fun CharSequence.minify(offset: Int = -1): CharSequence { +internal fun CharSequence.minify(offset: Int = -1): CharSequence { if (length < 200) return this if (offset == -1) { val start = this.length - 60 diff --git a/formats/json/commonMain/src/kotlinx/serialization/json/internal/Polymorphic.kt b/formats/json/commonMain/src/kotlinx/serialization/json/internal/Polymorphic.kt index c1c91264f..bd658fc12 100644 --- a/formats/json/commonMain/src/kotlinx/serialization/json/internal/Polymorphic.kt +++ b/formats/json/commonMain/src/kotlinx/serialization/json/internal/Polymorphic.kt @@ -63,20 +63,15 @@ internal fun JsonDecoder.decodeSerializableValuePolymorphic(deserializer: De val discriminator = deserializer.descriptor.classDiscriminator(json) val jsonTree = cast(decodeJsonElement(), deserializer.descriptor) - val type = jsonTree[discriminator]?.jsonPrimitive?.content - val actualSerializer = deserializer.findPolymorphicSerializerOrNull(this, type) - ?: throwSerializerNotFound(type, jsonTree) - + val type = jsonTree[discriminator]?.jsonPrimitive?.contentOrNull // differentiate between `"type":"null"` and `"type":null`. @Suppress("UNCHECKED_CAST") - return json.readPolymorphicJson(discriminator, jsonTree, actualSerializer as DeserializationStrategy) -} - -@JvmName("throwSerializerNotFound") -internal fun throwSerializerNotFound(type: String?, jsonTree: JsonObject): Nothing { - val suffix = - if (type == null) "missing class discriminator ('null')" - else "class discriminator '$type'" - throw JsonDecodingException(-1, "Polymorphic serializer was not found for $suffix", jsonTree.toString()) + val actualSerializer = + try { + deserializer.findPolymorphicSerializer(this, type) + } catch (it: SerializationException) { // Wrap SerializationException into JsonDecodingException to preserve input + throw JsonDecodingException(-1, it.message!!, jsonTree.toString()) + } as DeserializationStrategy + return json.readPolymorphicJson(discriminator, jsonTree, actualSerializer) } internal fun SerialDescriptor.classDiscriminator(json: Json): String { 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 4e373f1fb..189ce9470 100644 --- a/formats/json/commonMain/src/kotlinx/serialization/json/internal/StreamingJsonDecoder.kt +++ b/formats/json/commonMain/src/kotlinx/serialization/json/internal/StreamingJsonDecoder.kt @@ -71,19 +71,22 @@ internal open class StreamingJsonDecoder( val discriminator = deserializer.descriptor.classDiscriminator(json) val type = lexer.peekLeadingMatchingValue(discriminator, configuration.isLenient) - var actualSerializer: DeserializationStrategy? = null - if (type != null) { - actualSerializer = deserializer.findPolymorphicSerializerOrNull(this, type) - } - if (actualSerializer == null) { - // Fallback if we haven't found discriminator or serializer + ?: // Fallback to slow path if we haven't found discriminator on first try return decodeSerializableValuePolymorphic(deserializer as DeserializationStrategy) - } - discriminatorHolder = DiscriminatorHolder(discriminator) @Suppress("UNCHECKED_CAST") - val result = actualSerializer.deserialize(this) as T - return result + val actualSerializer = try { + deserializer.findPolymorphicSerializer(this, type) + } catch (it: SerializationException) { // Wrap SerializationException into JsonDecodingException to preserve position, path, and input. + // Split multiline message from private core function: + // core/commonMain/src/kotlinx/serialization/internal/AbstractPolymorphicSerializer.kt:102 + val message = it.message!!.substringBefore('\n').removeSuffix(".") + val hint = it.message!!.substringAfter('\n', missingDelimiterValue = "") + lexer.fail(message, hint = hint) + } as DeserializationStrategy + + discriminatorHolder = DiscriminatorHolder(discriminator) + return actualSerializer.deserialize(this) } catch (e: MissingFieldException) { // Add "at path" if and only if we've just caught an exception and it hasn't been augmented yet diff --git a/guide/test/PolymorphismTest.kt b/guide/test/PolymorphismTest.kt index f6147634c..344ed24da 100644 --- a/guide/test/PolymorphismTest.kt +++ b/guide/test/PolymorphismTest.kt @@ -23,9 +23,9 @@ class PolymorphismTest { @Test fun testExamplePoly03() { captureOutput("ExamplePoly03") { example.examplePoly03.main() }.verifyOutputLinesStart( - "Exception in thread \"main\" kotlinx.serialization.SerializationException: Class 'OwnedProject' is not registered for polymorphic serialization in the scope of 'Project'.", - "To be registered automatically, class 'OwnedProject' has to be '@Serializable', and the base class 'Project' has to be sealed and '@Serializable'.", - "Alternatively, register the serializer for 'OwnedProject' explicitly in a corresponding SerializersModule." + "Exception in thread \"main\" kotlinx.serialization.SerializationException: Serializer for subclass 'OwnedProject' is not found in the polymorphic scope of 'Project'.", + "Check if class with serial name 'OwnedProject' exists and serializer is registered in a corresponding SerializersModule.", + "To be registered automatically, class 'OwnedProject' has to be '@Serializable', and the base class 'Project' has to be sealed and '@Serializable'." ) } @@ -133,7 +133,8 @@ class PolymorphismTest { @Test fun testExamplePoly18() { captureOutput("ExamplePoly18") { example.examplePoly18.main() }.verifyOutputLinesStart( - "Exception in thread \"main\" kotlinx.serialization.json.internal.JsonDecodingException: Polymorphic serializer was not found for class discriminator 'unknown'" + "Exception in thread \"main\" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 0: Serializer for subclass 'unknown' is not found in the polymorphic scope of 'Project' at path: $", + "Check if class with serial name 'unknown' exists and serializer is registered in a corresponding SerializersModule." ) } diff --git a/integration-test/src/commonTest/kotlin/sample/JsonTest.kt b/integration-test/src/commonTest/kotlin/sample/JsonTest.kt index 6b704354a..88a7a0d7d 100644 --- a/integration-test/src/commonTest/kotlin/sample/JsonTest.kt +++ b/integration-test/src/commonTest/kotlin/sample/JsonTest.kt @@ -12,7 +12,7 @@ import kotlinx.serialization.modules.* import kotlin.reflect.* import kotlin.test.* -public val jsonWithDefaults = Json { encodeDefaults = true } +val jsonWithDefaults = Json { encodeDefaults = true } class JsonTest { @@ -129,10 +129,9 @@ class JsonTest { assertEquals("""Derived2(state1='foo')""", restored2.toString()) } - @Suppress("NAME_SHADOWING") private fun checkNotRegisteredMessage(exception: SerializationException) { val expectedText = - "is not registered for polymorphic serialization in the scope of" + "is not found in the polymorphic scope of" assertEquals(true, exception.message?.contains(expectedText)) }