diff options
author | Leonid Startsev <sandwwraith@users.noreply.github.com> | 2023-07-05 19:18:17 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-07-05 19:18:17 +0200 |
commit | 782b9f3be9970e0fd36215a86bf7fdba9f2bfe83 (patch) | |
tree | dcec941bc829929cd66d004825c5eb728e5d8d23 | |
parent | a87b0f1d89f927896bbeeefa4377bfdeafbf7cb0 (diff) | |
download | kotlinx.serialization-782b9f3be9970e0fd36215a86bf7fdba9f2bfe83.tar.gz |
Introduce 'decodeEnumsCaseInsensitive' feature to Json. (#2345)
It allows decoding enum values in a case-insensitive manner. It does not affect CLASS kinds or encoding. It is one of the most-voted feature requests.
Also enhance JsonNamingStrategy documentation.
Fixes #209
27 files changed, 536 insertions, 255 deletions
diff --git a/docs/json.md b/docs/json.md index 9fd8cac4..07476f97 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) + * [Decoding enums in a case-insensitive manner](#decoding-enums-in-a-case-insensitive-manner) * [Global naming strategy](#global-naming-strategy) * [Json elements](#json-elements) * [Parsing to Json element](#parsing-to-json-element) @@ -469,6 +470,39 @@ As you can see, discriminator from the `Base` class is used: <!--- TEST --> +### Decoding enums in a case-insensitive manner + +[Kotlin's naming policy recommends](https://kotlinlang.org/docs/coding-conventions.html#property-names) naming enum values +using either uppercase underscore-separated names or upper camel case names. +[Json] uses exact Kotlin enum values names for decoding by default. +However, sometimes third-party JSONs have such values named in lowercase or some mixed case. +In this case, it is possible to decode enum values in a case-insensitive manner using [JsonBuilder.decodeEnumsCaseInsensitive] property: + +```kotlin +val format = Json { decodeEnumsCaseInsensitive = true } + +enum class Cases { VALUE_A, @JsonNames("Alternative") VALUE_B } + +@Serializable +data class CasesList(val cases: List<Cases>) + +fun main() { + println(format.decodeFromString<CasesList>("""{"cases":["value_A", "alternative"]}""")) +} +``` + +> You can get the full code [here](../guide/example/example-json-12.kt). + +It affects serial names as well as alternative names specified with [JsonNames] annotation, so both values are successfully decoded: + +```text +CasesList(cases=[VALUE_A, VALUE_B]) +``` + +This property does not affect encoding in any way. + +<!--- TEST --> + ### Global naming strategy If properties' names in Json input are different from Kotlin ones, it is recommended to specify the name @@ -489,7 +523,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). As you can see, both serialization and deserialization work as if all serial names are transformed from camel case to snake case: @@ -541,7 +575,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). A `JsonElement` prints itself as a valid JSON: @@ -584,7 +618,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). The above example sums `votes` in all objects in the `forks` array, ignoring the objects that have no `votes`: @@ -624,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). As a result, you get a proper JSON string: @@ -653,7 +687,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). The result is exactly what you would expect: @@ -699,7 +733,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). 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. @@ -739,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). `pi_literal` now accurately matches the value defined. @@ -779,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). The exact value of `pi` is decoded, with all 30 decimal places of precision that were in the source JSON. @@ -801,7 +835,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). ```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 @@ -877,7 +911,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). The output shows that both cases are correctly deserialized into a Kotlin [List]. @@ -929,7 +963,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). You end up with a single JSON object, not an array with one element: @@ -974,7 +1008,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). See the effect of the custom serializer: @@ -1047,7 +1081,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). No class discriminator is added in the JSON output: @@ -1143,7 +1177,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). This gives you fine-grained control on the representation of the `Response` class in the JSON output: @@ -1208,7 +1242,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). ```text UnknownProject(name=example, details={"type":"unknown","maintainer":"Unknown","license":"Apache 2.0"}) @@ -1262,6 +1296,7 @@ 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.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 [JsonElement]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-element/index.html diff --git a/docs/serialization-guide.md b/docs/serialization-guide.md index 7048206a..445d32e3 100644 --- a/docs/serialization-guide.md +++ b/docs/serialization-guide.md @@ -119,6 +119,7 @@ Once the project is set up, we can start serializing some classes. * <a name='allowing-structured-map-keys'></a>[Allowing structured map keys](json.md#allowing-structured-map-keys) * <a name='allowing-special-floating-point-values'></a>[Allowing special floating-point values](json.md#allowing-special-floating-point-values) * <a name='class-discriminator-for-polymorphism'></a>[Class discriminator for polymorphism](json.md#class-discriminator-for-polymorphism) + * <a name='decoding-enums-in-a-case-insensitive-manner'></a>[Decoding enums in a case-insensitive manner](json.md#decoding-enums-in-a-case-insensitive-manner) * <a name='global-naming-strategy'></a>[Global naming strategy](json.md#global-naming-strategy) * <a name='json-elements'></a>[Json elements](json.md#json-elements) * <a name='parsing-to-json-element'></a>[Parsing to Json element](json.md#parsing-to-json-element) diff --git a/formats/json-tests/commonTest/src/kotlinx/serialization/features/JsonEnumsCaseInsensitiveTest.kt b/formats/json-tests/commonTest/src/kotlinx/serialization/features/JsonEnumsCaseInsensitiveTest.kt new file mode 100644 index 00000000..ce80a346 --- /dev/null +++ b/formats/json-tests/commonTest/src/kotlinx/serialization/features/JsonEnumsCaseInsensitiveTest.kt @@ -0,0 +1,171 @@ +package kotlinx.serialization.features + +import kotlinx.serialization.* +import kotlinx.serialization.json.* +import kotlinx.serialization.test.* +import kotlin.test.* + +@Suppress("EnumEntryName") +class JsonEnumsCaseInsensitiveTest: JsonTestBase() { + @Serializable + data class Foo( + val one: Bar = Bar.BAZ, + val two: Bar = Bar.QUX, + val three: Bar = Bar.QUX + ) + + enum class Bar { BAZ, QUX } + + // It seems that we no longer report a warning that @Serializable is required for enums with @SerialName. + // It is still required for them to work at top-level. + @Serializable + enum class Cases { + ALL_CAPS, + MiXed, + all_lower, + + @JsonNames("AltName") + hasAltNames, + + @SerialName("SERIAL_NAME") + hasSerialName + } + + @Serializable + data class EnumCases(val cases: List<Cases>) + + val json = Json(default) { decodeEnumsCaseInsensitive = true } + + @Test + fun testCases() = noLegacyJs { parametrizedTest { mode -> + val input = + """{"cases":["ALL_CAPS","all_caps","mixed","MIXED","miXed","all_lower","ALL_LOWER","all_Lower","hasAltNames","HASALTNAMES","altname","ALTNAME","AltName","SERIAL_NAME","serial_name"]}""" + val target = listOf( + Cases.ALL_CAPS, + Cases.ALL_CAPS, + Cases.MiXed, + Cases.MiXed, + Cases.MiXed, + Cases.all_lower, + Cases.all_lower, + Cases.all_lower, + Cases.hasAltNames, + Cases.hasAltNames, + Cases.hasAltNames, + Cases.hasAltNames, + Cases.hasAltNames, + Cases.hasSerialName, + Cases.hasSerialName + ) + val decoded = json.decodeFromString<EnumCases>(input, mode) + assertEquals(EnumCases(target), decoded) + val encoded = json.encodeToString(decoded, mode) + assertEquals( + """{"cases":["ALL_CAPS","ALL_CAPS","MiXed","MiXed","MiXed","all_lower","all_lower","all_lower","hasAltNames","hasAltNames","hasAltNames","hasAltNames","hasAltNames","SERIAL_NAME","SERIAL_NAME"]}""", + encoded + ) + }} + + @Test + fun testTopLevelList() = noLegacyJs { parametrizedTest { mode -> + val input = """["all_caps","serial_name"]""" + val decoded = json.decodeFromString<List<Cases>>(input, mode) + assertEquals(listOf(Cases.ALL_CAPS, Cases.hasSerialName), decoded) + assertEquals("""["ALL_CAPS","SERIAL_NAME"]""", json.encodeToString(decoded, mode)) + }} + + @Test + fun testTopLevelEnum() = noLegacyJs { parametrizedTest { mode -> + val input = """"altName"""" + val decoded = json.decodeFromString<Cases>(input, mode) + assertEquals(Cases.hasAltNames, decoded) + assertEquals(""""hasAltNames"""", json.encodeToString(decoded, mode)) + }} + + @Test + fun testSimpleCase() = parametrizedTest { mode -> + val input = """{"one":"baz","two":"Qux","three":"QUX"}""" + val decoded = json.decodeFromString<Foo>(input, mode) + assertEquals(Foo(), decoded) + assertEquals("""{"one":"BAZ","two":"QUX","three":"QUX"}""", json.encodeToString(decoded, mode)) + } + + enum class E { VALUE_A, @JsonNames("ALTERNATIVE") VALUE_B } + + @Test + fun testDocSample() = noLegacyJs { + + val j = Json { decodeEnumsCaseInsensitive = true } + @Serializable + data class Outer(val enums: List<E>) + + println(j.decodeFromString<Outer>("""{"enums":["value_A", "alternative"]}""").enums) + } + + @Test + fun testCoercingStillWorks() = parametrizedTest { mode -> + val withCoercing = Json(json) { coerceInputValues = true } + val input = """{"one":"baz","two":"unknown","three":"Que"}""" + assertEquals(Foo(), withCoercing.decodeFromString<Foo>(input, mode)) + } + + @Test + fun testCaseInsensitivePriorityOverCoercing() = parametrizedTest { mode -> + val withCoercing = Json(json) { coerceInputValues = true } + val input = """{"one":"QuX","two":"Baz","three":"Que"}""" + assertEquals(Foo(Bar.QUX, Bar.BAZ, Bar.QUX), withCoercing.decodeFromString<Foo>(input, mode)) + } + + @Test + fun testCoercingStillWorksWithNulls() = parametrizedTest { mode -> + val withCoercing = Json(json) { coerceInputValues = true } + val input = """{"one":"baz","two":"null","three":null}""" + assertEquals(Foo(), withCoercing.decodeFromString<Foo>(input, mode)) + } + + @Test + fun testFeatureDisablesProperly() = parametrizedTest { mode -> + val disabled = Json(json) { + coerceInputValues = true + decodeEnumsCaseInsensitive = false + } + val input = """{"one":"BAZ","two":"BAz","three":"baz"}""" // two and three should be coerced to QUX + assertEquals(Foo(), disabled.decodeFromString<Foo>(input, mode)) + } + + @Test + fun testFeatureDisabledThrowsWithoutCoercing() = parametrizedTest { mode -> + val disabled = Json(json) { + coerceInputValues = false + decodeEnumsCaseInsensitive = false + } + val input = """{"one":"BAZ","two":"BAz","three":"baz"}""" + assertFailsWithMessage<SerializationException>("does not contain element with name 'BAz'") { + disabled.decodeFromString<Foo>(input, mode) + } + } + + @Serializable enum class BadEnum { Bad, BAD } + + @Serializable data class ListBadEnum(val l: List<BadEnum>) + + @Test + fun testLowercaseClashThrowsException() = parametrizedTest { mode -> + assertFailsWithMessage<SerializationException>("""The suggested name 'bad' for enum value BAD is already one of the names for enum value Bad""") { + // an explicit serializer is required for JSLegacy + json.decodeFromString(Box.serializer(BadEnum.serializer()),"""{"boxed":"bad"}""", mode) + } + assertFailsWithMessage<SerializationException>("""The suggested name 'bad' for enum value BAD is already one of the names for enum value Bad""") { + json.decodeFromString(Box.serializer(BadEnum.serializer()),"""{"boxed":"unrelated"}""", mode) + } + } + + @Test + fun testLowercaseClashHandledWithoutFeature() = parametrizedTest { mode -> + val disabled = Json(json) { + coerceInputValues = false + decodeEnumsCaseInsensitive = false + } + assertEquals(ListBadEnum(listOf(BadEnum.Bad, BadEnum.BAD)), disabled.decodeFromString("""{"l":["Bad","BAD"]}""")) + } +} diff --git a/formats/json-tests/commonTest/src/kotlinx/serialization/features/JsonNamingStrategyTest.kt b/formats/json-tests/commonTest/src/kotlinx/serialization/features/JsonNamingStrategyTest.kt index 330d5d2b..68f36def 100644 --- a/formats/json-tests/commonTest/src/kotlinx/serialization/features/JsonNamingStrategyTest.kt +++ b/formats/json-tests/commonTest/src/kotlinx/serialization/features/JsonNamingStrategyTest.kt @@ -24,6 +24,7 @@ class JsonNamingStrategyTest : JsonTestBase() { val jsonWithNaming = Json(default) { namingStrategy = JsonNamingStrategy.SnakeCase + decodeEnumsCaseInsensitive = true // check that related feature does not break anything } @Test diff --git a/formats/json-tests/commonTest/src/kotlinx/serialization/test/TestingFramework.kt b/formats/json-tests/commonTest/src/kotlinx/serialization/test/TestingFramework.kt index b46afe69..e941f047 100644 --- a/formats/json-tests/commonTest/src/kotlinx/serialization/test/TestingFramework.kt +++ b/formats/json-tests/commonTest/src/kotlinx/serialization/test/TestingFramework.kt @@ -78,7 +78,7 @@ inline fun assertFailsWithSerialMessage( ) assertTrue( exception.message!!.contains(message), - "expected:<${exception.message}> but was:<$message>" + "expected:<$message> but was:<${exception.message}>" ) } inline fun <reified T : Throwable> assertFailsWithMessage( @@ -89,6 +89,6 @@ inline fun <reified T : Throwable> assertFailsWithMessage( val exception = assertFailsWith(T::class, assertionMessage, block) assertTrue( exception.message!!.contains(message), - "expected:<${exception.message}> but was:<$message>" + "expected:<$message> but was:<${exception.message}>" ) } diff --git a/formats/json/api/kotlinx-serialization-json.api b/formats/json/api/kotlinx-serialization-json.api index 663bd997..ec79e13d 100644 --- a/formats/json/api/kotlinx-serialization-json.api +++ b/formats/json/api/kotlinx-serialization-json.api @@ -88,6 +88,7 @@ public final class kotlinx/serialization/json/JsonBuilder { public final fun getAllowStructuredMapKeys ()Z public final fun getClassDiscriminator ()Ljava/lang/String; public final fun getCoerceInputValues ()Z + public final fun getDecodeEnumsCaseInsensitive ()Z public final fun getEncodeDefaults ()Z public final fun getExplicitNulls ()Z public final fun getIgnoreUnknownKeys ()Z @@ -102,6 +103,7 @@ public final class kotlinx/serialization/json/JsonBuilder { public final fun setAllowStructuredMapKeys (Z)V public final fun setClassDiscriminator (Ljava/lang/String;)V public final fun setCoerceInputValues (Z)V + public final fun setDecodeEnumsCaseInsensitive (Z)V public final fun setEncodeDefaults (Z)V public final fun setExplicitNulls (Z)V public final fun setIgnoreUnknownKeys (Z)V @@ -129,6 +131,7 @@ public final class kotlinx/serialization/json/JsonConfiguration { public final fun getAllowStructuredMapKeys ()Z public final fun getClassDiscriminator ()Ljava/lang/String; public final fun getCoerceInputValues ()Z + public final fun getDecodeEnumsCaseInsensitive ()Z public final fun getEncodeDefaults ()Z public final fun getExplicitNulls ()Z public final fun getIgnoreUnknownKeys ()Z diff --git a/formats/json/commonMain/src/kotlinx/serialization/json/Json.kt b/formats/json/commonMain/src/kotlinx/serialization/json/Json.kt index 443f1dc3..40dcc23e 100644 --- a/formats/json/commonMain/src/kotlinx/serialization/json/Json.kt +++ b/formats/json/commonMain/src/kotlinx/serialization/json/Json.kt @@ -288,7 +288,7 @@ public class JsonBuilder internal constructor(json: Json) { /** * Enables coercing incorrect JSON values to the default property value in the following cases: - * 1. JSON value is `null` but property type is non-nullable. + * 1. JSON value is `null` but the property type is non-nullable. * 2. Property type is an enum type, but JSON value contains unknown enum member. * * `false` by default. @@ -336,6 +336,35 @@ public class JsonBuilder internal constructor(json: Json) { public var namingStrategy: JsonNamingStrategy? = json.configuration.namingStrategy /** + * Enables decoding enum values in a case-insensitive manner. + * Encoding is not affected. + * + * This affects both enum serial names and alternative names (specified with the [JsonNames] annotation). + * In the following example, string `[VALUE_A, VALUE_B]` will be printed: + * ``` + * enum class E { VALUE_A, @JsonNames("ALTERNATIVE") VALUE_B } + * + * @Serializable + * data class Outer(val enums: List<E>) + * + * val j = Json { decodeEnumsCaseInsensitive = true } + * println(j.decodeFromString<Outer>("""{"enums":["value_A", "alternative"]}""").enums) + * ``` + * + * If this feature is enabled, + * it is no longer possible to decode enum values that have the same name in a lowercase form. + * The following code will throw a serialization exception: + * + * ``` + * enum class BadEnum { Bad, BAD } + * val j = Json { decodeEnumsCaseInsensitive = true } + * j.decodeFromString<Box<BadEnum>>("""{"boxed":"bad"}""") + * ``` + */ + @ExperimentalSerializationApi + public var decodeEnumsCaseInsensitive: Boolean = json.configuration.decodeEnumsCaseInsensitive + + /** * Module with contextual and polymorphic serializers to be used in the resulting [Json] instance. * * @see SerializersModule @@ -367,7 +396,7 @@ public class JsonBuilder internal constructor(json: Json) { allowStructuredMapKeys, prettyPrint, explicitNulls, prettyPrintIndent, coerceInputValues, useArrayPolymorphism, classDiscriminator, allowSpecialFloatingPointValues, useAlternativeNames, - namingStrategy + namingStrategy, decodeEnumsCaseInsensitive ) } } diff --git a/formats/json/commonMain/src/kotlinx/serialization/json/JsonConfiguration.kt b/formats/json/commonMain/src/kotlinx/serialization/json/JsonConfiguration.kt index d17d0fcc..ea653a64 100644 --- a/formats/json/commonMain/src/kotlinx/serialization/json/JsonConfiguration.kt +++ b/formats/json/commonMain/src/kotlinx/serialization/json/JsonConfiguration.kt @@ -9,7 +9,7 @@ import kotlinx.serialization.* * Can be used for debug purposes and for custom Json-specific serializers * via [JsonEncoder] and [JsonDecoder]. * - * Standalone configuration object is meaningless and can nor be used outside of the + * Standalone configuration object is meaningless and can nor be used outside the * [Json], neither new [Json] instance can be created from it. * * Detailed description of each property is available in [JsonBuilder] class. @@ -31,6 +31,8 @@ public class JsonConfiguration @OptIn(ExperimentalSerializationApi::class) inter public val useAlternativeNames: Boolean = true, @ExperimentalSerializationApi public val namingStrategy: JsonNamingStrategy? = null, + @ExperimentalSerializationApi + public val decodeEnumsCaseInsensitive: Boolean = false ) { /** @suppress Dokka **/ @@ -40,6 +42,6 @@ public class JsonConfiguration @OptIn(ExperimentalSerializationApi::class) inter "allowStructuredMapKeys=$allowStructuredMapKeys, prettyPrint=$prettyPrint, explicitNulls=$explicitNulls, " + "prettyPrintIndent='$prettyPrintIndent', coerceInputValues=$coerceInputValues, useArrayPolymorphism=$useArrayPolymorphism, " + "classDiscriminator='$classDiscriminator', allowSpecialFloatingPointValues=$allowSpecialFloatingPointValues, useAlternativeNames=$useAlternativeNames, " + - "namingStrategy=$namingStrategy)" + "namingStrategy=$namingStrategy, decodeEnumsCaseInsensitive=$decodeEnumsCaseInsensitive)" } } diff --git a/formats/json/commonMain/src/kotlinx/serialization/json/JsonNamingStrategy.kt b/formats/json/commonMain/src/kotlinx/serialization/json/JsonNamingStrategy.kt index 060572af..64b4e0b7 100644 --- a/formats/json/commonMain/src/kotlinx/serialization/json/JsonNamingStrategy.kt +++ b/formats/json/commonMain/src/kotlinx/serialization/json/JsonNamingStrategy.kt @@ -7,10 +7,11 @@ import kotlinx.serialization.descriptors.* /** * Represents naming strategy — a transformer for serial names in a [Json] format. * Transformed serial names are used for both serialization and deserialization. - * Actual transformation happens in the [serialNameForJson] function. * A naming strategy is always applied globally in the Json configuration builder * (see [JsonBuilder.namingStrategy]). - * However, it is possible to apply additional filtering inside the transformer using the `descriptor` parameter in [serialNameForJson]. + * + * Actual transformation happens in the [serialNameForJson] function. + * It is possible to apply additional filtering inside the transformer using the `descriptor` parameter in [serialNameForJson]. * * Original serial names are never used after transformation, so they are ignored in a Json input. * If the original serial name is present in the Json input but transformed is not, @@ -21,7 +22,7 @@ import kotlinx.serialization.descriptors.* * * * Due to the nature of kotlinx.serialization framework, naming strategy transformation is applied to all properties regardless * of whether their serial name was taken from the property name or provided by @[SerialName] annotation. - * Effectively it means one cannot avoid transformation by explicitly specifying the serial name. + * Effectively, it means one cannot avoid transformation by explicitly specifying the serial name. * * * Collision of the transformed name with any other (transformed) properties serial names or any alternative names * specified with [JsonNames] will lead to a deserialization exception. @@ -40,7 +41,7 @@ import kotlinx.serialization.descriptors.* * changing one without the other may introduce bugs in many unexpected ways. * The lack of a single place of definition, the inability to use automated tools, and more error-prone code lead * to greater maintenance efforts for code with global naming strategies. - * However, there are cases where usage of naming strategies is inevitable, such as interop with existing API or migrating a large codebase. + * However, there are cases where usage of naming strategies is inevitable, such as interop with an existing API or migrating a large codebase. * Therefore, one should carefully weigh the pros and cons before considering adding global naming strategies to an application. */ @ExperimentalSerializationApi @@ -56,7 +57,7 @@ public fun interface JsonNamingStrategy { * annotations (see [SerialDescriptor.getElementAnnotations]) or element optionality (see [SerialDescriptor.isElementOptional]). * * Note that invocations of this function are cached for performance reasons. - * Caching strategy is an implementation detail and shouldn't be assumed as a part of the public API contract, as it may be changed in future releases. + * Caching strategy is an implementation detail and should not be assumed as a part of the public API contract, as it may be changed in future releases. * Therefore, it is essential for this function to be pure: it should not have any side effects, and it should * return the same String for a given [descriptor], [elementIndex], and [serialName], regardless of the number of invocations. */ @@ -74,7 +75,7 @@ public fun interface JsonNamingStrategy { * * **Transformation rules** * - * Words bounds are defined by uppercase characters. If there is a single uppercase char, it is transformed into lowercase one with underscore in front: + * Words' bounds are defined by uppercase characters. If there is a single uppercase char, it is transformed into lowercase one with underscore in front: * `twoWords` -> `two_words`. No underscore is added if it was a beginning of the name: `MyProperty` -> `my_property`. Also, no underscore is added if it was already there: * `camel_Case_Underscores` -> `camel_case_underscores`. * 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 fc9cc19b..8acd8fc4 100644 --- a/formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonNamesMap.kt +++ b/formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonNamesMap.kt @@ -17,9 +17,10 @@ internal val JsonSerializationNamesKey = DescriptorSchemaCache.Key<Array<String> private fun SerialDescriptor.buildDeserializationNamesMap(json: Json): Map<String, Int> { fun MutableMap<String, Int>.putOrThrow(name: String, index: Int) { + val entity = if (kind == SerialKind.ENUM) "enum value" else "property" if (name in this) { throw JsonException( - "The suggested name '$name' for property ${getElementName(index)} is already one of the names for property " + + "The suggested name '$name' for $entity ${getElementName(index)} is already one of the names for $entity " + "${getElementName(getValue(name))} in ${this@buildDeserializationNamesMap}" ) } @@ -28,12 +29,19 @@ private fun SerialDescriptor.buildDeserializationNamesMap(json: Json): Map<Strin val builder: MutableMap<String, Int> = mutableMapOf() // can be not concurrent because it is only read after creation and safely published to concurrent map - val strategy = namingStrategy(json) + val useLowercaseEnums = json.decodeCaseInsensitive(this) + val strategyForClasses = namingStrategy(json) for (i in 0 until elementsCount) { getElementAnnotations(i).filterIsInstance<JsonNames>().singleOrNull()?.names?.forEach { name -> - builder.putOrThrow(name, i) + builder.putOrThrow(if (useLowercaseEnums) name.lowercase() else name, i) + } + val nameToPut = when { + // the branches do not intersect because useLowercase = true for enums only, and strategy != null for classes only. + useLowercaseEnums -> getElementName(i).lowercase() + strategyForClasses != null -> strategyForClasses.serialNameForJson(this, i, getElementName(i)) + else -> null } - strategy?.let { builder.putOrThrow(it.serialNameForJson(this, i, getElementName(i)), i) } + nameToPut?.let { builder.putOrThrow(it, i) } } return builder.ifEmpty { emptyMap() } } @@ -61,17 +69,24 @@ internal fun SerialDescriptor.getJsonElementName(json: Json, index: Int): String internal fun SerialDescriptor.namingStrategy(json: Json) = if (kind == StructureKind.CLASS) json.configuration.namingStrategy else null +private fun SerialDescriptor.getJsonNameIndexSlowPath(json: Json, name: String): Int = + json.deserializationNamesMap(this)[name] ?: CompositeDecoder.UNKNOWN_NAME + +private fun Json.decodeCaseInsensitive(descriptor: SerialDescriptor) = + configuration.decodeEnumsCaseInsensitive && descriptor.kind == SerialKind.ENUM + /** - * Serves same purpose as [SerialDescriptor.getElementIndex] but respects - * [JsonNames] annotation and [JsonConfiguration.useAlternativeNames] state. + * Serves same purpose as [SerialDescriptor.getElementIndex] but respects [JsonNames] annotation + * and [JsonConfiguration] settings. */ @OptIn(ExperimentalSerializationApi::class) internal fun SerialDescriptor.getJsonNameIndex(json: Json, name: String): Int { - fun getJsonNameIndexSlowPath(): Int = - json.deserializationNamesMap(this)[name] ?: CompositeDecoder.UNKNOWN_NAME + if (json.decodeCaseInsensitive(this)) { + return getJsonNameIndexSlowPath(json, name.lowercase()) + } val strategy = namingStrategy(json) - if (strategy != null) return getJsonNameIndexSlowPath() + if (strategy != null) return getJsonNameIndexSlowPath(json, name) val index = getElementIndex(name) // Fast path, do not go through ConcurrentHashMap.get // Note, it blocks ability to detect collisions between the primary name and alternate, @@ -79,7 +94,7 @@ internal fun SerialDescriptor.getJsonNameIndex(json: Json, name: String): Int { if (index != CompositeDecoder.UNKNOWN_NAME) return index if (!json.configuration.useAlternativeNames) return index // Slow path - return getJsonNameIndexSlowPath() + return getJsonNameIndexSlowPath(json, name) } /** diff --git a/guide/example/example-json-12.kt b/guide/example/example-json-12.kt index 5a2cbbe4..1a37516b 100644 --- a/guide/example/example-json-12.kt +++ b/guide/example/example-json-12.kt @@ -4,12 +4,13 @@ package example.exampleJson12 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<Cases>) fun main() { - val project = format.decodeFromString<Project>("""{"project_name":"kotlinx.coroutines", "project_owner":"Kotlin"}""") - println(format.encodeToString(project.copy(projectName = "kotlinx.serialization"))) + println(format.decodeFromString<CasesList>("""{"cases":["value_A", "alternative"]}""")) } diff --git a/guide/example/example-json-13.kt b/guide/example/example-json-13.kt index 86a57c82..cd7cf7f2 100644 --- a/guide/example/example-json-13.kt +++ b/guide/example/example-json-13.kt @@ -4,9 +4,12 @@ package example.exampleJson13 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>("""{"project_name":"kotlinx.coroutines", "project_owner":"Kotlin"}""") + println(format.encodeToString(project.copy(projectName = "kotlinx.serialization"))) } diff --git a/guide/example/example-json-14.kt b/guide/example/example-json-14.kt index 8025e58b..98464dcd 100644 --- a/guide/example/example-json-14.kt +++ b/guide/example/example-json-14.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-15.kt b/guide/example/example-json-15.kt index 2f2b33a4..72fd23eb 100644 --- a/guide/example/example-json-15.kt +++ b/guide/example/example-json-15.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-16.kt b/guide/example/example-json-16.kt index d502c864..cff8ec7d 100644 --- a/guide/example/example-json-16.kt +++ b/guide/example/example-json-16.kt @@ -4,14 +4,20 @@ package example.exampleJson16 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<Project>(element) - println(data) + println(element) } diff --git a/guide/example/example-json-17.kt b/guide/example/example-json-17.kt index b51b48c5..25be7584 100644 --- a/guide/example/example-json-17.kt +++ b/guide/example/example-json-17.kt @@ -4,20 +4,14 @@ package example.exampleJson17 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<Project>(element) + println(data) } diff --git a/guide/example/example-json-18.kt b/guide/example/example-json-18.kt index fbbbffe3..2a1add45 100644 --- a/guide/example/example-json-18.kt +++ b/guide/example/example-json-18.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-19.kt b/guide/example/example-json-19.kt index 2ed79cb2..d59bf26b 100644 --- a/guide/example/example-json-19.kt +++ b/guide/example/example-json-19.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-20.kt b/guide/example/example-json-20.kt index 638ccde9..2f481daa 100644 --- a/guide/example/example-json-20.kt +++ b/guide/example/example-json-20.kt @@ -4,7 +4,20 @@ package example.exampleJson20 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-21.kt b/guide/example/example-json-21.kt index 3f1b2477..86a4b734 100644 --- a/guide/example/example-json-21.kt +++ b/guide/example/example-json-21.kt @@ -4,29 +4,7 @@ package example.exampleJson21 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<User> -) - -@Serializable -data class User(val name: String) - -object UserListSerializer : JsonTransformingSerializer<List<User>>(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<Project>(""" - {"name":"kotlinx.serialization","users":{"name":"kotlin"}} - """)) - println(Json.decodeFromString<Project>(""" - {"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-22.kt b/guide/example/example-json-22.kt index 58b7b261..84bd0d8a 100644 --- a/guide/example/example-json-22.kt +++ b/guide/example/example-json-22.kt @@ -17,14 +17,16 @@ data class Project( data class User(val name: String) object UserListSerializer : JsonTransformingSerializer<List<User>>(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<Project>(""" + {"name":"kotlinx.serialization","users":{"name":"kotlin"}} + """)) + println(Json.decodeFromString<Project>(""" + {"name":"kotlinx.serialization","users":[{"name":"kotlin"},{"name":"jetbrains"}]} + """)) } diff --git a/guide/example/example-json-23.kt b/guide/example/example-json-23.kt index 3b553e25..bb23f527 100644 --- a/guide/example/example-json-23.kt +++ b/guide/example/example-json-23.kt @@ -4,19 +4,27 @@ package example.exampleJson23 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<User> +) + @Serializable -class Project(val name: String, val language: String) +data class User(val name: String) + +object UserListSerializer : JsonTransformingSerializer<List<User>>(ListSerializer(User.serializer())) { -object ProjectSerializer : JsonTransformingSerializer<Project>(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-24.kt b/guide/example/example-json-24.kt index 19fbbea6..def90f20 100644 --- a/guide/example/example-json-24.kt +++ b/guide/example/example-json-24.kt @@ -4,33 +4,19 @@ package example.exampleJson24 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>(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>(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-25.kt b/guide/example/example-json-25.kt index 94c9deee..6f6d67a0 100644 --- a/guide/example/example-json-25.kt +++ b/guide/example/example-json-25.kt @@ -4,56 +4,33 @@ package example.exampleJson25 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<out T> { - data class Ok<out T>(val data: T) : Response<T>() - data class Error(val message: String) : Response<Nothing>() +@Serializable +abstract class Project { + abstract val name: String } -class ResponseSerializer<T>(private val dataSerializer: KSerializer<T>) : KSerializer<Response<T>> { - override val descriptor: SerialDescriptor = buildSerialDescriptor("Response", PolymorphicKind.SEALED) { - element("Ok", buildClassSerialDescriptor("Ok") { - element<String>("message") - }) - element("Error", dataSerializer.descriptor) - } +@Serializable +data class BasicProject(override val name: String): Project() - override fun deserialize(decoder: Decoder): Response<T> { - // 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<T>) { - // 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>(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<List<Response<Project>>>(string)) + println(Json.decodeFromString(ListSerializer(ProjectSerializer), string)) } diff --git a/guide/example/example-json-26.kt b/guide/example/example-json-26.kt index 9e4b5857..32b2fb65 100644 --- a/guide/example/example-json-26.kt +++ b/guide/example/example-json-26.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<out T> { + data class Ok<out T>(val data: T) : Response<T>() + data class Error(val message: String) : Response<Nothing>() +} -object UnknownProjectSerializer : KSerializer<UnknownProject> { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("UnknownProject") { - element<String>("name") - element<JsonElement>("details") +class ResponseSerializer<T>(private val dataSerializer: KSerializer<T>) : KSerializer<Response<T>> { + override val descriptor: SerialDescriptor = buildSerialDescriptor("Response", PolymorphicKind.SEALED) { + element("Ok", buildClassSerialDescriptor("Ok") { + element<String>("message") + }) + element("Error", dataSerializer.descriptor) } - 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<T> { + // 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<T>) { + // 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<List<Response<Project>>>(string)) } diff --git a/guide/example/example-json-27.kt b/guide/example/example-json-27.kt new file mode 100644 index 00000000..219de6ef --- /dev/null +++ b/guide/example/example-json-27.kt @@ -0,0 +1,37 @@ +// This file was automatically generated from json.md by Knit tool. Do not edit. +package example.exampleJson27 + +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<UnknownProject> { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("UnknownProject") { + element<String>("name") + element<JsonElement>("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 8a081f01..115ef778 100644 --- a/guide/test/JsonTest.kt +++ b/guide/test/JsonTest.kt @@ -90,45 +90,42 @@ class JsonTest { @Test fun testExampleJson12() { captureOutput("ExampleJson12") { example.exampleJson12.main() }.verifyOutputLines( - "{\"project_name\":\"kotlinx.serialization\",\"project_owner\":\"Kotlin\"}" + "CasesList(cases=[VALUE_A, VALUE_B])" ) } @Test fun testExampleJson13() { captureOutput("ExampleJson13") { example.exampleJson13.main() }.verifyOutputLines( - "{\"name\":\"kotlinx.serialization\",\"language\":\"Kotlin\"}" + "{\"project_name\":\"kotlinx.serialization\",\"project_owner\":\"Kotlin\"}" ) } @Test fun testExampleJson14() { captureOutput("ExampleJson14") { example.exampleJson14.main() }.verifyOutputLines( - "9042" + "{\"name\":\"kotlinx.serialization\",\"language\":\"Kotlin\"}" ) } @Test fun testExampleJson15() { captureOutput("ExampleJson15") { example.exampleJson15.main() }.verifyOutputLines( - "{\"name\":\"kotlinx.serialization\",\"owner\":{\"name\":\"kotlin\"},\"forks\":[{\"votes\":42},{\"votes\":9000}]}" + "9042" ) } @Test fun testExampleJson16() { captureOutput("ExampleJson16") { example.exampleJson16.main() }.verifyOutputLines( - "Project(name=kotlinx.serialization, language=Kotlin)" + "{\"name\":\"kotlinx.serialization\",\"owner\":{\"name\":\"kotlin\"},\"forks\":[{\"votes\":42},{\"votes\":9000}]}" ) } @Test fun testExampleJson17() { captureOutput("ExampleJson17") { example.exampleJson17.main() }.verifyOutputLines( - "{", - " \"pi_double\": 3.141592653589793,", - " \"pi_string\": \"3.141592653589793238462643383279\"", - "}" + "Project(name=kotlinx.serialization, language=Kotlin)" ) } @@ -136,7 +133,6 @@ class JsonTest { fun testExampleJson18() { captureOutput("ExampleJson18") { example.exampleJson18.main() }.verifyOutputLines( "{", - " \"pi_literal\": 3.141592653589793238462643383279,", " \"pi_double\": 3.141592653589793,", " \"pi_string\": \"3.141592653589793238462643383279\"", "}" @@ -146,59 +142,70 @@ class JsonTest { @Test fun testExampleJson19() { captureOutput("ExampleJson19") { example.exampleJson19.main() }.verifyOutputLines( - "3.141592653589793238462643383279" + "{", + " \"pi_literal\": 3.141592653589793238462643383279,", + " \"pi_double\": 3.141592653589793,", + " \"pi_string\": \"3.141592653589793238462643383279\"", + "}" ) } @Test fun testExampleJson20() { - captureOutput("ExampleJson20") { example.exampleJson20.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("ExampleJson20") { example.exampleJson20.main() }.verifyOutputLines( + "3.141592653589793238462643383279" ) } @Test fun testExampleJson21() { - captureOutput("ExampleJson21") { example.exampleJson21.main() }.verifyOutputLines( - "Project(name=kotlinx.serialization, users=[User(name=kotlin)])", - "Project(name=kotlinx.serialization, users=[User(name=kotlin), User(name=jetbrains)])" + 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" ) } @Test fun testExampleJson22() { captureOutput("ExampleJson22") { example.exampleJson22.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 testExampleJson23() { captureOutput("ExampleJson23") { example.exampleJson23.main() }.verifyOutputLines( - "{\"name\":\"kotlinx.serialization\",\"language\":\"Kotlin\"}", - "{\"name\":\"kotlinx.serialization\"}" + "{\"name\":\"kotlinx.serialization\",\"users\":{\"name\":\"kotlin\"}}" ) } @Test fun testExampleJson24() { captureOutput("ExampleJson24") { example.exampleJson24.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 testExampleJson25() { captureOutput("ExampleJson25") { example.exampleJson25.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 testExampleJson26() { captureOutput("ExampleJson26") { example.exampleJson26.main() }.verifyOutputLines( + "[{\"name\":\"kotlinx.serialization\"},{\"error\":\"Not found\"}]", + "[Ok(data=Project(name=kotlinx.serialization)), Error(message=Not found)]" + ) + } + + @Test + fun testExampleJson27() { + captureOutput("ExampleJson27") { example.exampleJson27.main() }.verifyOutputLines( "UnknownProject(name=example, details={\"type\":\"unknown\",\"maintainer\":\"Unknown\",\"license\":\"Apache 2.0\"})" ) } |