It's quite common case when deserialization of JSON you want to parse some property to Enum constant, but now Enum constant should have the same name and same case as a property value.
Would be nice to allow annotate enums with SerialName to provide custom naming for a serilized format.
An alternative solution is to use Serializer but looks like overkill for enum classes and SerialName could replace it.
I had a helluva time getting this to work, so hopefully to save the next person some time, here is what I believe a complete example (with fully-qualified imports!) where you want an enum value to be serialized/deserialized as lowercase:
// This annotation avoids the need for @Serializable(with=Linter.LintSeveritySerializer::class)
// elsewhere in this file, but you still need to add it in other files.
@file:UseSerializers(Linter.LintSeveritySerializer::class)
package com.example
import kotlinx.serialization.Decoder
import kotlinx.serialization.Encoder
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialDescriptor
import kotlinx.serialization.Serializable
import kotlinx.serialization.Serializer
import kotlinx.serialization.UseSerializers
import kotlinx.serialization.internal.StringDescriptor
class Linter {
// Cannot declare this enum private or LintSeveritySerializer
// will not compile.
enum class LintSeverity {
ERROR,
WARNING,
AUTOFIX,
ADVICE,
DISABLED
}
@Serializer(forClass = LintSeverity::class)
object LintSeveritySerializer : KSerializer<LintSeverity> {
override val descriptor: SerialDescriptor = StringDescriptor
override fun serialize(output: Encoder, obj: LintSeverity) {
output.encodeString(obj.toString().toLowerCase())
}
override fun deserialize(input: Decoder): LintSeverity {
// Admittedly, this would accept "Error" in addition to "error".
return LintSeverity.valueOf(input.decodeString().toUpperCase())
}
}
@Serializable
private class LintMessage(
val path: String,
val line: Int? = null,
val char: Int? = null,
val severity: LintSeverity
)
}
I'm surprised that this hasn't been implemented yet, as this is pretty basic.
Here is a generic Enum serializer that uses @SerializedName annotation on Enum fields.
NOTE 1: The contents of companion object are 100% reusable, and could be moved out into common code/library.
NOTE 2: The "UNKNOWN" fallback value allows forward compatibility, so that old client doesn't crash when receiving an unknown enum value (because it was introduced later), so that it can log it and ignore - instead of crashing.
NOTE 3: The EnumSerialNameSerializer could be modified to be more lenient (e.g. case-insensitive) if necessary.
Perhaps, there is a better way of doing this? Let me know.
package com.example
import kotlinx.serialization.Decoder
import kotlinx.serialization.Encoder
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialDescriptor
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.enumMembers
import kotlinx.serialization.internal.StringDescriptor
import kotlin.reflect.KClass
class Linter {
@Serializable(with = LintSeveritySerializer::class)
enum class LintSeverity {
@SerialName("error") ERROR,
@SerialName("warning") WARNING,
@SerialName("auto_fix") AUTOFIX,
@SerialName("advice") ADVICE,
@SerialName("disabled") DISABLED,
UNKNOWN
}
object LintSeveritySerializer : EnumSerialNameSerializer<LintSeverity>(
LintSeverity::class,
fallback = LintSeverity.UNKNOWN
)
@Serializable
private class LintMessage(
val path: String,
val line: Int? = null,
val char: Int? = null,
val severity: LintSeverity
)
companion object {
open class EnumSerialNameSerializer<E : Enum<E>>(
private val kClass: KClass<E>,
private val fallback: E
) : KSerializer<E> {
override val descriptor: SerialDescriptor = StringDescriptor
override fun serialize(encoder: Encoder, obj: E) {
encoder.encodeString(obj.getEnumFieldAnnotation<SerialName>()!!.value)
}
override fun deserialize(decoder: Decoder): E =
decoder.decodeString().let { value ->
kClass.enumMembers()
.firstOrNull { it.getEnumFieldAnnotation<SerialName>()?.value == value }
?: run {
// TODO: Log an error/warning?
fallback
}
}
}
inline fun <reified A : Annotation> Enum<*>.getEnumFieldAnnotation(): A? =
javaClass.getDeclaredField(name).getAnnotation(A::class.java)
}
}
That throws a KotlinNullPointerException for me.
This version works though:
Use this one instead is as it cross-platform and does not require reflection.
Old version:
open class EnumSerialNameSerializer<E : Enum<E>>(
private val kClass: KClass<E>
) : KSerializer<E> {
override val descriptor: SerialDescriptor = StringDescriptor
override fun serialize(encoder: Encoder, obj: E) {
val value = obj.javaClass.getField(obj.name).getAnnotation(SerialName::class.java)?.value
encoder.encodeString(value ?: obj.name)
}
override fun deserialize(decoder: Decoder): E =
decoder.decodeString().let { value ->
kClass.enumMembers()
.firstOrNull { it.getEnumFieldAnnotation<SerialName>()?.value == value }
?: run {
throw SerializationException("Could not serialize class $kClass. There are no enum members.")
}
}
}
inline fun <reified A : Annotation> Enum<*>.getEnumFieldAnnotation(): A? =
javaClass.getDeclaredField(name).getAnnotation(A::class.java)
Example usage:
@Serializable
class Wrapper(val test: Test)
object TestSerializer : EnumSerialNameSerializer<Test>(Test::class)
@Serializable(with = TestSerializer::class)
enum class Test {
@SerialName("Bar") // Will be serialized to "Bar"
Foo
}
(Update: fixed a null pointer exception that I had too)
@natanfudge you mean in serialize()? Yes, that was sloppy. Changed it to same getEnumFieldAnnotation extension method.
@valeriyo I think yours might crash still in this case:
@Serializable(with = TestSerializer::class)
enum class Test {
@SerialName("Bar") // Will be serialized to "Bar"
Foo,
Baz // Kotlin NPE when serializing!
}
Maybe you can also use the help of CommonEnumSerializer. The principle is the same (create derived object), but you'll achieve multiplatform support without reflection.
@natanfudge that crash would be intentional, since I require all valid values to be annotated with @SerialName. But the number of possibilities is large here.
@sandwwraith thanks for the tip, I didn't find CommonEnumSerializer myself.. but since multi-platform is not needed for me, the annotation approach would be better.
@sandwwraith That's very useful indeed.
Example usage for those looking for it:
@Serializable
class Wrapper(val test: TestEnum)
object TestSerializer : CommonEnumSerializer<TestEnum>("TestEnum", arrayOf(TestEnum.Foo), arrayOf("Bar"))
@Serializable(with = TestSerializer::class)
enum class TestEnum {
Foo // Will be serialized to "Bar"
}
Although I found doing this pattern is much more effective (use this one):
// Define this once:
interface SerialEnum {
val serialName: String?
}
fun <T> Array<T>.serial() where T : SerialEnum, T : Enum<T> = this.map { it.serialName ?: it.name }.toTypedArray()
// Then use:
@Serializable
class Wrapper(val test: TestEnum)
object TestSerializer :
CommonEnumSerializer<TestEnum>("TestEnum", TestEnum.values(), TestEnum.values().serial())
@Serializable(with = TestSerializer::class)
enum class TestEnum(override val serialName: String? = null) : SerialEnum {
Foo("Bar"), // Will be serialized to "Bar"
AnotherConstant // Will be serialized to "AnotherConstant"
}
@natanfudge How would one go about doing this with an enum class defined by Int or Long values instead of Strings?
It looks like CommonEnumSerializer and EnumDescriptor require things to be represented as String…
@ankushg
If you look at the implementation of StreamingJsonOutput#encodeEnum, you can see it will always go down as a String that way:
override fun encodeEnum(enumDescription: EnumDescriptor, ordinal: Int) {
encodeString(enumDescription.getElementName(ordinal))
}
So we need to serialize using encodeInt with a custom serializer.
open class CommonEnumIntSerializer<T>(val serialName: String, val choices: Array<T>,val choicesNumbers: Array<Int>) :
KSerializer<T> {
override val descriptor: EnumDescriptor = EnumDescriptor(serialName, choicesNumbers.map { it.toString() }.toTypedArray())
init {
require(choicesNumbers.size == choices.size){"There must be exactly one serial number for every enum constant."}
require(choicesNumbers.distinct().size == choicesNumbers.size){"There must be no duplicates of serial numbers."}
}
final override fun serialize(encoder: Encoder, obj: T) {
val index = choices.indexOf(obj)
.also { check(it != -1) { "$obj is not a valid enum $serialName, choices are $choices" } }
encoder.encodeInt(choicesNumbers[index])
}
final override fun deserialize(decoder: Decoder): T {
val serialNumber = decoder.decodeInt()
val index = choicesNumbers.indexOf(serialNumber)
check(index != -1) {"$serialNumber is not a valid serial value of $serialName, choices are $choicesNumbers"}
check(index in choices.indices)
{ "$index is not among valid $serialName choices, choices size is ${choices.size}" }
return choices[index]
}
}
With appropriate helper methods:
interface SerialEnum {
val serialNumber: Int?
}
fun <T> Array<T>.serial() where T : SerialEnum, T : Enum<T> = this.map { it.serialNumber ?: it.ordinal }.toTypedArray()
And then we can do this:
object TestSerializer :
CommonEnumIntSerializer<TestEnum>("TestEnum", TestEnum.values(), TestEnum.values().serial())
@Serializable(with = TestSerializer::class)
enum class TestEnum(override val serialNumber: Int? = null) : SerialEnum {
Foo(123), // Will be serialized to 123
AnotherConstant // Will be serialized to 1
}
It is supported starting from Kotlin 1.3.60/kotlinx.serialization 0.14.0.
Note that enum should be annotated @Serializable to work with @SerialName – a special enum serializer will be generated since it is the only way to support custom names.
Regular enums are still implicitly serializable, but @SerialName/@SerialInfo annotations won't affect them. A new IDEA inspection will help you to detect such problems.
I found it a little hard to follow the examples above so here's a slightly different and more complete solution that uses a label field for serialization/deserialization instead of @SerialName:
interface LabeledEnum {
val label: String
}
object MessageTypeSerializer : MessageType.Companion.EnumLabelSerializer<MessageType>(
MessageType::class
)
@Serializable(with = EnumLabelSerializer::class)
enum class MessageType(override val label: String) : LabeledEnum {
FOO("foo"),
BAR("bar"),
BAZ("baz");
override fun toString() = label
fun toJson(configuration: JsonConfiguration = JsonConfiguration.Stable) =
Json(configuration).stringify(MessageTypeSerializer, this)
companion object {
fun fromJson(data: String, configuration: JsonConfiguration = JsonConfiguration.Stable) =
Json(configuration).parse(MessageTypeSerializer, data)
// Adapted from
// https://github.com/Kotlin/kotlinx.serialization/issues/31#issuecomment-505999158
open class EnumLabelSerializer<E>(
private val kClass: KClass<E>
) : KSerializer<E> where E : Enum<E>, E : LabeledEnum {
override val descriptor: SerialDescriptor = PrimitiveDescriptor(
"com.example.EnumLabelSerializer", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: E) {
encoder.encodeString(value.label)
}
override fun deserialize(decoder: Decoder): E =
decoder.decodeString().let { value ->
kClass.enumMembers().firstOrNull { it.label == value } ?: run {
throw IllegalStateException("Cannot find enum with label $value")
}
}
}
}
}
Most helpful comment
I'm surprised that this hasn't been implemented yet, as this is pretty basic.