kotlinx.serialization icon indicating copy to clipboard operation
kotlinx.serialization copied to clipboard

kotlinx.serialization.SerializationException: Enum class Country does not contain element with name 'MISSING' at path $[2]

Open karloti opened this issue 1 year ago • 0 comments

Describe the bug I made several attempts, but I failed to deserialize the JSON as expected. Every time, I encountered an exception error. I had to write my own serializer to solve the issue with the serialization of List<Enum<*>> or other enum cases...

To Reproduce

class KotlinxTests {
    enum class Country { USA, OTHER }

    @Test
    fun testSerializer() {
        val json = Json {
            ignoreUnknownKeys = true
            coerceInputValues = true
            explicitNulls = false
        }

        val countriesActual: List<Country?> = json.decodeFromString("""["USA", null, "MISSING"]""")
        val countriesExpect: List<Country?> = listOf(USA, null, null)

        require(countriesActual == countriesExpect)
    }
}

image

My Solution If anyone is facing a similar issue, they can temporarily use my this solution:

import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder

/**
 * A generic serializer for enum classes using Kotlin Serialization with caches.
 *
 * This serializer handles the serialization and deserialization of enum values as strings,
 * using either the `serialName` (if available) or the regular `name` of the enum.
 *
 * @param T The enum type to serialize.
 */

inline fun <reified T : Enum<T>> enumSerializer(): KSerializer<T?> = object : KSerializer<T?> {
    override val descriptor: SerialDescriptor =
        PrimitiveSerialDescriptor("EnumSerializer", PrimitiveKind.STRING)

    override fun serialize(encoder: Encoder, value: T?) {
        (value?.serialName ?: value?.name)?.let { encoder.encodeString(it) }
    }

    override fun deserialize(decoder: Decoder): T? {
        val decodeString = decoder.decodeString()
        return decodeString.enumBySerialName<T>() as T?
            ?: decodeString.enumByName<T>() as T?
            ?: run {
                println(
                    """
                    |enumSerializer
                    |Unknown enum value found: "$decodeString" in ${T::class.simpleName}
                    """.trimMargin()
                )
                null
            }
    }
}


/**
 * A utility object that provides caching for enum name and serialized name lookups.
 *
 * This object maintains three caches:*
 * - `serialNameByEnum`: Maps enum instances to their serialized names (as defined by the `@SerialName` annotation).
 * - `enumByEnumName`: Maps enum names to their corresponding enum instances.
 * - `enumBySerialName`: Maps serialized names to their corresponding enum instances.
 *
 * The caches are populated lazily, meaning that the mappings are generated only when a particular enum class is accessed for the first time.
 */

object Caches {
    private val serialNameByEnum: MutableMap<Class<*>, Map<Enum<*>, String>> = mutableMapOf()
    private val enumByEnumName: MutableMap<Class<*>, Map<String, Enum<*>>> = mutableMapOf()
    private val enumBySerialName: MutableMap<Class<*>, Map<String, Enum<*>>> = mutableMapOf()

    private fun <T : Enum<T>> makeCache(declaringClass: Class<T>) {
        val mapNames = declaringClass.enumConstants!!
        val pairs: List<Pair<T, String>> = mapNames
            .mapNotNull { constant ->
                val serialName = constant
                    .declaringJavaClass
                    .getField(constant.name)
                    .getAnnotation(SerialName::class.java)?.value
                serialName?.let { constant to it }
            }
        serialNameByEnum[declaringClass] = pairs.toMap()
        enumByEnumName[declaringClass] = mapNames.associateBy { it.name }
        enumBySerialName[declaringClass] = pairs.associate { it.second to it.first }
    }

    fun <T : Enum<T>> serialNameByEnum(enum: Enum<T>): String? {
        val declaringClass: Class<T> = enum.declaringJavaClass
        serialNameByEnum[declaringClass] ?: makeCache(declaringClass)
        return serialNameByEnum[declaringClass]!![enum]
    }

    fun <T : Enum<T>> enumByName(declaringClass: Class<T>, serialName: String): Enum<*>? {
        enumByEnumName[declaringClass] ?: makeCache(declaringClass)
        return enumByEnumName[declaringClass]!![serialName]
    }

    fun <T : Enum<T>> enumBySerialName(declaringClass: Class<T>, serialName: String): Enum<*>? {
        enumBySerialName[declaringClass] ?: makeCache(declaringClass)
        return enumBySerialName[declaringClass]!![serialName]
    }
}

/**
 * Returns the serialized name of the enum instance, as defined by the `@SerialName` annotation.
 *
 * @returnThe serialized name of the enum, or `null` if no `@SerialName` annotation is present.
 */

val <T : Enum<T>> Enum<T>.serialName: String?
    get() = Caches.serialNameByEnum(this)

/**
 * Attempts to findan enum instance of the reified type [T] by its simple name.
 *
 * @return The enum instance corresponding to the given name, or `null` if not found.
 */

inline fun <reified T : Enum<T>> String.enumByName(): Enum<*>? =
    Caches.enumByName(T::class.java, this)

/**
 * Attempts to find an enum instance of the reified type [T] by its serialized name.
 *
 * @return The enum instance corresponding to the given serialized name, or `null` if not found.
 */

inline fun <reified T : Enum<T>> String.enumBySerialName(): Enum<*>? =
    Caches.enumBySerialName(T::class.java, this)

Using My custom enumSerializer in our test.

class KotlinxTests {
    object EnumSerializer : KSerializer<Country?> by enumSerializer()

    @Serializable(with = EnumSerializer::class)
    enum class Country { USA, OTHER }

    @Test
    fun testSerializer() {
        val json = Json {
            ignoreUnknownKeys = true
            coerceInputValues = true
            explicitNulls = false
        }

        val countriesActual: List<Country?> = json.decodeFromString("""["USA", null, "MISSING"]""")
        val countriesExpect: List<Country?> = listOf(USA, null, null)

        require(countriesActual == countriesExpect)

    }
}

image

Expected behavior listOf(USA, null, null)

Environment

  • Kotlin version: 2.0.0
  • Library version: 1.7.0
  • Kotlin platforms: JVM
  • Gradle version: 8.5
  • IDE version: IntelliJ IDEA 2024.1.4 (241.18034.62 build)
  • Microsoft Windows 11 Pro
  • JRE 21

karloti avatar Jun 30 '24 12:06 karloti