Skip to content

Commit 782b9f3

Browse files
authored
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
1 parent a87b0f1 commit 782b9f3

27 files changed

+536
-255
lines changed

docs/json.md

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ In this chapter, we'll walk through features of [JSON](https://www.json.org/json
2020
* [Allowing structured map keys](#allowing-structured-map-keys)
2121
* [Allowing special floating-point values](#allowing-special-floating-point-values)
2222
* [Class discriminator for polymorphism](#class-discriminator-for-polymorphism)
23+
* [Decoding enums in a case-insensitive manner](#decoding-enums-in-a-case-insensitive-manner)
2324
* [Global naming strategy](#global-naming-strategy)
2425
* [Json elements](#json-elements)
2526
* [Parsing to Json element](#parsing-to-json-element)
@@ -469,6 +470,39 @@ As you can see, discriminator from the `Base` class is used:
469470

470471
<!--- TEST -->
471472

473+
### Decoding enums in a case-insensitive manner
474+
475+
[Kotlin's naming policy recommends](https://kotlinlang.org/docs/coding-conventions.html#property-names) naming enum values
476+
using either uppercase underscore-separated names or upper camel case names.
477+
[Json] uses exact Kotlin enum values names for decoding by default.
478+
However, sometimes third-party JSONs have such values named in lowercase or some mixed case.
479+
In this case, it is possible to decode enum values in a case-insensitive manner using [JsonBuilder.decodeEnumsCaseInsensitive] property:
480+
481+
```kotlin
482+
val format = Json { decodeEnumsCaseInsensitive = true }
483+
484+
enum class Cases { VALUE_A, @JsonNames("Alternative") VALUE_B }
485+
486+
@Serializable
487+
data class CasesList(val cases: List<Cases>)
488+
489+
fun main() {
490+
println(format.decodeFromString<CasesList>("""{"cases":["value_A", "alternative"]}"""))
491+
}
492+
```
493+
494+
> You can get the full code [here](../guide/example/example-json-12.kt).
495+
496+
It affects serial names as well as alternative names specified with [JsonNames] annotation, so both values are successfully decoded:
497+
498+
```text
499+
CasesList(cases=[VALUE_A, VALUE_B])
500+
```
501+
502+
This property does not affect encoding in any way.
503+
504+
<!--- TEST -->
505+
472506
### Global naming strategy
473507

474508
If properties' names in Json input are different from Kotlin ones, it is recommended to specify the name
@@ -489,7 +523,7 @@ fun main() {
489523
}
490524
```
491525

492-
> You can get the full code [here](../guide/example/example-json-12.kt).
526+
> You can get the full code [here](../guide/example/example-json-13.kt).
493527
494528
As you can see, both serialization and deserialization work as if all serial names are transformed from camel case to snake case:
495529

@@ -541,7 +575,7 @@ fun main() {
541575
}
542576
```
543577

544-
> You can get the full code [here](../guide/example/example-json-13.kt).
578+
> You can get the full code [here](../guide/example/example-json-14.kt).
545579
546580
A `JsonElement` prints itself as a valid JSON:
547581

@@ -584,7 +618,7 @@ fun main() {
584618
}
585619
```
586620

587-
> You can get the full code [here](../guide/example/example-json-14.kt).
621+
> You can get the full code [here](../guide/example/example-json-15.kt).
588622
589623
The above example sums `votes` in all objects in the `forks` array, ignoring the objects that have no `votes`:
590624

@@ -624,7 +658,7 @@ fun main() {
624658
}
625659
```
626660

627-
> You can get the full code [here](../guide/example/example-json-15.kt).
661+
> You can get the full code [here](../guide/example/example-json-16.kt).
628662
629663
As a result, you get a proper JSON string:
630664

@@ -653,7 +687,7 @@ fun main() {
653687
}
654688
```
655689

656-
> You can get the full code [here](../guide/example/example-json-16.kt).
690+
> You can get the full code [here](../guide/example/example-json-17.kt).
657691
658692
The result is exactly what you would expect:
659693

@@ -699,7 +733,7 @@ fun main() {
699733
}
700734
```
701735

702-
> You can get the full code [here](../guide/example/example-json-17.kt).
736+
> You can get the full code [here](../guide/example/example-json-18.kt).
703737
704738
Even though `pi` was defined as a number with 30 decimal places, the resulting JSON does not reflect this.
705739
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() {
739773
}
740774
```
741775

742-
> You can get the full code [here](../guide/example/example-json-18.kt).
776+
> You can get the full code [here](../guide/example/example-json-19.kt).
743777
744778
`pi_literal` now accurately matches the value defined.
745779

@@ -779,7 +813,7 @@ fun main() {
779813
}
780814
```
781815

782-
> You can get the full code [here](../guide/example/example-json-19.kt).
816+
> You can get the full code [here](../guide/example/example-json-20.kt).
783817
784818
The exact value of `pi` is decoded, with all 30 decimal places of precision that were in the source JSON.
785819

@@ -801,7 +835,7 @@ fun main() {
801835
}
802836
```
803837

804-
> You can get the full code [here](../guide/example/example-json-20.kt).
838+
> You can get the full code [here](../guide/example/example-json-21.kt).
805839
806840
```text
807841
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() {
877911
}
878912
```
879913

880-
> You can get the full code [here](../guide/example/example-json-21.kt).
914+
> You can get the full code [here](../guide/example/example-json-22.kt).
881915
882916
The output shows that both cases are correctly deserialized into a Kotlin [List].
883917

@@ -929,7 +963,7 @@ fun main() {
929963
}
930964
```
931965

932-
> You can get the full code [here](../guide/example/example-json-22.kt).
966+
> You can get the full code [here](../guide/example/example-json-23.kt).
933967
934968
You end up with a single JSON object, not an array with one element:
935969

@@ -974,7 +1008,7 @@ fun main() {
9741008
}
9751009
```
9761010

977-
> You can get the full code [here](../guide/example/example-json-23.kt).
1011+
> You can get the full code [here](../guide/example/example-json-24.kt).
9781012
9791013
See the effect of the custom serializer:
9801014

@@ -1047,7 +1081,7 @@ fun main() {
10471081
}
10481082
```
10491083

1050-
> You can get the full code [here](../guide/example/example-json-24.kt).
1084+
> You can get the full code [here](../guide/example/example-json-25.kt).
10511085
10521086
No class discriminator is added in the JSON output:
10531087

@@ -1143,7 +1177,7 @@ fun main() {
11431177
}
11441178
```
11451179

1146-
> You can get the full code [here](../guide/example/example-json-25.kt).
1180+
> You can get the full code [here](../guide/example/example-json-26.kt).
11471181
11481182
This gives you fine-grained control on the representation of the `Response` class in the JSON output:
11491183

@@ -1208,7 +1242,7 @@ fun main() {
12081242
}
12091243
```
12101244

1211-
> You can get the full code [here](../guide/example/example-json-26.kt).
1245+
> You can get the full code [here](../guide/example/example-json-27.kt).
12121246
12131247
```text
12141248
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.
12621296
[JsonBuilder.allowSpecialFloatingPointValues]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/allow-special-floating-point-values.html
12631297
[JsonBuilder.classDiscriminator]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/class-discriminator.html
12641298
[JsonClassDiscriminator]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-class-discriminator/index.html
1299+
[JsonBuilder.decodeEnumsCaseInsensitive]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/decode-enums-case-insensitive.html
12651300
[JsonBuilder.namingStrategy]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/naming-strategy.html
12661301
[JsonNamingStrategy]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-naming-strategy/index.html
12671302
[JsonElement]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-element/index.html

docs/serialization-guide.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ Once the project is set up, we can start serializing some classes.
119119
* <a name='allowing-structured-map-keys'></a>[Allowing structured map keys](json.md#allowing-structured-map-keys)
120120
* <a name='allowing-special-floating-point-values'></a>[Allowing special floating-point values](json.md#allowing-special-floating-point-values)
121121
* <a name='class-discriminator-for-polymorphism'></a>[Class discriminator for polymorphism](json.md#class-discriminator-for-polymorphism)
122+
* <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)
122123
* <a name='global-naming-strategy'></a>[Global naming strategy](json.md#global-naming-strategy)
123124
* <a name='json-elements'></a>[Json elements](json.md#json-elements)
124125
* <a name='parsing-to-json-element'></a>[Parsing to Json element](json.md#parsing-to-json-element)
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package kotlinx.serialization.features
2+
3+
import kotlinx.serialization.*
4+
import kotlinx.serialization.json.*
5+
import kotlinx.serialization.test.*
6+
import kotlin.test.*
7+
8+
@Suppress("EnumEntryName")
9+
class JsonEnumsCaseInsensitiveTest: JsonTestBase() {
10+
@Serializable
11+
data class Foo(
12+
val one: Bar = Bar.BAZ,
13+
val two: Bar = Bar.QUX,
14+
val three: Bar = Bar.QUX
15+
)
16+
17+
enum class Bar { BAZ, QUX }
18+
19+
// It seems that we no longer report a warning that @Serializable is required for enums with @SerialName.
20+
// It is still required for them to work at top-level.
21+
@Serializable
22+
enum class Cases {
23+
ALL_CAPS,
24+
MiXed,
25+
all_lower,
26+
27+
@JsonNames("AltName")
28+
hasAltNames,
29+
30+
@SerialName("SERIAL_NAME")
31+
hasSerialName
32+
}
33+
34+
@Serializable
35+
data class EnumCases(val cases: List<Cases>)
36+
37+
val json = Json(default) { decodeEnumsCaseInsensitive = true }
38+
39+
@Test
40+
fun testCases() = noLegacyJs { parametrizedTest { mode ->
41+
val input =
42+
"""{"cases":["ALL_CAPS","all_caps","mixed","MIXED","miXed","all_lower","ALL_LOWER","all_Lower","hasAltNames","HASALTNAMES","altname","ALTNAME","AltName","SERIAL_NAME","serial_name"]}"""
43+
val target = listOf(
44+
Cases.ALL_CAPS,
45+
Cases.ALL_CAPS,
46+
Cases.MiXed,
47+
Cases.MiXed,
48+
Cases.MiXed,
49+
Cases.all_lower,
50+
Cases.all_lower,
51+
Cases.all_lower,
52+
Cases.hasAltNames,
53+
Cases.hasAltNames,
54+
Cases.hasAltNames,
55+
Cases.hasAltNames,
56+
Cases.hasAltNames,
57+
Cases.hasSerialName,
58+
Cases.hasSerialName
59+
)
60+
val decoded = json.decodeFromString<EnumCases>(input, mode)
61+
assertEquals(EnumCases(target), decoded)
62+
val encoded = json.encodeToString(decoded, mode)
63+
assertEquals(
64+
"""{"cases":["ALL_CAPS","ALL_CAPS","MiXed","MiXed","MiXed","all_lower","all_lower","all_lower","hasAltNames","hasAltNames","hasAltNames","hasAltNames","hasAltNames","SERIAL_NAME","SERIAL_NAME"]}""",
65+
encoded
66+
)
67+
}}
68+
69+
@Test
70+
fun testTopLevelList() = noLegacyJs { parametrizedTest { mode ->
71+
val input = """["all_caps","serial_name"]"""
72+
val decoded = json.decodeFromString<List<Cases>>(input, mode)
73+
assertEquals(listOf(Cases.ALL_CAPS, Cases.hasSerialName), decoded)
74+
assertEquals("""["ALL_CAPS","SERIAL_NAME"]""", json.encodeToString(decoded, mode))
75+
}}
76+
77+
@Test
78+
fun testTopLevelEnum() = noLegacyJs { parametrizedTest { mode ->
79+
val input = """"altName""""
80+
val decoded = json.decodeFromString<Cases>(input, mode)
81+
assertEquals(Cases.hasAltNames, decoded)
82+
assertEquals(""""hasAltNames"""", json.encodeToString(decoded, mode))
83+
}}
84+
85+
@Test
86+
fun testSimpleCase() = parametrizedTest { mode ->
87+
val input = """{"one":"baz","two":"Qux","three":"QUX"}"""
88+
val decoded = json.decodeFromString<Foo>(input, mode)
89+
assertEquals(Foo(), decoded)
90+
assertEquals("""{"one":"BAZ","two":"QUX","three":"QUX"}""", json.encodeToString(decoded, mode))
91+
}
92+
93+
enum class E { VALUE_A, @JsonNames("ALTERNATIVE") VALUE_B }
94+
95+
@Test
96+
fun testDocSample() = noLegacyJs {
97+
98+
val j = Json { decodeEnumsCaseInsensitive = true }
99+
@Serializable
100+
data class Outer(val enums: List<E>)
101+
102+
println(j.decodeFromString<Outer>("""{"enums":["value_A", "alternative"]}""").enums)
103+
}
104+
105+
@Test
106+
fun testCoercingStillWorks() = parametrizedTest { mode ->
107+
val withCoercing = Json(json) { coerceInputValues = true }
108+
val input = """{"one":"baz","two":"unknown","three":"Que"}"""
109+
assertEquals(Foo(), withCoercing.decodeFromString<Foo>(input, mode))
110+
}
111+
112+
@Test
113+
fun testCaseInsensitivePriorityOverCoercing() = parametrizedTest { mode ->
114+
val withCoercing = Json(json) { coerceInputValues = true }
115+
val input = """{"one":"QuX","two":"Baz","three":"Que"}"""
116+
assertEquals(Foo(Bar.QUX, Bar.BAZ, Bar.QUX), withCoercing.decodeFromString<Foo>(input, mode))
117+
}
118+
119+
@Test
120+
fun testCoercingStillWorksWithNulls() = parametrizedTest { mode ->
121+
val withCoercing = Json(json) { coerceInputValues = true }
122+
val input = """{"one":"baz","two":"null","three":null}"""
123+
assertEquals(Foo(), withCoercing.decodeFromString<Foo>(input, mode))
124+
}
125+
126+
@Test
127+
fun testFeatureDisablesProperly() = parametrizedTest { mode ->
128+
val disabled = Json(json) {
129+
coerceInputValues = true
130+
decodeEnumsCaseInsensitive = false
131+
}
132+
val input = """{"one":"BAZ","two":"BAz","three":"baz"}""" // two and three should be coerced to QUX
133+
assertEquals(Foo(), disabled.decodeFromString<Foo>(input, mode))
134+
}
135+
136+
@Test
137+
fun testFeatureDisabledThrowsWithoutCoercing() = parametrizedTest { mode ->
138+
val disabled = Json(json) {
139+
coerceInputValues = false
140+
decodeEnumsCaseInsensitive = false
141+
}
142+
val input = """{"one":"BAZ","two":"BAz","three":"baz"}"""
143+
assertFailsWithMessage<SerializationException>("does not contain element with name 'BAz'") {
144+
disabled.decodeFromString<Foo>(input, mode)
145+
}
146+
}
147+
148+
@Serializable enum class BadEnum { Bad, BAD }
149+
150+
@Serializable data class ListBadEnum(val l: List<BadEnum>)
151+
152+
@Test
153+
fun testLowercaseClashThrowsException() = parametrizedTest { mode ->
154+
assertFailsWithMessage<SerializationException>("""The suggested name 'bad' for enum value BAD is already one of the names for enum value Bad""") {
155+
// an explicit serializer is required for JSLegacy
156+
json.decodeFromString(Box.serializer(BadEnum.serializer()),"""{"boxed":"bad"}""", mode)
157+
}
158+
assertFailsWithMessage<SerializationException>("""The suggested name 'bad' for enum value BAD is already one of the names for enum value Bad""") {
159+
json.decodeFromString(Box.serializer(BadEnum.serializer()),"""{"boxed":"unrelated"}""", mode)
160+
}
161+
}
162+
163+
@Test
164+
fun testLowercaseClashHandledWithoutFeature() = parametrizedTest { mode ->
165+
val disabled = Json(json) {
166+
coerceInputValues = false
167+
decodeEnumsCaseInsensitive = false
168+
}
169+
assertEquals(ListBadEnum(listOf(BadEnum.Bad, BadEnum.BAD)), disabled.decodeFromString("""{"l":["Bad","BAD"]}"""))
170+
}
171+
}

formats/json-tests/commonTest/src/kotlinx/serialization/features/JsonNamingStrategyTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class JsonNamingStrategyTest : JsonTestBase() {
2424

2525
val jsonWithNaming = Json(default) {
2626
namingStrategy = JsonNamingStrategy.SnakeCase
27+
decodeEnumsCaseInsensitive = true // check that related feature does not break anything
2728
}
2829

2930
@Test

formats/json-tests/commonTest/src/kotlinx/serialization/test/TestingFramework.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ inline fun assertFailsWithSerialMessage(
7878
)
7979
assertTrue(
8080
exception.message!!.contains(message),
81-
"expected:<${exception.message}> but was:<$message>"
81+
"expected:<$message> but was:<${exception.message}>"
8282
)
8383
}
8484
inline fun <reified T : Throwable> assertFailsWithMessage(
@@ -89,6 +89,6 @@ inline fun <reified T : Throwable> assertFailsWithMessage(
8989
val exception = assertFailsWith(T::class, assertionMessage, block)
9090
assertTrue(
9191
exception.message!!.contains(message),
92-
"expected:<${exception.message}> but was:<$message>"
92+
"expected:<$message> but was:<${exception.message}>"
9393
)
9494
}

0 commit comments

Comments
 (0)