Polymorphic kotlinx serialization when type is integer, not string
Asked Answered
W

1

5

I am trying to consume and emit JSON which contains a polymorphic list of items. The problem is: the items contain type key with integer values (not strings). The API endpoint produces and expects JSON similar to this:

{
  "startTime": "2022-07-27T13:32:57.379Z",
  "items": [
    {
      "type": 0,
      "results": "string",
      "restBetweenRounds": "string"
    },
    {
      "type": 1,
      "results": "string",
      "setCount": 0
    },
    {
      "type": 2,
      "items": [
        {
          "type": 0,
          "results": "string",
          "restBetweenRounds": "string"
        },
        {
          "type": 1,
          "results": "string",
          "setCount": 0
        }
      ],
      "results": "string"
    }
  ],
  "status": 0,
  "clientId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}

As described in the article on polymorphism, I created an hierarchy of classes. I also try to convert type value before deserialization.

object MyTransformingDeserializer : JsonTransformingSerializer<BaseItem>(PolymorphicSerializer(BaseItem::class)) {
    override fun transformDeserialize(element: JsonElement): JsonElement {
        val type = element.jsonObject["type"]!!
        val newType = JsonPrimitive(value = type.toString())
        return JsonObject(element.jsonObject.toMutableMap().also { it["type"] = newType })
    }
}


@Serializable(with = MyTransformingDeserializer::class)
sealed class BaseItem {
    abstract val type: String
}

@Serializable
@SerialName("0")
class ItemType0(
    override val type: String,
    // ...
) : BaseItem()


@Serializable
@SerialName("1")
class ItemType1(
    override val type: String,
    // ...
) : BaseItem()

@Serializable
@SerialName("2")
class ItemType2(
    override val type: String,
    // ...
) : BaseItem()

But all I get is this error:

kotlinx.serialization.json.internal.JsonDecodingException: Polymorphic serializer was not found for class discriminator '0'

Given that I can not change the format of the JSON, what can be done to successfully serialize/desereialize it?

Withstand answered 27/7, 2022 at 14:26 Comment(1)
It's important to see how you've defined the class that has the items list - can you provide a complete and working example?Gemoets
G
12

Handling polymorphism in Kotlinx Serialization is difficult, especially when you don't have control over the format of the source. But KxS does give a lot of low-level tools to manually handle almost anything.

You were close in choosing JsonTransformingSerializer! It seems that it doesn't transform the JSON before KxS selects a serializer. Because discriminators can only be strings, deserialization fails.

JsonContentPolymorphicSerializer

Instead of JsonTransformingSerializer, you can use JsonContentPolymorphicSerializer.

Kotlinx Serialization will first deserialize the JSON to a JsonObject. It will then provide that object to the serializer for BaseItem, and you can parse and select the correct subclass.

import kotlinx.serialization.*
import kotlinx.serialization.json.*

object BaseItemSerializer : JsonContentPolymorphicSerializer<BaseItem>(BaseItem::class) {
  override fun selectDeserializer(
    element: JsonElement
  ): DeserializationStrategy<out BaseItem> {

    return when (val type = element.jsonObject["type"]?.jsonPrimitive?.intOrNull) {
      0    -> ItemType0.serializer()
      1    -> ItemType1.serializer()
      2    -> ItemType2.serializer()
      else -> error("unknown Item type $type")
    }
  }
}

Including type

Since this is manually performing polymorphic discrimination, there's no need to include type in your classes.

import kotlinx.serialization.Serializable

@Serializable(with = BaseItemSerializer::class)
sealed class BaseItem

@Serializable
data class ItemType0(
  // ...
) : BaseItem()

@Serializable
class ItemType1(
  // ...
) : BaseItem()

@Serializable
class ItemType2(
  // ...
) : BaseItem()

However you might like to include it, for completeness, and so it's included when serializing. For that, you must use @EncodeDefault

import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.Serializable

@Serializable(with = BaseItemSerializer::class)
sealed class BaseItem {
  abstract val type: Int
}

@Serializable
class ItemType0(
  // ...
) : BaseItem() {
  @EncodeDefault
  override val type: Int = 0
}

// ...

Complete example

Bringing it all together, here's a complete example.

import kotlinx.serialization.*
import kotlinx.serialization.json.*

val mapper = Json {
  prettyPrint = true
  prettyPrintIndent = "  "
}

fun main() {

  val json = """
{
  "startTime": "2022-07-27T13:32:57.379Z",
  "status": 0,
  "clientId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "items": [
    {
      "type": 0,
      "results": "string",
      "restBetweenRounds": "string"
    },
    {
      "type": 1,
      "results": "string",
      "setCount": 0
    },
    {
      "type": 2,
      "items": [
        {
          "type": 0,
          "results": "string",
          "restBetweenRounds": "string"
        },
        {
          "type": 1,
          "results": "string",
          "setCount": 0
        }
      ],
      "results": "string"
    }
  ]
}
  """.trimIndent()

  val itemHolder: ItemHolder = mapper.decodeFromString(json)

  println(itemHolder)

  println(mapper.encodeToString(itemHolder))
}

@Serializable
data class ItemHolder(
  val startTime: String,
  val clientId: String,
  val status: Int,
  val items: List<BaseItem>,
)

@Serializable(with = BaseItem.Serializer::class)
sealed class BaseItem {
  abstract val type: Int

  object Serializer : JsonContentPolymorphicSerializer<BaseItem>(BaseItem::class) {
    override fun selectDeserializer(
      element: JsonElement
    ): DeserializationStrategy<out BaseItem> {

      return when (val type = element.jsonObject["type"]?.jsonPrimitive?.intOrNull) {
        0    -> ItemType0.serializer()
        1    -> ItemType1.serializer()
        2    -> ItemType2.serializer()
        else -> error("unknown Item type $type")
      }
    }
  }  
}

@Serializable
data class ItemType0(
  val results: String,
  val restBetweenRounds: String,
) : BaseItem() {
  @EncodeDefault
  override val type: Int = 0
}

@Serializable
data class ItemType1(
  val results: String,
  val setCount: Int,
) : BaseItem() {
  @EncodeDefault
  override val type: Int = 1
}

@Serializable
data class ItemType2(
  val results: String,
  val items: List<BaseItem>,
) : BaseItem() {
  @EncodeDefault
  override val type: Int = 2
}

This prints

ItemHolder(
  startTime=2022-07-27T13:32:57.379Z, 
  clientId=3fa85f64-5717-4562-b3fc-2c963f66afa6, 
  status=0, 
  items=[
    ItemType0(results=string, restBetweenRounds=string), 
    ItemType1(results=string, setCount=0), 
    ItemType2(
      results=string, 
      items=[
        ItemType0(results=string, restBetweenRounds=string),
        ItemType1(results=string, setCount=0)
      ]
    )
  ]
)

and

{
  "startTime": "2022-07-27T13:32:57.379Z",
  "clientId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "status": 0,
  "items": [
    {
      "results": "string",
      "restBetweenRounds": "string",
      "type": 0
    },
    {
      "results": "string",
      "setCount": 0,
      "type": 1
    },
    {
      "results": "string",
      "items": [
        {
          "results": "string",
          "restBetweenRounds": "string",
          "type": 0
        },
        {
          "results": "string",
          "setCount": 0,
          "type": 1
        }
      ],
      "type": 2
    }
  ]
}

which matches the input!

Versions

  • Kotlin 1.7.10
  • Kotlinx Serialization 1.3.4
Gemoets answered 27/7, 2022 at 22:37 Comment(1)
Thank you, Adam. This is really a great answer. It worked perfectly in my case.Withstand

© 2022 - 2024 — McMap. All rights reserved.