andrew
andrew

Reputation: 797

Serialize sealed class with Moshi

The following will produce an IllegalArgumentException because you "Cannot serialize abstract class"

sealed class Animal {
    data class Dog(val isGoodBoy: Boolean) : Animal()
    data class Cat(val remainingLives: Int) : Animal()
}

private val moshi = Moshi.Builder()
    .build()

@Test
fun test() {
    val animal: Animal = Animal.Dog(true)
    println(moshi.adapter(Animal::class.java).toJson(animal))
}

I have tried solving this using a custom adapter, but the only solution I could figure out involves explicitly writing all of the property names for each subclass. e.g:

class AnimalAdapter {
    @ToJson
    fun toJson(jsonWriter: JsonWriter, animal: Animal) {
        jsonWriter.beginObject()
        jsonWriter.name("type")
        when (animal) {
            is Animal.Dog -> jsonWriter.value("dog")
            is Animal.Cat -> jsonWriter.value("cat")
        }

        jsonWriter.name("properties").beginObject()
        when (animal) {
            is Animal.Dog -> jsonWriter.name("isGoodBoy").value(animal.isGoodBoy)
            is Animal.Cat -> jsonWriter.name("remainingLives").value(animal.remainingLives)
        }
        jsonWriter.endObject().endObject()
    }

    ....
}

Ultimately I'm looking to produce JSON that looks like this:

{
    "type" : "cat",
    "properties" : {
        "remainingLives" : 6
    }
}
{
    "type" : "dog",
    "properties" : {
        "isGoodBoy" : true
    }
}

I'm happy with having to use the custom adapter to write the name of each type, but I need a solution that will automatically serialize the properties for each type rather than having to write them all manually.

Upvotes: 11

Views: 11896

Answers (4)

Luciano
Luciano

Reputation: 2798

This can be done with PolymorphicJsonAdapterFactory and including an extra property in the json to specify the type.

For example:

This JSON

{
  "animals": [
    { 
        "type": "dog",
        "isGoodBoy": true
    },
    {
        "type": "cat",
        "remainingLives": 9
    }    
  ]
}

Can be mapped to the following classes

sealed class Animal {
    @JsonClass(generateAdapter = true)
    data class Dog(val isGoodBoy: Boolean) : Animal()

    @JsonClass(generateAdapter = true)
    data class Cat(val remainingLives: Int) : Animal()

    object Unknown : Animal()
}

With the following Moshi config

Moshi.Builder()
    .add(
        PolymorphicJsonAdapterFactory.of(Animal::class.java, "type")
            .withSubtype(Animal.Dog::class.java, "dog")
            .withSubtype(Animal.Cat::class.java, "cat")
            .withDefaultValue(Animal.Unknown)
    )

Upvotes: 14

Jc Miñarro
Jc Miñarro

Reputation: 1411

You should be able to create your own JsonAdapter.Factory and provide custom adapter whenever an Animal need to be serialized/deserialized:

sealed class Animal {
    @JsonClass(generateAdapter = true)
    data class Dog(val isGoodBoy: Boolean) : Animal()

    @JsonClass(generateAdapter = true)
    data class Cat(val remainingLives: Int) : Animal()
}

object AnimalAdapterFactory : JsonAdapter.Factory {
    override fun create(type: Type, annotations: MutableSet<out Annotation>, moshi: Moshi): JsonAdapter<*>? =
        when (type) {
            Animal::class.java -> AnimalAdapter(moshi)
            else -> null
        }

    private class AnimalAdapter(moshi: Moshi) : JsonAdapter<Animal>() {

        private val mapAdapter: JsonAdapter<MutableMap<String, Any?>> =
            moshi.adapter(Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java))
        private val dogAdapter = moshi.adapter(Animal.Dog::class.java)
        private val catAdapter = moshi.adapter(Animal.Cat::class.java)

        override fun fromJson(reader: JsonReader): Animal? {
            val mapValues = mapAdapter.fromJson(reader)
            val type = mapValues?.get("type") ?: throw Util.missingProperty("type", "type", reader)
            val properties = mapValues["properties"] ?: throw Util.missingProperty("properties", "properties", reader)
            return when (type) {
                "dog" -> dogAdapter.fromJsonValue(properties)
                "cat" -> catAdapter.fromJsonValue(properties)
                else -> null
            }
        }

        override fun toJson(writer: JsonWriter, value: Animal?) {
            writer.beginObject()
            writer.name("type")
            when (value) {
                is Animal.Dog -> writer.value("dog")
                is Animal.Cat -> writer.value("cat")
            }

            writer.name("properties")
            when (value) {
                is Animal.Dog -> dogAdapter.toJson(writer, value)
                is Animal.Cat -> catAdapter.toJson(writer, value)
            }
            writer.endObject()
        }
    }
}

private val moshi = Moshi.Builder()
    .add(AnimalAdapterFactory)
    .build()

@Test
fun test() {
    val dog: Animal = Animal.Dog(true)
    val cat: Animal = Animal.Cat(7)
    println(moshi.adapter(Animal::class.java).toJson(dog))
    println(moshi.adapter(Animal::class.java).toJson(cat))
    val shouldBeDog: Animal? = moshi.adapter(Animal::class.java).fromJson(moshi.adapter(Animal::class.java).toJson(dog))
    val shouldBeCat: Animal? = moshi.adapter(Animal::class.java).fromJson(moshi.adapter(Animal::class.java).toJson(cat))
    println(shouldBeDog)
    println(shouldBeCat)
}

Upvotes: 1

andrew
andrew

Reputation: 797

I have solved this by creating a Factory, an enclosing class, and an enum that can provide the classes for each item type. However this feels rather clunky and I would love a more straight forward solution.

data class AnimalObject(val type: AnimalType, val properties: Animal)

enum class AnimalType(val derivedClass: Class<out Animal>) {
    DOG(Animal.Dog::class.java),
    CAT(Animal.Cat::class.java)
}

class AnimalFactory : JsonAdapter.Factory {
    override fun create(type: Type, annotations: MutableSet<out Annotation>, moshi: Moshi): JsonAdapter<AnimalObject>? {
        if (!Types.getRawType(type).isAssignableFrom(AnimalObject::class.java)) {
            return null
        }

        return object : JsonAdapter<AnimalObject>() {
            private val animalTypeAdapter = moshi.adapter<AnimalType>(AnimalType::class.java)

            override fun fromJson(reader: JsonReader): AnimalObject? {
                TODO()
            }

            override fun toJson(writer: JsonWriter, value: AnimalObject?) {
                writer.beginObject()
                writer.name("type")
                animalTypeAdapter.toJson(writer, value!!.type)
                writer.name("properties")
                moshi.adapter<Animal>(value.type.derivedClass).toJson(writer, value.properties)
                writer.endObject()
            }
        }
    }
}

Answer is taken from: github.com/square/moshi/issues/813

Upvotes: 1

alexy
alexy

Reputation: 462

I think you need the polymorphic adapter to achieve this which requires the moshi-adapters artifact. This will enable serialization of sealed classes with different properties. More details are in this article here: https://proandroiddev.com/moshi-polymorphic-adapter-is-d25deebbd7c5

Upvotes: 1

Related Questions