From cd9f8b0094c5e88ae30fc7baba3478e83aef8ebd Mon Sep 17 00:00:00 2001 From: Leonid Startsev Date: Tue, 19 Dec 2023 17:03:39 +0100 Subject: [PATCH] Implement ClassDiscriminatorMode.ALL, .NONE, and .POLYMORPHIC (#2532) Implement ClassDiscriminatorMode.ALL, .NONE, and .POLYMORPHIC As a part of the solution for #1247 --- .../modules/SerializersModule.kt | 1 - docs/json.md | 76 +++++++-- docs/serialization-guide.md | 1 + .../JsonClassDiscriminatorModeBaseTest.kt | 153 ++++++++++++++++++ .../JsonClassDiscriminatorModeTest.kt | 84 ++++++++++ .../json/api/kotlinx-serialization-json.api | 13 ++ .../src/kotlinx/serialization/json/Json.kt | 23 ++- .../serialization/json/JsonConfiguration.kt | 89 +++++++++- .../json/internal/JsonConfiguration.kt | 0 .../json/internal/Polymorphic.kt | 32 +++- guide/example/example-json-12.kt | 12 +- guide/example/example-json-13.kt | 11 +- guide/example/example-json-14.kt | 11 +- guide/example/example-json-15.kt | 10 +- guide/example/example-json-16.kt | 23 ++- guide/example/example-json-17.kt | 18 ++- guide/example/example-json-18.kt | 20 +-- guide/example/example-json-19.kt | 6 +- guide/example/example-json-20.kt | 30 ++-- guide/example/example-json-21.kt | 17 +- guide/example/example-json-22.kt | 26 +-- guide/example/example-json-23.kt | 16 +- guide/example/example-json-24.kt | 28 ++-- guide/example/example-json-25.kt | 36 ++--- guide/example/example-json-26.kt | 59 +++---- guide/example/example-json-27.kt | 58 ++++--- guide/example/example-json-28.kt | 37 +++++ guide/test/JsonTest.kt | 55 ++++--- 28 files changed, 692 insertions(+), 253 deletions(-) create mode 100644 formats/json-tests/commonTest/src/kotlinx/serialization/json/polymorphic/JsonClassDiscriminatorModeBaseTest.kt create mode 100644 formats/json-tests/commonTest/src/kotlinx/serialization/json/polymorphic/JsonClassDiscriminatorModeTest.kt delete mode 100644 formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonConfiguration.kt create mode 100644 guide/example/example-json-28.kt diff --git a/core/commonMain/src/kotlinx/serialization/modules/SerializersModule.kt b/core/commonMain/src/kotlinx/serialization/modules/SerializersModule.kt index f01f952a0..8a9126d74 100644 --- a/core/commonMain/src/kotlinx/serialization/modules/SerializersModule.kt +++ b/core/commonMain/src/kotlinx/serialization/modules/SerializersModule.kt @@ -8,7 +8,6 @@ import kotlinx.serialization.* import kotlinx.serialization.internal.* import kotlin.js.* import kotlin.jvm.* -import kotlin.native.concurrent.* import kotlin.reflect.* /** diff --git a/docs/json.md b/docs/json.md index d764ce5f7..eaa2b797b 100644 --- a/docs/json.md +++ b/docs/json.md @@ -20,6 +20,7 @@ In this chapter, we'll walk through features of [JSON](https://www.json.org/json * [Allowing structured map keys](#allowing-structured-map-keys) * [Allowing special floating-point values](#allowing-special-floating-point-values) * [Class discriminator for polymorphism](#class-discriminator-for-polymorphism) + * [Class discriminator output mode](#class-discriminator-output-mode) * [Decoding enums in a case-insensitive manner](#decoding-enums-in-a-case-insensitive-manner) * [Global naming strategy](#global-naming-strategy) * [Json elements](#json-elements) @@ -470,6 +471,45 @@ As you can see, discriminator from the `Base` class is used: +### Class discriminator output mode + +Class discriminator provides information for serializing and deserializing [polymorphic class hierarchies](polymorphism.md#sealed-classes). +As shown above, it is only added for polymorphic classes by default. +In case you want to encode more or less information for various third party APIs about types in the output, it is possible to control +addition of the class discriminator with the [JsonBuilder.classDiscriminatorMode] property. + +For example, [ClassDiscriminatorMode.NONE] does not add class discriminator at all, in case the receiving party is not interested in Kotlin types: + +```kotlin +val format = Json { classDiscriminatorMode = ClassDiscriminatorMode.NONE } + +@Serializable +sealed class Project { + abstract val name: String +} + +@Serializable +class OwnedProject(override val name: String, val owner: String) : Project() + +fun main() { + val data: Project = OwnedProject("kotlinx.coroutines", "kotlin") + println(format.encodeToString(data)) +} +``` + +> You can get the full code [here](../guide/example/example-json-12.kt). + +Note that it would be impossible to deserialize this output back with kotlinx.serialization. + +```text +{"name":"kotlinx.coroutines","owner":"kotlin"} +``` + +Two other available values are [ClassDiscriminatorMode.POLYMORPHIC] (default behavior) and [ClassDiscriminatorMode.ALL_JSON_OBJECTS] (adds discriminator whenever possible). +Consult their documentation for details. + + + ### Decoding enums in a case-insensitive manner [Kotlin's naming policy recommends](https://kotlinlang.org/docs/coding-conventions.html#property-names) naming enum values @@ -491,7 +531,7 @@ fun main() { } ``` -> You can get the full code [here](../guide/example/example-json-12.kt). +> You can get the full code [here](../guide/example/example-json-13.kt). It affects serial names as well as alternative names specified with [JsonNames] annotation, so both values are successfully decoded: @@ -523,7 +563,7 @@ fun main() { } ``` -> You can get the full code [here](../guide/example/example-json-13.kt). +> You can get the full code [here](../guide/example/example-json-14.kt). As you can see, both serialization and deserialization work as if all serial names are transformed from camel case to snake case: @@ -575,7 +615,7 @@ fun main() { } ``` -> You can get the full code [here](../guide/example/example-json-14.kt). +> You can get the full code [here](../guide/example/example-json-15.kt). A `JsonElement` prints itself as a valid JSON: @@ -618,7 +658,7 @@ fun main() { } ``` -> You can get the full code [here](../guide/example/example-json-15.kt). +> You can get the full code [here](../guide/example/example-json-16.kt). The above example sums `votes` in all objects in the `forks` array, ignoring the objects that have no `votes`: @@ -658,7 +698,7 @@ fun main() { } ``` -> You can get the full code [here](../guide/example/example-json-16.kt). +> You can get the full code [here](../guide/example/example-json-17.kt). As a result, you get a proper JSON string: @@ -687,7 +727,7 @@ fun main() { } ``` -> You can get the full code [here](../guide/example/example-json-17.kt). +> You can get the full code [here](../guide/example/example-json-18.kt). The result is exactly what you would expect: @@ -733,7 +773,7 @@ fun main() { } ``` -> You can get the full code [here](../guide/example/example-json-18.kt). +> You can get the full code [here](../guide/example/example-json-19.kt). Even though `pi` was defined as a number with 30 decimal places, the resulting JSON does not reflect this. The [Double] value is truncated to 15 decimal places, and the String is wrapped in quotes - which is not a JSON number. @@ -773,7 +813,7 @@ fun main() { } ``` -> You can get the full code [here](../guide/example/example-json-19.kt). +> You can get the full code [here](../guide/example/example-json-20.kt). `pi_literal` now accurately matches the value defined. @@ -813,7 +853,7 @@ fun main() { } ``` -> You can get the full code [here](../guide/example/example-json-20.kt). +> You can get the full code [here](../guide/example/example-json-21.kt). The exact value of `pi` is decoded, with all 30 decimal places of precision that were in the source JSON. @@ -835,7 +875,7 @@ fun main() { } ``` -> You can get the full code [here](../guide/example/example-json-21.kt). +> You can get the full code [here](../guide/example/example-json-22.kt). ```text Exception in thread "main" kotlinx.serialization.json.internal.JsonEncodingException: Creating a literal unquoted value of 'null' is forbidden. If you want to create JSON null literal, use JsonNull object, otherwise, use JsonPrimitive @@ -911,7 +951,7 @@ fun main() { } ``` -> You can get the full code [here](../guide/example/example-json-22.kt). +> You can get the full code [here](../guide/example/example-json-23.kt). The output shows that both cases are correctly deserialized into a Kotlin [List]. @@ -963,7 +1003,7 @@ fun main() { } ``` -> You can get the full code [here](../guide/example/example-json-23.kt). +> You can get the full code [here](../guide/example/example-json-24.kt). You end up with a single JSON object, not an array with one element: @@ -1008,7 +1048,7 @@ fun main() { } ``` -> You can get the full code [here](../guide/example/example-json-24.kt). +> You can get the full code [here](../guide/example/example-json-25.kt). See the effect of the custom serializer: @@ -1081,7 +1121,7 @@ fun main() { } ``` -> You can get the full code [here](../guide/example/example-json-25.kt). +> You can get the full code [here](../guide/example/example-json-26.kt). No class discriminator is added in the JSON output: @@ -1177,7 +1217,7 @@ fun main() { } ``` -> You can get the full code [here](../guide/example/example-json-26.kt). +> You can get the full code [here](../guide/example/example-json-27.kt). This gives you fine-grained control on the representation of the `Response` class in the JSON output: @@ -1242,7 +1282,7 @@ fun main() { } ``` -> You can get the full code [here](../guide/example/example-json-27.kt). +> You can get the full code [here](../guide/example/example-json-28.kt). ```text UnknownProject(name=example, details={"type":"unknown","maintainer":"Unknown","license":"Apache 2.0"}) @@ -1296,6 +1336,10 @@ The next chapter covers [Alternative and custom formats (experimental)](formats. [JsonBuilder.allowSpecialFloatingPointValues]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/allow-special-floating-point-values.html [JsonBuilder.classDiscriminator]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/class-discriminator.html [JsonClassDiscriminator]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-class-discriminator/index.html +[JsonBuilder.classDiscriminatorMode]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/class-discriminator-mode.html +[ClassDiscriminatorMode.NONE]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-class-discriminator-mode/-n-o-n-e/index.html +[ClassDiscriminatorMode.POLYMORPHIC]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-class-discriminator-mode/-p-o-l-y-m-o-r-p-h-i-c/index.html +[ClassDiscriminatorMode.ALL_JSON_OBJECTS]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-class-discriminator-mode/-a-l-l_-j-s-o-n_-o-b-j-e-c-t-s/index.html [JsonBuilder.decodeEnumsCaseInsensitive]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/decode-enums-case-insensitive.html [JsonBuilder.namingStrategy]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/naming-strategy.html [JsonNamingStrategy]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-naming-strategy/index.html diff --git a/docs/serialization-guide.md b/docs/serialization-guide.md index 68ede1448..50cb1306a 100644 --- a/docs/serialization-guide.md +++ b/docs/serialization-guide.md @@ -120,6 +120,7 @@ Once the project is set up, we can start serializing some classes. * [Allowing structured map keys](json.md#allowing-structured-map-keys) * [Allowing special floating-point values](json.md#allowing-special-floating-point-values) * [Class discriminator for polymorphism](json.md#class-discriminator-for-polymorphism) + * [Class discriminator output mode](json.md#class-discriminator-output-mode) * [Decoding enums in a case-insensitive manner](json.md#decoding-enums-in-a-case-insensitive-manner) * [Global naming strategy](json.md#global-naming-strategy) * [Json elements](json.md#json-elements) diff --git a/formats/json-tests/commonTest/src/kotlinx/serialization/json/polymorphic/JsonClassDiscriminatorModeBaseTest.kt b/formats/json-tests/commonTest/src/kotlinx/serialization/json/polymorphic/JsonClassDiscriminatorModeBaseTest.kt new file mode 100644 index 000000000..8fcd54997 --- /dev/null +++ b/formats/json-tests/commonTest/src/kotlinx/serialization/json/polymorphic/JsonClassDiscriminatorModeBaseTest.kt @@ -0,0 +1,153 @@ +/* + * Copyright 2017-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.serialization.json.polymorphic + +import kotlinx.serialization.* +import kotlinx.serialization.builtins.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* +import kotlinx.serialization.json.* +import kotlinx.serialization.modules.* +import kotlin.test.* + +abstract class JsonClassDiscriminatorModeBaseTest( + val discriminator: ClassDiscriminatorMode, + val deserializeBack: Boolean = true +) : JsonTestBase() { + + @Serializable + sealed class SealedBase + + @Serializable + @SerialName("container") + data class SealedContainer(val i: Inner): SealedBase() + + @Serializable + @SerialName("inner") + data class Inner(val x: String, val e: SampleEnum = SampleEnum.OptionB) + + @Serializable + @SerialName("outer") + data class Outer(val inn: Inner, val lst: List, val lss: List) + + data class ContextualType(val text: String) + + object CtxSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("CtxSerializer") { + element("a", String.serializer().descriptor) + element("b", String.serializer().descriptor) + } + + override fun serialize(encoder: Encoder, value: ContextualType) { + encoder.encodeStructure(descriptor) { + encodeStringElement(descriptor, 0, value.text.substringBefore("#")) + encodeStringElement(descriptor, 1, value.text.substringAfter("#")) + } + } + + override fun deserialize(decoder: Decoder): ContextualType { + lateinit var a: String + lateinit var b: String + decoder.decodeStructure(descriptor) { + while (true) { + when (decodeElementIndex(descriptor)) { + 0 -> a = decodeStringElement(descriptor, 0) + 1 -> b = decodeStringElement(descriptor, 1) + else -> break + } + } + } + return ContextualType("$a#$b") + } + } + + @Serializable + @SerialName("withContextual") + data class WithContextual(@Contextual val ctx: ContextualType, val i: Inner) + + val ctxModule = serializersModuleOf(CtxSerializer) + + val json = Json(default) { + ignoreUnknownKeys = true + serializersModule = polymorphicTestModule + ctxModule + encodeDefaults = true + classDiscriminatorMode = discriminator + } + + @Serializable + @SerialName("mixed") + data class MixedPolyAndRegular(val sb: SealedBase, val sc: SealedContainer, val i: Inner) + + private inline fun doTest(expected: String, obj: T) { + parametrizedTest { mode -> + val serialized = json.encodeToString(serializer(), obj, mode) + assertEquals(expected, serialized, "Failed with mode = $mode") + if (deserializeBack) { + val deserialized: T = json.decodeFromString(serializer(), serialized, mode) + assertEquals(obj, deserialized, "Failed with mode = $mode") + } + } + } + + fun testMixed(expected: String) { + val i = Inner("in", SampleEnum.OptionC) + val o = MixedPolyAndRegular(SealedContainer(i), SealedContainer(i), i) + doTest(expected, o) + } + + fun testIncludeNonPolymorphic(expected: String) { + val o = Outer(Inner("X"), listOf(Inner("a"), Inner("b")), listOf("foo")) + doTest(expected, o) + } + + fun testIncludePolymorphic(expected: String) { + val o = OuterNullableBox(OuterNullableImpl(InnerImpl(42), null), InnerImpl2(239)) + doTest(expected, o) + } + + fun testIncludeSealed(expected: String) { + val b = Box(SealedContainer(Inner("x", SampleEnum.OptionC))) + doTest(expected, b) + } + + fun testContextual(expected: String) { + val c = WithContextual(ContextualType("c#d"), Inner("x")) + doTest(expected, c) + } + + @Serializable + @JsonClassDiscriminator("message_type") + sealed class Base + + @Serializable // Class discriminator is inherited from Base + sealed class ErrorClass : Base() + + @Serializable + @SerialName("ErrorClassImpl") + data class ErrorClassImpl(val msg: String) : ErrorClass() + + @Serializable + @SerialName("Cont") + data class Cont(val ec: ErrorClass, val eci: ErrorClassImpl) + + fun testCustomDiscriminator(expected: String) { + val c = Cont(ErrorClassImpl("a"), ErrorClassImpl("b")) + doTest(expected, c) + } + + fun testTopLevelPolyImpl(expectedOpen: String, expectedSealed: String) { + assertEquals(expectedOpen, json.encodeToString(InnerImpl(42))) + assertEquals(expectedSealed, json.encodeToString(SealedContainer(Inner("x")))) + } + + @Serializable + @SerialName("NullableMixed") + data class NullableMixed(val sb: SealedBase?, val sc: SealedContainer?) + + fun testNullable(expected: String) { + val nm = NullableMixed(null, null) + doTest(expected, nm) + } +} diff --git a/formats/json-tests/commonTest/src/kotlinx/serialization/json/polymorphic/JsonClassDiscriminatorModeTest.kt b/formats/json-tests/commonTest/src/kotlinx/serialization/json/polymorphic/JsonClassDiscriminatorModeTest.kt new file mode 100644 index 000000000..b2f471372 --- /dev/null +++ b/formats/json-tests/commonTest/src/kotlinx/serialization/json/polymorphic/JsonClassDiscriminatorModeTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2017-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.serialization.json.polymorphic + +import kotlinx.serialization.json.* +import kotlin.test.* + +class ClassDiscriminatorModeAllObjectsTest : + JsonClassDiscriminatorModeBaseTest(ClassDiscriminatorMode.ALL_JSON_OBJECTS) { + @Test + fun testIncludeNonPolymorphic() = testIncludeNonPolymorphic("""{"type":"outer","inn":{"type":"inner","x":"X","e":"OptionB"},"lst":[{"type":"inner","x":"a","e":"OptionB"},{"type":"inner","x":"b","e":"OptionB"}],"lss":["foo"]}""") + + @Test + fun testIncludePolymorphic() { + val s = """{"type":"kotlinx.serialization.json.polymorphic.OuterNullableBox","outerBase":{"type":"kotlinx.serialization.json.polymorphic.OuterNullableImpl","""+ + """"base":{"type":"kotlinx.serialization.json.polymorphic.InnerImpl","field":42,"str":"default","nullable":null},"base2":null},"innerBase":{"type":"kotlinx.serialization.json.polymorphic.InnerImpl2","field":239}}""" + testIncludePolymorphic(s) + } + + @Test + fun testIncludeSealed() { + testIncludeSealed("""{"type":"kotlinx.serialization.Box","boxed":{"type":"container","i":{"type":"inner","x":"x","e":"OptionC"}}}""") + } + + @Test + fun testIncludeMixed() = testMixed("""{"type":"mixed","sb":{"type":"container","i":{"type":"inner","x":"in","e":"OptionC"}},"sc":{"type":"container","i":{"type":"inner","x":"in","e":"OptionC"}},"i":{"type":"inner","x":"in","e":"OptionC"}}""") + + @Test + fun testIncludeCtx() = + testContextual("""{"type":"withContextual","ctx":{"type":"CtxSerializer","a":"c","b":"d"},"i":{"type":"inner","x":"x","e":"OptionB"}}""") + + @Test + fun testIncludeCustomDiscriminator() = + testCustomDiscriminator("""{"type":"Cont","ec":{"message_type":"ErrorClassImpl","msg":"a"},"eci":{"message_type":"ErrorClassImpl","msg":"b"}}""") + + @Test + fun testTopLevelPolyImpl() = testTopLevelPolyImpl( + """{"type":"kotlinx.serialization.json.polymorphic.InnerImpl","field":42,"str":"default","nullable":null}""", + """{"type":"container","i":{"type":"inner","x":"x","e":"OptionB"}}""" + ) + + @Test + fun testNullable() = testNullable("""{"type":"NullableMixed","sb":null,"sc":null}""") + +} + +class ClassDiscriminatorModeNoneTest : + JsonClassDiscriminatorModeBaseTest(ClassDiscriminatorMode.NONE, deserializeBack = false) { + @Test + fun testIncludeNonPolymorphic() = testIncludeNonPolymorphic("""{"inn":{"x":"X","e":"OptionB"},"lst":[{"x":"a","e":"OptionB"},{"x":"b","e":"OptionB"}],"lss":["foo"]}""") + + @Test + fun testIncludePolymorphic() { + val s = """{"outerBase":{"base":{"field":42,"str":"default","nullable":null},"base2":null},"innerBase":{"field":239}}""" + testIncludePolymorphic(s) + } + + @Test + fun testIncludeSealed() { + testIncludeSealed("""{"boxed":{"i":{"x":"x","e":"OptionC"}}}""") + } + + @Test + fun testIncludeMixed() = testMixed("""{"sb":{"i":{"x":"in","e":"OptionC"}},"sc":{"i":{"x":"in","e":"OptionC"}},"i":{"x":"in","e":"OptionC"}}""") + + @Test + fun testIncludeCtx() = + testContextual("""{"ctx":{"a":"c","b":"d"},"i":{"x":"x","e":"OptionB"}}""") + + @Test + fun testIncludeCustomDiscriminator() = testCustomDiscriminator("""{"ec":{"msg":"a"},"eci":{"msg":"b"}}""") + + @Test + fun testTopLevelPolyImpl() = testTopLevelPolyImpl( + """{"field":42,"str":"default","nullable":null}""", + """{"i":{"x":"x","e":"OptionB"}}""" + ) + + @Test + fun testNullable() = testNullable("""{"sb":null,"sc":null}""") +} + diff --git a/formats/json/api/kotlinx-serialization-json.api b/formats/json/api/kotlinx-serialization-json.api index 649cce0ec..88dd29c2c 100644 --- a/formats/json/api/kotlinx-serialization-json.api +++ b/formats/json/api/kotlinx-serialization-json.api @@ -1,3 +1,12 @@ +public final class kotlinx/serialization/json/ClassDiscriminatorMode : java/lang/Enum { + public static final field ALL_JSON_OBJECTS Lkotlinx/serialization/json/ClassDiscriminatorMode; + public static final field NONE Lkotlinx/serialization/json/ClassDiscriminatorMode; + public static final field POLYMORPHIC Lkotlinx/serialization/json/ClassDiscriminatorMode; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lkotlinx/serialization/json/ClassDiscriminatorMode; + public static fun values ()[Lkotlinx/serialization/json/ClassDiscriminatorMode; +} + public final class kotlinx/serialization/json/DecodeSequenceMode : java/lang/Enum { public static final field ARRAY_WRAPPED Lkotlinx/serialization/json/DecodeSequenceMode; public static final field AUTO_DETECT Lkotlinx/serialization/json/DecodeSequenceMode; @@ -89,6 +98,7 @@ public final class kotlinx/serialization/json/JsonBuilder { public final fun getAllowStructuredMapKeys ()Z public final fun getAllowTrailingComma ()Z public final fun getClassDiscriminator ()Ljava/lang/String; + public final fun getClassDiscriminatorMode ()Lkotlinx/serialization/json/ClassDiscriminatorMode; public final fun getCoerceInputValues ()Z public final fun getDecodeEnumsCaseInsensitive ()Z public final fun getEncodeDefaults ()Z @@ -105,6 +115,7 @@ public final class kotlinx/serialization/json/JsonBuilder { public final fun setAllowStructuredMapKeys (Z)V public final fun setAllowTrailingComma (Z)V public final fun setClassDiscriminator (Ljava/lang/String;)V + public final fun setClassDiscriminatorMode (Lkotlinx/serialization/json/ClassDiscriminatorMode;)V public final fun setCoerceInputValues (Z)V public final fun setDecodeEnumsCaseInsensitive (Z)V public final fun setEncodeDefaults (Z)V @@ -134,6 +145,7 @@ public final class kotlinx/serialization/json/JsonConfiguration { public final fun getAllowStructuredMapKeys ()Z public final fun getAllowTrailingComma ()Z public final fun getClassDiscriminator ()Ljava/lang/String; + public final fun getClassDiscriminatorMode ()Lkotlinx/serialization/json/ClassDiscriminatorMode; public final fun getCoerceInputValues ()Z public final fun getDecodeEnumsCaseInsensitive ()Z public final fun getEncodeDefaults ()Z @@ -145,6 +157,7 @@ public final class kotlinx/serialization/json/JsonConfiguration { public final fun getUseAlternativeNames ()Z public final fun getUseArrayPolymorphism ()Z public final fun isLenient ()Z + public final fun setClassDiscriminatorMode (Lkotlinx/serialization/json/ClassDiscriminatorMode;)V public fun toString ()Ljava/lang/String; } diff --git a/formats/json/commonMain/src/kotlinx/serialization/json/Json.kt b/formats/json/commonMain/src/kotlinx/serialization/json/Json.kt index a510e8a30..e09b9edc9 100644 --- a/formats/json/commonMain/src/kotlinx/serialization/json/Json.kt +++ b/formats/json/commonMain/src/kotlinx/serialization/json/Json.kt @@ -299,6 +299,8 @@ public class JsonBuilder internal constructor(json: Json) { * Switches polymorphic serialization to the default array format. * This is an option for legacy JSON format and should not be generally used. * `false` by default. + * + * This option can only be used if [classDiscriminatorMode] in a default [ClassDiscriminatorMode.POLYMORPHIC] state. */ public var useArrayPolymorphism: Boolean = json.configuration.useArrayPolymorphism @@ -308,6 +310,16 @@ public class JsonBuilder internal constructor(json: Json) { */ public var classDiscriminator: String = json.configuration.classDiscriminator + + /** + * Defines which classes and objects should have class discriminator added to the output. + * [ClassDiscriminatorMode.POLYMORPHIC] by default. + * + * Other modes are generally intended to produce JSON for consumption by third-party libraries, + * therefore, this setting does not affect the deserialization process. + */ + public var classDiscriminatorMode: ClassDiscriminatorMode = json.configuration.classDiscriminatorMode + /** * Removes JSON specification restriction on * special floating-point values such as `NaN` and `Infinity` and enables their serialization and deserialization. @@ -385,8 +397,13 @@ public class JsonBuilder internal constructor(json: Json) { @OptIn(ExperimentalSerializationApi::class) internal fun build(): JsonConfiguration { - if (useArrayPolymorphism) require(classDiscriminator == defaultDiscriminator) { - "Class discriminator should not be specified when array polymorphism is specified" + if (useArrayPolymorphism) { + require(classDiscriminator == defaultDiscriminator) { + "Class discriminator should not be specified when array polymorphism is specified" + } + require(classDiscriminatorMode == ClassDiscriminatorMode.POLYMORPHIC) { + "useArrayPolymorphism option can only be used if classDiscriminatorMode in a default POLYMORPHIC state." + } } if (!prettyPrint) { @@ -406,7 +423,7 @@ public class JsonBuilder internal constructor(json: Json) { allowStructuredMapKeys, prettyPrint, explicitNulls, prettyPrintIndent, coerceInputValues, useArrayPolymorphism, classDiscriminator, allowSpecialFloatingPointValues, useAlternativeNames, - namingStrategy, decodeEnumsCaseInsensitive, allowTrailingComma + namingStrategy, decodeEnumsCaseInsensitive, allowTrailingComma, classDiscriminatorMode ) } } diff --git a/formats/json/commonMain/src/kotlinx/serialization/json/JsonConfiguration.kt b/formats/json/commonMain/src/kotlinx/serialization/json/JsonConfiguration.kt index 053f4cd6d..1fa1644e9 100644 --- a/formats/json/commonMain/src/kotlinx/serialization/json/JsonConfiguration.kt +++ b/formats/json/commonMain/src/kotlinx/serialization/json/JsonConfiguration.kt @@ -1,6 +1,8 @@ package kotlinx.serialization.json import kotlinx.serialization.* +import kotlinx.serialization.modules.* +import kotlinx.serialization.descriptors.* /** * Configuration of the current [Json] instance available through [Json.configuration] @@ -35,6 +37,8 @@ public class JsonConfiguration @OptIn(ExperimentalSerializationApi::class) inter public val decodeEnumsCaseInsensitive: Boolean = false, @ExperimentalSerializationApi public val allowTrailingComma: Boolean = false, + @ExperimentalSerializationApi + public var classDiscriminatorMode: ClassDiscriminatorMode = ClassDiscriminatorMode.POLYMORPHIC, ) { /** @suppress Dokka **/ @@ -43,7 +47,88 @@ public class JsonConfiguration @OptIn(ExperimentalSerializationApi::class) inter return "JsonConfiguration(encodeDefaults=$encodeDefaults, ignoreUnknownKeys=$ignoreUnknownKeys, isLenient=$isLenient, " + "allowStructuredMapKeys=$allowStructuredMapKeys, prettyPrint=$prettyPrint, explicitNulls=$explicitNulls, " + "prettyPrintIndent='$prettyPrintIndent', coerceInputValues=$coerceInputValues, useArrayPolymorphism=$useArrayPolymorphism, " + - "classDiscriminator='$classDiscriminator', allowSpecialFloatingPointValues=$allowSpecialFloatingPointValues, useAlternativeNames=$useAlternativeNames, " + - "namingStrategy=$namingStrategy, decodeEnumsCaseInsensitive=$decodeEnumsCaseInsensitive, allowTrailingComma=$allowTrailingComma)" + "classDiscriminator='$classDiscriminator', allowSpecialFloatingPointValues=$allowSpecialFloatingPointValues, " + + "useAlternativeNames=$useAlternativeNames, namingStrategy=$namingStrategy, decodeEnumsCaseInsensitive=$decodeEnumsCaseInsensitive, " + + "allowTrailingComma=$allowTrailingComma, classDiscriminatorMode=$classDiscriminatorMode)" } } + +/** + * Defines which classes and objects should have their serial name included in the json as so-called class discriminator. + * + * Class discriminator is a JSON field added by kotlinx.serialization that has [JsonBuilder.classDiscriminator] as a key (`type` by default), + * and class' serial name as a value (fully-qualified name by default, can be changed with [SerialName] annotation). + * + * Class discriminator is important for serializing and deserializing [polymorphic class hierarchies](https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/polymorphism.md#sealed-classes). + * Default [ClassDiscriminatorMode.POLYMORPHIC] mode adds discriminator only to polymorphic classes. + * This behavior can be changed to match various JSON schemas. + * + * @see JsonBuilder.classDiscriminator + * @see JsonBuilder.classDiscriminatorMode + * @see Polymorphic + * @see PolymorphicSerializer + */ +public enum class ClassDiscriminatorMode { + /** + * Never include class discriminator in the output. + * + * This mode is generally intended to produce JSON for consumption by third-party libraries. + * kotlinx.serialization is unable to deserialize [polymorphic classes][POLYMORPHIC] without class discriminators, + * so it is impossible to deserialize JSON produced in this mode if a data model has polymorphic classes. + */ + NONE, + + /** + * Include class discriminators whenever possible. + * + * Given that class discriminator is added as a JSON field, adding class discriminator is possible + * when the resulting JSON is a json object — i.e., for Kotlin classes, `object`s, and interfaces. + * More specifically, discriminator is added to the output of serializers which descriptors + * have a [kind][SerialDescriptor.kind] of either [StructureKind.CLASS] or [StructureKind.OBJECT]. + * + * This mode is generally intended to produce JSON for consumption by third-party libraries. + * Given that [JsonBuilder.classDiscriminatorMode] does not affect deserialization, kotlinx.serialization + * does not expect every object to have discriminator, which may trigger deserialization errors. + * If you experience such problems, refrain from using [ALL_JSON_OBJECTS] or use [JsonBuilder.ignoreUnknownKeys]. + * + * In the example: + * ``` + * @Serializable class Plain(val p: String) + * @Serializable sealed class Base + * @Serializable object Impl: Base() + * + * @Serializable class All(val p: Plain, val b: Base, val i: Impl) + * ``` + * setting [JsonBuilder.classDiscriminatorMode] to [ClassDiscriminatorMode.ALL_JSON_OBJECTS] adds + * class discriminator to `All.p`, `All.b`, `All.i`, and to `All` object itself. + */ + ALL_JSON_OBJECTS, + + /** + * Include class discriminators for polymorphic classes. + * + * Sealed classes, abstract classes, and interfaces are polymorphic classes by definition. + * Open classes can be polymorphic if they are serializable with [PolymorphicSerializer] + * and properly registered in the [SerializersModule]. + * See [kotlinx.serialization polymorphism guide](https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/polymorphism.md#sealed-classes) for details. + * + * Note that implementations of polymorphic classes (e.g., sealed class inheritors) are not polymorphic classes from kotlinx.serialization standpoint. + * This means that this mode adds class discriminators only if a statically known type of the property is a base class or interface. + * + * In the example: + * ``` + * @Serializable class Plain(val p: String) + * @Serializable sealed class Base + * @Serializable object Impl: Base() + * + * @Serializable class All(val p: Plain, val b: Base, val i: Impl) + * ``` + * setting [JsonBuilder.classDiscriminatorMode] to [ClassDiscriminatorMode.POLYMORPHIC] adds + * class discriminator to `All.b`, but leaves `All.p` and `All.i` intact. + * + * @see SerializersModule + * @see SerializersModuleBuilder + * @see PolymorphicModuleBuilder + */ + POLYMORPHIC, +} diff --git a/formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonConfiguration.kt b/formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonConfiguration.kt deleted file mode 100644 index e69de29bb..000000000 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 bd658fc12..636f340dd 100644 --- a/formats/json/commonMain/src/kotlinx/serialization/json/internal/Polymorphic.kt +++ b/formats/json/commonMain/src/kotlinx/serialization/json/internal/Polymorphic.kt @@ -9,6 +9,7 @@ import kotlinx.serialization.* import kotlinx.serialization.descriptors.* import kotlinx.serialization.internal.* import kotlinx.serialization.json.* +import kotlinx.serialization.modules.* import kotlin.jvm.* @Suppress("UNCHECKED_CAST") @@ -17,22 +18,37 @@ internal inline fun JsonEncoder.encodePolymorphically( value: T, ifPolymorphic: (String) -> Unit ) { - if (serializer !is AbstractPolymorphicSerializer<*> || json.configuration.useArrayPolymorphism) { + if (json.configuration.useArrayPolymorphism) { serializer.serialize(this, value) return } - val casted = serializer as AbstractPolymorphicSerializer - val baseClassDiscriminator = serializer.descriptor.classDiscriminator(json) - val actualSerializer = casted.findPolymorphicSerializer(this, value as Any) - validateIfSealed(casted, actualSerializer, baseClassDiscriminator) - checkKind(actualSerializer.descriptor.kind) - ifPolymorphic(baseClassDiscriminator) + val isPolymorphicSerializer = serializer is AbstractPolymorphicSerializer<*> + val needDiscriminator = + if (isPolymorphicSerializer) { + json.configuration.classDiscriminatorMode != ClassDiscriminatorMode.NONE + } else { + when (json.configuration.classDiscriminatorMode) { + ClassDiscriminatorMode.NONE, ClassDiscriminatorMode.POLYMORPHIC /* already handled in isPolymorphicSerializer */ -> false + ClassDiscriminatorMode.ALL_JSON_OBJECTS -> serializer.descriptor.kind.let { it == StructureKind.CLASS || it == StructureKind.OBJECT } + } + } + val baseClassDiscriminator = if (needDiscriminator) serializer.descriptor.classDiscriminator(json) else null + val actualSerializer: SerializationStrategy = if (isPolymorphicSerializer) { + val casted = serializer as AbstractPolymorphicSerializer + requireNotNull(value) { "Value for serializer ${serializer.descriptor} should always be non-null. Please report issue to the kotlinx.serialization tracker." } + val actual = casted.findPolymorphicSerializer(this, value) + if (baseClassDiscriminator != null) validateIfSealed(serializer, actual, baseClassDiscriminator) + checkKind(actual.descriptor.kind) + actual as SerializationStrategy + } else serializer + + if (baseClassDiscriminator != null) ifPolymorphic(baseClassDiscriminator) actualSerializer.serialize(this, value) } private fun validateIfSealed( serializer: SerializationStrategy<*>, - actualSerializer: SerializationStrategy, + actualSerializer: SerializationStrategy<*>, classDiscriminator: String ) { if (serializer !is SealedClassSerializer<*>) return diff --git a/guide/example/example-json-12.kt b/guide/example/example-json-12.kt index 1a37516b4..99a872b72 100644 --- a/guide/example/example-json-12.kt +++ b/guide/example/example-json-12.kt @@ -4,13 +4,17 @@ package example.exampleJson12 import kotlinx.serialization.* import kotlinx.serialization.json.* -val format = Json { decodeEnumsCaseInsensitive = true } +val format = Json { classDiscriminatorMode = ClassDiscriminatorMode.NONE } -enum class Cases { VALUE_A, @JsonNames("Alternative") VALUE_B } +@Serializable +sealed class Project { + abstract val name: String +} @Serializable -data class CasesList(val cases: List) +class OwnedProject(override val name: String, val owner: String) : Project() fun main() { - println(format.decodeFromString("""{"cases":["value_A", "alternative"]}""")) + val data: Project = OwnedProject("kotlinx.coroutines", "kotlin") + println(format.encodeToString(data)) } diff --git a/guide/example/example-json-13.kt b/guide/example/example-json-13.kt index cd7cf7f2c..e20afe286 100644 --- a/guide/example/example-json-13.kt +++ b/guide/example/example-json-13.kt @@ -4,12 +4,13 @@ package example.exampleJson13 import kotlinx.serialization.* import kotlinx.serialization.json.* -@Serializable -data class Project(val projectName: String, val projectOwner: String) +val format = Json { decodeEnumsCaseInsensitive = true } + +enum class Cases { VALUE_A, @JsonNames("Alternative") VALUE_B } -val format = Json { namingStrategy = JsonNamingStrategy.SnakeCase } +@Serializable +data class CasesList(val cases: List) fun main() { - val project = format.decodeFromString("""{"project_name":"kotlinx.coroutines", "project_owner":"Kotlin"}""") - println(format.encodeToString(project.copy(projectName = "kotlinx.serialization"))) + println(format.decodeFromString("""{"cases":["value_A", "alternative"]}""")) } diff --git a/guide/example/example-json-14.kt b/guide/example/example-json-14.kt index 98464dcd6..50de55fdc 100644 --- a/guide/example/example-json-14.kt +++ b/guide/example/example-json-14.kt @@ -4,9 +4,12 @@ package example.exampleJson14 import kotlinx.serialization.* import kotlinx.serialization.json.* +@Serializable +data class Project(val projectName: String, val projectOwner: String) + +val format = Json { namingStrategy = JsonNamingStrategy.SnakeCase } + fun main() { - val element = Json.parseToJsonElement(""" - {"name":"kotlinx.serialization","language":"Kotlin"} - """) - println(element) + val project = format.decodeFromString("""{"project_name":"kotlinx.coroutines", "project_owner":"Kotlin"}""") + println(format.encodeToString(project.copy(projectName = "kotlinx.serialization"))) } diff --git a/guide/example/example-json-15.kt b/guide/example/example-json-15.kt index 72fd23eb2..384ae4160 100644 --- a/guide/example/example-json-15.kt +++ b/guide/example/example-json-15.kt @@ -6,13 +6,7 @@ import kotlinx.serialization.json.* fun main() { val element = Json.parseToJsonElement(""" - { - "name": "kotlinx.serialization", - "forks": [{"votes": 42}, {"votes": 9000}, {}] - } + {"name":"kotlinx.serialization","language":"Kotlin"} """) - val sum = element - .jsonObject["forks"]!! - .jsonArray.sumOf { it.jsonObject["votes"]?.jsonPrimitive?.int ?: 0 } - println(sum) + println(element) } diff --git a/guide/example/example-json-16.kt b/guide/example/example-json-16.kt index cff8ec7d8..fff287ae8 100644 --- a/guide/example/example-json-16.kt +++ b/guide/example/example-json-16.kt @@ -5,19 +5,14 @@ import kotlinx.serialization.* import kotlinx.serialization.json.* fun main() { - val element = buildJsonObject { - put("name", "kotlinx.serialization") - putJsonObject("owner") { - put("name", "kotlin") + val element = Json.parseToJsonElement(""" + { + "name": "kotlinx.serialization", + "forks": [{"votes": 42}, {"votes": 9000}, {}] } - putJsonArray("forks") { - addJsonObject { - put("votes", 42) - } - addJsonObject { - put("votes", 9000) - } - } - } - println(element) + """) + val sum = element + .jsonObject["forks"]!! + .jsonArray.sumOf { it.jsonObject["votes"]?.jsonPrimitive?.int ?: 0 } + println(sum) } diff --git a/guide/example/example-json-17.kt b/guide/example/example-json-17.kt index 25be7584b..72a696a23 100644 --- a/guide/example/example-json-17.kt +++ b/guide/example/example-json-17.kt @@ -4,14 +4,20 @@ package example.exampleJson17 import kotlinx.serialization.* import kotlinx.serialization.json.* -@Serializable -data class Project(val name: String, val language: String) - fun main() { val element = buildJsonObject { put("name", "kotlinx.serialization") - put("language", "Kotlin") + putJsonObject("owner") { + put("name", "kotlin") + } + putJsonArray("forks") { + addJsonObject { + put("votes", 42) + } + addJsonObject { + put("votes", 9000) + } + } } - val data = Json.decodeFromJsonElement(element) - println(data) + println(element) } diff --git a/guide/example/example-json-18.kt b/guide/example/example-json-18.kt index 2a1add456..1b655bfe9 100644 --- a/guide/example/example-json-18.kt +++ b/guide/example/example-json-18.kt @@ -4,20 +4,14 @@ package example.exampleJson18 import kotlinx.serialization.* import kotlinx.serialization.json.* -import java.math.BigDecimal - -val format = Json { prettyPrint = true } +@Serializable +data class Project(val name: String, val language: String) fun main() { - val pi = BigDecimal("3.141592653589793238462643383279") - - val piJsonDouble = JsonPrimitive(pi.toDouble()) - val piJsonString = JsonPrimitive(pi.toString()) - - val piObject = buildJsonObject { - put("pi_double", piJsonDouble) - put("pi_string", piJsonString) + val element = buildJsonObject { + put("name", "kotlinx.serialization") + put("language", "Kotlin") } - - println(format.encodeToString(piObject)) + val data = Json.decodeFromJsonElement(element) + println(data) } diff --git a/guide/example/example-json-19.kt b/guide/example/example-json-19.kt index d59bf26b2..b001c55a7 100644 --- a/guide/example/example-json-19.kt +++ b/guide/example/example-json-19.kt @@ -10,15 +10,11 @@ val format = Json { prettyPrint = true } fun main() { val pi = BigDecimal("3.141592653589793238462643383279") - - // use JsonUnquotedLiteral to encode raw JSON content - val piJsonLiteral = JsonUnquotedLiteral(pi.toString()) - + val piJsonDouble = JsonPrimitive(pi.toDouble()) val piJsonString = JsonPrimitive(pi.toString()) val piObject = buildJsonObject { - put("pi_literal", piJsonLiteral) put("pi_double", piJsonDouble) put("pi_string", piJsonString) } diff --git a/guide/example/example-json-20.kt b/guide/example/example-json-20.kt index 2f481daa4..f522b3fac 100644 --- a/guide/example/example-json-20.kt +++ b/guide/example/example-json-20.kt @@ -6,18 +6,22 @@ import kotlinx.serialization.json.* import java.math.BigDecimal +val format = Json { prettyPrint = true } + fun main() { - val piObjectJson = """ - { - "pi_literal": 3.141592653589793238462643383279 - } - """.trimIndent() - - val piObject: JsonObject = Json.decodeFromString(piObjectJson) - - val piJsonLiteral = piObject["pi_literal"]!!.jsonPrimitive.content - - val pi = BigDecimal(piJsonLiteral) - - println(pi) + val pi = BigDecimal("3.141592653589793238462643383279") + + // use JsonUnquotedLiteral to encode raw JSON content + val piJsonLiteral = JsonUnquotedLiteral(pi.toString()) + + val piJsonDouble = JsonPrimitive(pi.toDouble()) + val piJsonString = JsonPrimitive(pi.toString()) + + val piObject = buildJsonObject { + put("pi_literal", piJsonLiteral) + put("pi_double", piJsonDouble) + put("pi_string", piJsonString) + } + + println(format.encodeToString(piObject)) } diff --git a/guide/example/example-json-21.kt b/guide/example/example-json-21.kt index 86a4b734d..efd60710f 100644 --- a/guide/example/example-json-21.kt +++ b/guide/example/example-json-21.kt @@ -4,7 +4,20 @@ package example.exampleJson21 import kotlinx.serialization.* import kotlinx.serialization.json.* +import java.math.BigDecimal + fun main() { - // caution: creating null with JsonUnquotedLiteral will cause an exception! - JsonUnquotedLiteral("null") + val piObjectJson = """ + { + "pi_literal": 3.141592653589793238462643383279 + } + """.trimIndent() + + val piObject: JsonObject = Json.decodeFromString(piObjectJson) + + val piJsonLiteral = piObject["pi_literal"]!!.jsonPrimitive.content + + val pi = BigDecimal(piJsonLiteral) + + println(pi) } diff --git a/guide/example/example-json-22.kt b/guide/example/example-json-22.kt index 84bd0d8a9..e64ab06f0 100644 --- a/guide/example/example-json-22.kt +++ b/guide/example/example-json-22.kt @@ -4,29 +4,7 @@ package example.exampleJson22 import kotlinx.serialization.* import kotlinx.serialization.json.* -import kotlinx.serialization.builtins.* - -@Serializable -data class Project( - val name: String, - @Serializable(with = UserListSerializer::class) - val users: List -) - -@Serializable -data class User(val name: String) - -object UserListSerializer : JsonTransformingSerializer>(ListSerializer(User.serializer())) { - // If response is not an array, then it is a single object that should be wrapped into the array - override fun transformDeserialize(element: JsonElement): JsonElement = - if (element !is JsonArray) JsonArray(listOf(element)) else element -} - fun main() { - println(Json.decodeFromString(""" - {"name":"kotlinx.serialization","users":{"name":"kotlin"}} - """)) - println(Json.decodeFromString(""" - {"name":"kotlinx.serialization","users":[{"name":"kotlin"},{"name":"jetbrains"}]} - """)) + // caution: creating null with JsonUnquotedLiteral will cause an exception! + JsonUnquotedLiteral("null") } diff --git a/guide/example/example-json-23.kt b/guide/example/example-json-23.kt index bb23f5272..ffa9f7d72 100644 --- a/guide/example/example-json-23.kt +++ b/guide/example/example-json-23.kt @@ -17,14 +17,16 @@ data class Project( data class User(val name: String) object UserListSerializer : JsonTransformingSerializer>(ListSerializer(User.serializer())) { - - override fun transformSerialize(element: JsonElement): JsonElement { - require(element is JsonArray) // this serializer is used only with lists - return element.singleOrNull() ?: element - } + // If response is not an array, then it is a single object that should be wrapped into the array + override fun transformDeserialize(element: JsonElement): JsonElement = + if (element !is JsonArray) JsonArray(listOf(element)) else element } fun main() { - val data = Project("kotlinx.serialization", listOf(User("kotlin"))) - println(Json.encodeToString(data)) + println(Json.decodeFromString(""" + {"name":"kotlinx.serialization","users":{"name":"kotlin"}} + """)) + println(Json.decodeFromString(""" + {"name":"kotlinx.serialization","users":[{"name":"kotlin"},{"name":"jetbrains"}]} + """)) } diff --git a/guide/example/example-json-24.kt b/guide/example/example-json-24.kt index def90f203..010bd27df 100644 --- a/guide/example/example-json-24.kt +++ b/guide/example/example-json-24.kt @@ -4,19 +4,27 @@ package example.exampleJson24 import kotlinx.serialization.* import kotlinx.serialization.json.* +import kotlinx.serialization.builtins.* + +@Serializable +data class Project( + val name: String, + @Serializable(with = UserListSerializer::class) + val users: List +) + @Serializable -class Project(val name: String, val language: String) +data class User(val name: String) + +object UserListSerializer : JsonTransformingSerializer>(ListSerializer(User.serializer())) { -object ProjectSerializer : JsonTransformingSerializer(Project.serializer()) { - override fun transformSerialize(element: JsonElement): JsonElement = - // Filter out top-level key value pair with the key "language" and the value "Kotlin" - JsonObject(element.jsonObject.filterNot { - (k, v) -> k == "language" && v.jsonPrimitive.content == "Kotlin" - }) + override fun transformSerialize(element: JsonElement): JsonElement { + require(element is JsonArray) // this serializer is used only with lists + return element.singleOrNull() ?: element + } } fun main() { - val data = Project("kotlinx.serialization", "Kotlin") - println(Json.encodeToString(data)) // using plugin-generated serializer - println(Json.encodeToString(ProjectSerializer, data)) // using custom serializer + val data = Project("kotlinx.serialization", listOf(User("kotlin"))) + println(Json.encodeToString(data)) } diff --git a/guide/example/example-json-25.kt b/guide/example/example-json-25.kt index 6f6d67a0c..a7d19a7fd 100644 --- a/guide/example/example-json-25.kt +++ b/guide/example/example-json-25.kt @@ -4,33 +4,19 @@ package example.exampleJson25 import kotlinx.serialization.* import kotlinx.serialization.json.* -import kotlinx.serialization.builtins.* - -@Serializable -abstract class Project { - abstract val name: String -} - @Serializable -data class BasicProject(override val name: String): Project() - - -@Serializable -data class OwnedProject(override val name: String, val owner: String) : Project() - -object ProjectSerializer : JsonContentPolymorphicSerializer(Project::class) { - override fun selectDeserializer(element: JsonElement) = when { - "owner" in element.jsonObject -> OwnedProject.serializer() - else -> BasicProject.serializer() - } +class Project(val name: String, val language: String) + +object ProjectSerializer : JsonTransformingSerializer(Project.serializer()) { + override fun transformSerialize(element: JsonElement): JsonElement = + // Filter out top-level key value pair with the key "language" and the value "Kotlin" + JsonObject(element.jsonObject.filterNot { + (k, v) -> k == "language" && v.jsonPrimitive.content == "Kotlin" + }) } fun main() { - val data = listOf( - OwnedProject("kotlinx.serialization", "kotlin"), - BasicProject("example") - ) - val string = Json.encodeToString(ListSerializer(ProjectSerializer), data) - println(string) - println(Json.decodeFromString(ListSerializer(ProjectSerializer), string)) + val data = Project("kotlinx.serialization", "Kotlin") + println(Json.encodeToString(data)) // using plugin-generated serializer + println(Json.encodeToString(ProjectSerializer, data)) // using custom serializer } diff --git a/guide/example/example-json-26.kt b/guide/example/example-json-26.kt index c308b6346..b1b92999c 100644 --- a/guide/example/example-json-26.kt +++ b/guide/example/example-json-26.kt @@ -4,56 +4,33 @@ package example.exampleJson26 import kotlinx.serialization.* import kotlinx.serialization.json.* -import kotlinx.serialization.descriptors.* -import kotlinx.serialization.encoding.* +import kotlinx.serialization.builtins.* -@Serializable(with = ResponseSerializer::class) -sealed class Response { - data class Ok(val data: T) : Response() - data class Error(val message: String) : Response() +@Serializable +abstract class Project { + abstract val name: String } -class ResponseSerializer(private val dataSerializer: KSerializer) : KSerializer> { - override val descriptor: SerialDescriptor = buildSerialDescriptor("Response", PolymorphicKind.SEALED) { - element("Ok", dataSerializer.descriptor) - element("Error", buildClassSerialDescriptor("Error") { - element("message") - }) - } +@Serializable +data class BasicProject(override val name: String): Project() - override fun deserialize(decoder: Decoder): Response { - // Decoder -> JsonDecoder - require(decoder is JsonDecoder) // this class can be decoded only by Json - // JsonDecoder -> JsonElement - val element = decoder.decodeJsonElement() - // JsonElement -> value - if (element is JsonObject && "error" in element) - return Response.Error(element["error"]!!.jsonPrimitive.content) - return Response.Ok(decoder.json.decodeFromJsonElement(dataSerializer, element)) - } - override fun serialize(encoder: Encoder, value: Response) { - // Encoder -> JsonEncoder - require(encoder is JsonEncoder) // This class can be encoded only by Json - // value -> JsonElement - val element = when (value) { - is Response.Ok -> encoder.json.encodeToJsonElement(dataSerializer, value.data) - is Response.Error -> buildJsonObject { put("error", value.message) } - } - // JsonElement -> JsonEncoder - encoder.encodeJsonElement(element) +@Serializable +data class OwnedProject(override val name: String, val owner: String) : Project() + +object ProjectSerializer : JsonContentPolymorphicSerializer(Project::class) { + override fun selectDeserializer(element: JsonElement) = when { + "owner" in element.jsonObject -> OwnedProject.serializer() + else -> BasicProject.serializer() } } -@Serializable -data class Project(val name: String) - fun main() { - val responses = listOf( - Response.Ok(Project("kotlinx.serialization")), - Response.Error("Not found") + val data = listOf( + OwnedProject("kotlinx.serialization", "kotlin"), + BasicProject("example") ) - val string = Json.encodeToString(responses) + val string = Json.encodeToString(ListSerializer(ProjectSerializer), data) println(string) - println(Json.decodeFromString>>(string)) + println(Json.decodeFromString(ListSerializer(ProjectSerializer), string)) } diff --git a/guide/example/example-json-27.kt b/guide/example/example-json-27.kt index 219de6ef3..5905733ab 100644 --- a/guide/example/example-json-27.kt +++ b/guide/example/example-json-27.kt @@ -7,31 +7,53 @@ import kotlinx.serialization.json.* import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.* -data class UnknownProject(val name: String, val details: JsonObject) +@Serializable(with = ResponseSerializer::class) +sealed class Response { + data class Ok(val data: T) : Response() + data class Error(val message: String) : Response() +} -object UnknownProjectSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("UnknownProject") { - element("name") - element("details") +class ResponseSerializer(private val dataSerializer: KSerializer) : KSerializer> { + override val descriptor: SerialDescriptor = buildSerialDescriptor("Response", PolymorphicKind.SEALED) { + element("Ok", dataSerializer.descriptor) + element("Error", buildClassSerialDescriptor("Error") { + element("message") + }) } - override fun deserialize(decoder: Decoder): UnknownProject { - // Cast to JSON-specific interface - val jsonInput = decoder as? JsonDecoder ?: error("Can be deserialized only by JSON") - // Read the whole content as JSON - val json = jsonInput.decodeJsonElement().jsonObject - // Extract and remove name property - val name = json.getValue("name").jsonPrimitive.content - val details = json.toMutableMap() - details.remove("name") - return UnknownProject(name, JsonObject(details)) + override fun deserialize(decoder: Decoder): Response { + // Decoder -> JsonDecoder + require(decoder is JsonDecoder) // this class can be decoded only by Json + // JsonDecoder -> JsonElement + val element = decoder.decodeJsonElement() + // JsonElement -> value + if (element is JsonObject && "error" in element) + return Response.Error(element["error"]!!.jsonPrimitive.content) + return Response.Ok(decoder.json.decodeFromJsonElement(dataSerializer, element)) } - override fun serialize(encoder: Encoder, value: UnknownProject) { - error("Serialization is not supported") + override fun serialize(encoder: Encoder, value: Response) { + // Encoder -> JsonEncoder + require(encoder is JsonEncoder) // This class can be encoded only by Json + // value -> JsonElement + val element = when (value) { + is Response.Ok -> encoder.json.encodeToJsonElement(dataSerializer, value.data) + is Response.Error -> buildJsonObject { put("error", value.message) } + } + // JsonElement -> JsonEncoder + encoder.encodeJsonElement(element) } } +@Serializable +data class Project(val name: String) + fun main() { - println(Json.decodeFromString(UnknownProjectSerializer, """{"type":"unknown","name":"example","maintainer":"Unknown","license":"Apache 2.0"}""")) + val responses = listOf( + Response.Ok(Project("kotlinx.serialization")), + Response.Error("Not found") + ) + val string = Json.encodeToString(responses) + println(string) + println(Json.decodeFromString>>(string)) } diff --git a/guide/example/example-json-28.kt b/guide/example/example-json-28.kt new file mode 100644 index 000000000..a3fab6178 --- /dev/null +++ b/guide/example/example-json-28.kt @@ -0,0 +1,37 @@ +// This file was automatically generated from json.md by Knit tool. Do not edit. +package example.exampleJson28 + +import kotlinx.serialization.* +import kotlinx.serialization.json.* + +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* + +data class UnknownProject(val name: String, val details: JsonObject) + +object UnknownProjectSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("UnknownProject") { + element("name") + element("details") + } + + override fun deserialize(decoder: Decoder): UnknownProject { + // Cast to JSON-specific interface + val jsonInput = decoder as? JsonDecoder ?: error("Can be deserialized only by JSON") + // Read the whole content as JSON + val json = jsonInput.decodeJsonElement().jsonObject + // Extract and remove name property + val name = json.getValue("name").jsonPrimitive.content + val details = json.toMutableMap() + details.remove("name") + return UnknownProject(name, JsonObject(details)) + } + + override fun serialize(encoder: Encoder, value: UnknownProject) { + error("Serialization is not supported") + } +} + +fun main() { + println(Json.decodeFromString(UnknownProjectSerializer, """{"type":"unknown","name":"example","maintainer":"Unknown","license":"Apache 2.0"}""")) +} diff --git a/guide/test/JsonTest.kt b/guide/test/JsonTest.kt index 115ef7786..0c5ed85e8 100644 --- a/guide/test/JsonTest.kt +++ b/guide/test/JsonTest.kt @@ -90,52 +90,49 @@ class JsonTest { @Test fun testExampleJson12() { captureOutput("ExampleJson12") { example.exampleJson12.main() }.verifyOutputLines( - "CasesList(cases=[VALUE_A, VALUE_B])" + "{\"name\":\"kotlinx.coroutines\",\"owner\":\"kotlin\"}" ) } @Test fun testExampleJson13() { captureOutput("ExampleJson13") { example.exampleJson13.main() }.verifyOutputLines( - "{\"project_name\":\"kotlinx.serialization\",\"project_owner\":\"Kotlin\"}" + "CasesList(cases=[VALUE_A, VALUE_B])" ) } @Test fun testExampleJson14() { captureOutput("ExampleJson14") { example.exampleJson14.main() }.verifyOutputLines( - "{\"name\":\"kotlinx.serialization\",\"language\":\"Kotlin\"}" + "{\"project_name\":\"kotlinx.serialization\",\"project_owner\":\"Kotlin\"}" ) } @Test fun testExampleJson15() { captureOutput("ExampleJson15") { example.exampleJson15.main() }.verifyOutputLines( - "9042" + "{\"name\":\"kotlinx.serialization\",\"language\":\"Kotlin\"}" ) } @Test fun testExampleJson16() { captureOutput("ExampleJson16") { example.exampleJson16.main() }.verifyOutputLines( - "{\"name\":\"kotlinx.serialization\",\"owner\":{\"name\":\"kotlin\"},\"forks\":[{\"votes\":42},{\"votes\":9000}]}" + "9042" ) } @Test fun testExampleJson17() { captureOutput("ExampleJson17") { example.exampleJson17.main() }.verifyOutputLines( - "Project(name=kotlinx.serialization, language=Kotlin)" + "{\"name\":\"kotlinx.serialization\",\"owner\":{\"name\":\"kotlin\"},\"forks\":[{\"votes\":42},{\"votes\":9000}]}" ) } @Test fun testExampleJson18() { captureOutput("ExampleJson18") { example.exampleJson18.main() }.verifyOutputLines( - "{", - " \"pi_double\": 3.141592653589793,", - " \"pi_string\": \"3.141592653589793238462643383279\"", - "}" + "Project(name=kotlinx.serialization, language=Kotlin)" ) } @@ -143,7 +140,6 @@ class JsonTest { fun testExampleJson19() { captureOutput("ExampleJson19") { example.exampleJson19.main() }.verifyOutputLines( "{", - " \"pi_literal\": 3.141592653589793238462643383279,", " \"pi_double\": 3.141592653589793,", " \"pi_string\": \"3.141592653589793238462643383279\"", "}" @@ -153,59 +149,70 @@ class JsonTest { @Test fun testExampleJson20() { captureOutput("ExampleJson20") { example.exampleJson20.main() }.verifyOutputLines( - "3.141592653589793238462643383279" + "{", + " \"pi_literal\": 3.141592653589793238462643383279,", + " \"pi_double\": 3.141592653589793,", + " \"pi_string\": \"3.141592653589793238462643383279\"", + "}" ) } @Test fun testExampleJson21() { - captureOutput("ExampleJson21") { example.exampleJson21.main() }.verifyOutputLinesStart( - "Exception in thread \"main\" kotlinx.serialization.json.internal.JsonEncodingException: Creating a literal unquoted value of 'null' is forbidden. If you want to create JSON null literal, use JsonNull object, otherwise, use JsonPrimitive" + captureOutput("ExampleJson21") { example.exampleJson21.main() }.verifyOutputLines( + "3.141592653589793238462643383279" ) } @Test fun testExampleJson22() { - captureOutput("ExampleJson22") { example.exampleJson22.main() }.verifyOutputLines( - "Project(name=kotlinx.serialization, users=[User(name=kotlin)])", - "Project(name=kotlinx.serialization, users=[User(name=kotlin), User(name=jetbrains)])" + captureOutput("ExampleJson22") { example.exampleJson22.main() }.verifyOutputLinesStart( + "Exception in thread \"main\" kotlinx.serialization.json.internal.JsonEncodingException: Creating a literal unquoted value of 'null' is forbidden. If you want to create JSON null literal, use JsonNull object, otherwise, use JsonPrimitive" ) } @Test fun testExampleJson23() { captureOutput("ExampleJson23") { example.exampleJson23.main() }.verifyOutputLines( - "{\"name\":\"kotlinx.serialization\",\"users\":{\"name\":\"kotlin\"}}" + "Project(name=kotlinx.serialization, users=[User(name=kotlin)])", + "Project(name=kotlinx.serialization, users=[User(name=kotlin), User(name=jetbrains)])" ) } @Test fun testExampleJson24() { captureOutput("ExampleJson24") { example.exampleJson24.main() }.verifyOutputLines( - "{\"name\":\"kotlinx.serialization\",\"language\":\"Kotlin\"}", - "{\"name\":\"kotlinx.serialization\"}" + "{\"name\":\"kotlinx.serialization\",\"users\":{\"name\":\"kotlin\"}}" ) } @Test fun testExampleJson25() { captureOutput("ExampleJson25") { example.exampleJson25.main() }.verifyOutputLines( - "[{\"name\":\"kotlinx.serialization\",\"owner\":\"kotlin\"},{\"name\":\"example\"}]", - "[OwnedProject(name=kotlinx.serialization, owner=kotlin), BasicProject(name=example)]" + "{\"name\":\"kotlinx.serialization\",\"language\":\"Kotlin\"}", + "{\"name\":\"kotlinx.serialization\"}" ) } @Test fun testExampleJson26() { captureOutput("ExampleJson26") { example.exampleJson26.main() }.verifyOutputLines( - "[{\"name\":\"kotlinx.serialization\"},{\"error\":\"Not found\"}]", - "[Ok(data=Project(name=kotlinx.serialization)), Error(message=Not found)]" + "[{\"name\":\"kotlinx.serialization\",\"owner\":\"kotlin\"},{\"name\":\"example\"}]", + "[OwnedProject(name=kotlinx.serialization, owner=kotlin), BasicProject(name=example)]" ) } @Test fun testExampleJson27() { captureOutput("ExampleJson27") { example.exampleJson27.main() }.verifyOutputLines( + "[{\"name\":\"kotlinx.serialization\"},{\"error\":\"Not found\"}]", + "[Ok(data=Project(name=kotlinx.serialization)), Error(message=Not found)]" + ) + } + + @Test + fun testExampleJson28() { + captureOutput("ExampleJson28") { example.exampleJson28.main() }.verifyOutputLines( "UnknownProject(name=example, details={\"type\":\"unknown\",\"maintainer\":\"Unknown\",\"license\":\"Apache 2.0\"})" ) }