TypedJsonAdapter for Moshi
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
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
Class Names
Qualified Names
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)