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 /formats | |
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
Diffstat (limited to 'formats')
8 files changed, 244 insertions, 22 deletions
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) } /** |