Max Nagl

TypedJsonAdapter for Moshi

posted in Utils on March 10, 2021

TypedJsonAdapter helps you to parse JSON:API or similar JSON files with type fields.

The Problem

Imaging you have a json file containing list of shapes. The shapes can have different types, like squares and rectangles:

{"shapes": [
    {
        "type": "Square",
        "side": 10
    },
    {
        "type": "Rect",
        "width": 20,
        "height": 5
    }
]}

Now, you want to convert this json to a convinient Java (or Kotlin) class:

interface Shape
data class Square(val side: Int) : Shape
data class Rect(val width: Int, val height: Int) : Shape
data class Shapes(val shapes: List)

You could use moshi to parse the file into a list of Map, and then convert the entries to shape:

data class ShapesMap(val shapes: List Square((it["side"] as Number).toInt())
            "Rect" -> Rect((it["width"] as Number).toInt(), (it["height"] as Number).toInt())
            else -> throw IllegalArgumentException()
        }
    }
}

This is a lot of work. You also have a lot of casts and probably errors. You may also need to write extra code to write the JSON file from the object. When you want to add a new class you have to adapt the code.

The Solution

A far better solution is the class TypedJsonAdapter. With this class you cann add an @JsonSubClasses annotation to your Shape interface and the adapter will take care of the work.

@JsonSubClasses([Square::class, Rect::class])
interface Shape
data class Square(val side: Int) : Shape
data class Rect(val width: Int, val height: Int) : Shape
data class Shapes(val shapes: List)

You also have to add the factory for the adapter to moshi:

Moshi.Builder().add(TypedJsonAdapter.Factory()).addLast(KotlinJsonAdapterFactory()).build()

That's it. Now you can start parsing:

val shapesAdapter = moshi.adapter(Shapes::class.java)
val shapes = shapesAdapter.fromJson(shapesJson)
println(shapes) //Prints: Shapes(shapes=[Square(side=10), Rect(width=20, height=5)])

Sealed Classes

TypedJsonAdapter works with sealed classes out of the box.

sealed class SealedShape {
    data class Rect(val width: Int, val height: Int) : SealedShape()
    data class Square(val side: Int) : SealedShape()
}
data class SealedShapes(val shapes: List)

val sealedAdapter = moshi.adapter(SealedShapes::class.java)
val sealedShapes = sealedAdapter.fromJson(shapesJson)

Configuration

There are several parameter you can use to confirue TypedJsonAdapter:

Field Name

You can configure the field name from type to anything you want by adding the fieldName parameter to the annotation:

@JsonSubClasses([Square::class, Rect::class], fieldName = "class")
interface Shape

moshi.adapter(Shaps::class.java).fromJson("""{"class": "Square", "side": 10},""")

If you want to change the field name for the whole project you can configure the default value in TypedJsonAdapter.Factory.

val factory = TypedJsonAdapter.Factory().apply { fieldName = "class" }
val moshi = Moshi.Builder().add(factory).addLast(KotlinJsonAdapterFactory()).build()

Class Names

Sometimes the names of the Java/Kotlin classes do not match the type values in your JSON file. You have two options to change the type names of you classes.

First you can add an @JsonNamedClass annotation directly to the Java/Kotlin class:

@JsonNamedClass("rect")
data class Rectangle(val width: Int, val height: Int) : Shape

The other option is to add a JsonNamedSubClass annotations to your JsonSubClasses annotation:

@JsonSubClasses(named = [
    JsonNamedSubClass(Square::class, "square"),
    JsonNamedSubClass(Rectangle::class, "rectangle"),
    JsonNamedSubClass(Rectangle::class, "rect"),
])
interface Shape

This way you can add multiple type names for one class. Now you can create Recangles by using rectangle or rect.

Qualified Names

When you enable the qualifiedNames flag then every JavaClass can be created using the qualified name:

@JsonSubClasses(qualifiedNames = true)
interface Shape

val shapesAdapter = moshi.adapter(Shapes::class.java)
shapesAdapter.fromJson("""{"type": "na.gl.Square", "side": 10}""")

This can easily cause casting exceptions or security problems. Be carefull when using this feature.

The Class

