Dennis Anderson
Dennis Anderson

Reputation: 1396

Kotlin Deserialization - JSON Array to multiple different objects

I'm using the 1.0.0 version of kotlin serialization but I'm stuck when I try to deserialize a "flexible" array.

From the Backend API that I don't control I get back an JSON Array that holds different types of objects. How would you deserialize them using kotlin serialization?

Example

This is the API's response

[
  {
    "id": "test",
    "person": "person",
    "lastTime": "lastTime",
    "expert": "pro"
  },
  {
    "id": "test",
    "person": "person",
    "period": "period",
    "value": 1
  }
]
@Serializable
sealed class Base {
  @SerialName("id")
  abstract val id: String
  @SerialName("person")
  abstract val person: String
}

@Serializable
data class ObjectA (
 @SerialName("id") override val id: String,
 @SerialName("title") override val title: String,
 @SerialName("lastTime") val lastTime: String,
 @SerialName("expert") val expert: String
) : Base()

@Serializable
data class ObjectB (
 @SerialName("id") override val id: String,
 @SerialName("title") override val title: String,
 @SerialName("period") val period: String,
 @SerialName("value") val value: Int
) : Base()


Performing the following code result in an error

println(Json.decodeFromString<List<Base>>(json))

error Polymorphic serializer was not found for class discriminator

Upvotes: 1

Views: 3178

Answers (3)

solamour
solamour

Reputation: 3194

In case it's not clear to you even after reading the answers multiple times, I'd like to add a little more explanation.

Suppose you have a JSON string as the following.

val s1 = """
    [
        {
            "type": "string_class",
            "id": 1,
            "string": "hello"
        },
        {
            "type": "number_class",
            "id": 1,
            "number": 100
        }
    ]
    """.trimIndent()

Each item in the list looks different (i.e. one has "string" and the other has "number"), and "type" is what distinguishes them.

val json = Json {
    classDiscriminator = "type"
}

@Serializable
sealed class Base {
    abstract val id: Int

    @Serializable
    @SerialName("string_class")
    data class StringClass(
        override val id: Int,
        val string: String,
    ) : Base()

    @Serializable
    @SerialName("number_class")
    data class NumberClass(
        override val id: Int,
        val number: Int,
    ) : Base()
}

This works well, as long as each item has "type" that we can use to distinguish one class from the other. But if there is no "type" you can leverage, you'd need to create a custom Serializer.

val string1 = """
    [
        {
            "id": 1,
            "string": "Hello"
        },
        {
            "id": 1,
            "number": 100
        }
    ]
    """.trimIndent()

You'd need to check what fields are available, and choose the right Serializer.

object BaseSerializer : JsonContentPolymorphicSerializer<Base>(Base::class) {
    override fun selectDeserializer(
        element: JsonElement,
    ): DeserializationStrategy<Base> {
        val jsonObject = element.jsonObject
        return when {
            jsonObject.containsKey("string") -> Base.StringClass.serializer()
            jsonObject.containsKey("number") -> Base.NumberClass.serializer()
            else -> throw SerializationException("not supported")
        }
    }
}

@Serializable(BaseSerializer::class)
sealed class Base {
    abstract val id: Int

    @Serializable
    data class StringClass(
        override val id: Int,
        val string: String,
    ) : Base()


    @Serializable
    data class NumberClass(
        override val id: Int,
        val number: Int,
    ) : Base()
}

Upvotes: 0

Dennis Anderson
Dennis Anderson

Reputation: 1396

@cactustictacs solution came very close. He said that "By default sealed classes are handled by adding a type field to the JSON"

But because I didn't had a type property I needed a other field that decides which subclass it should be.

In Kotlin Serializer you can do that by

 val format = Json {
    classDiscriminator = "PROPERTY_THAT_DEFINES_THE_SUBCLASS"
 }
 val contentType = MediaType.get("application/json")
 val retrofit = Retrofit.Builder()
                .baseUrl(baseUrl)
                .client(client)
                .addConverterFactory(format.asConverterFactory(contentType))
                .build()

where in classDiscriminator you can enter the property that you want. Hope this helps other people in the future.

Upvotes: 1

cactustictacs
cactustictacs

Reputation: 19524

When you say you don't control the API, is that JSON being generated from your code by the Kotlin serialization library? Or is it something else you want to wrangle into your own types?

By default sealed classes are handled by adding a type field to the JSON, which you have in your objects, but it's a property in your Base class. In the next example it shows you how you can add a @SerialName("owned") annotation to say what type value each class corresponds to, which might help you if you can add the right one to your classes? Although in your JSON example both objects have "type" as their type...

If you can't nudge the API response into the right places, you might have to write a custom serializer (it's the deserialize part you care about) to parse things and identify what each object looks like, and construct the appropriate one.

(I don't know a huge amount about the library or anything, just trying to give you some stuff to look at, see if it helps!)

Upvotes: 1

Related Questions