/*
 * Copyright 2021 Max Nagl
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package na.gl.util.moshi

import com.squareup.moshi.*
import java.lang.reflect.Modifier
import java.lang.reflect.Type
import kotlin.reflect.KClass

class TypedJsonAdapter(private val moshi: Moshi, var fieldName: String) : JsonAdapter<Any>() {
    var typeClasses: HashMap<String, Class<*>> = HashMap()
    var qualifiedNames: Boolean = false
    var default: Class<*>? = null

    override fun fromJson(reader: JsonReader): Any? {
        reader.peekJson().use { peek ->
            peek.beginObject()
            while (peek.hasNext()) {
                if (peek.nextName() == fieldName) return getAdapterForType(peek.nextString()).fromJson(reader)
                peek.skipValue()
            }
        }
        default?.let { return moshi.adapter(it).fromJson(reader) }
        throw JsonDataException("Missing type definition.")
    }

    private fun getAdapterForType(type: String): JsonAdapter<*> {
        typeClasses[type]?.let { return moshi.adapter(it) }
        if (qualifiedNames) try {
            return moshi.adapter(Class.forName(type))
        } catch (e: Exception) {
            throw throw JsonDataException("Class not found: $type", e)
        }
        throw JsonDataException("Unknown type $type")
    }

    override fun toJson(writer: JsonWriter, value: Any?) {
        if (value == null) {
            writer.nullValue()
        } else {
            val clazz = value.javaClass
            val adapter = moshi.adapter(clazz) ?: throw JsonDataException("Unknown class $clazz")
            val type = typeClasses.entries.firstOrNull { it.value == clazz }?.key
            if (type == null && default != clazz) throw JsonDataException("Unknown class $clazz")
            writer.beginObject()
            writer.name(fieldName).value(type)
            val flatten = writer.beginFlatten()
            adapter.toJson(writer, value)
            writer.endFlatten(flatten)
            writer.endObject()
        }
    }

    fun addClass(clazz: Class<*>, name: String? = null) {
        if (clazz.isInterface || Modifier.isAbstract(clazz.modifiers)) return
        if (name == null) {
            typeClasses[clazz.getAnnotation(JsonNamedClass::class.java)?.name ?: clazz.simpleName] = clazz
        } else typeClasses[name] = clazz
    }

    private fun collect(clazz: Class<*>): TypedJsonAdapter {
        val annotations = clazz.annotations.filterIsInstance(JsonSubClasses::class.java)
        if (annotations.any { it.inherit }) {
            clazz.superclass?.let { collect((it)) }
            clazz.interfaces.forEach { collect(it) }
        }
        if (annotations.isEmpty()) {
            clazz.kotlin.sealedSubclasses.forEach { addClass(it.java) }

        } else collect(annotations)
        return this
    }

    private fun collect(annotations: Collection<Annotation>): TypedJsonAdapter {
        annotations.forEach {
            if (it is JsonSubClasses) {
                if (it.default != Unit::class) default = it.default.java
                if (it.qualifiedNames) qualifiedNames = true
                if (it.fieldName.isNotBlank()) fieldName = it.fieldName
                it.classes.forEach { clazz ->
                    if (it.extend) collect(clazz.java)
                    addClass(clazz.java)
                }
                collect(it.named.asList())
            }
            if (it is JsonNamedSubClass) {
                if (it.extend) collect(it.clazz.java)
                addClass(it.clazz.java, it.name)
            }
        }
        return this
    }

    private fun isEmpty() = typeClasses.isEmpty() && !qualifiedNames

    private fun copy() = TypedJsonAdapter(moshi, fieldName).also { copy ->
        copy.typeClasses = HashMap(typeClasses)
        copy.qualifiedNames = qualifiedNames
        copy.default = default
    }

    class Factory : JsonAdapter.Factory {
        private val adapterCache = HashMap<Class<*>, TypedJsonAdapter>()
        var fieldName = "type"

        fun getPrototype(type: Type, moshi: Moshi): TypedJsonAdapter? {
            if (type !is Class<*>) return null
            return adapterCache.getOrPut(type) { TypedJsonAdapter(moshi, fieldName).collect(type) }
        }

        override fun create(type: Type, annotations: Set<Annotation>, moshi: Moshi): JsonAdapter<*>? {
            var typeClasses = getPrototype(type, moshi) ?: return null
            if (typeClasses.isEmpty() && annotations.none { it is JsonSubClasses }) return null
            if (annotations.isNotEmpty()) typeClasses = typeClasses.copy().collect(annotations)
            return typeClasses
        }
    }
}

@JsonQualifier
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.PROPERTY)
annotation class JsonSubClasses(
        val classes: Array<KClass<*>> = [],
        val named: Array<JsonNamedSubClass> = [],
        val default: KClass<*> = Unit::class,
        val qualifiedNames: Boolean = false,
        val fieldName: String = "",
        val inherit: Boolean = false,
        val extend: Boolean = true)

@JsonQualifier
@Repeatable
@Retention(AnnotationRetention.RUNTIME)
annotation class JsonNamedSubClass(val clazz: KClass<*>, val name: String, val extend: Boolean = true)

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
annotation class JsonNamedClass(val name: String)