'Moshi: Parse single object or list of objects (kotlin)

Problem

How to parse either a single Warning object or a list of Warning objects (List<Warning>) from an API using Moshi?

The response as a single warning:

{
  "warnings": {...}
}

The response as a list of warnings:

{
  "warnings": [{...}, {...}]
}

Trial and Error

Tried to shoehorn an autogenerated Moshi adapter. Tried to build on top of it but failed.

Solution

Generalized approach with a factory

I tried to translate the adapter Eric wrote from Java to Kotlin since I realized that a more general approach is better much like Eric points out in his reply.

Once it works, I will revise this post to make it easier to understand. A bit messy now I'm sorry.

EDIT: I ended up using the solution Eric suggested in another thread (translated into Kotlin).

Adapter with factory

package org.domain.name

import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonQualifier
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import java.util.Collections
import java.lang.reflect.Type
import kotlin.annotation.AnnotationRetention.RUNTIME
import kotlin.annotation.AnnotationTarget.FIELD

class SingleToArrayAdapter(
    val delegateAdapter: JsonAdapter<List<Any>>,
    val elementAdapter: JsonAdapter<Any>
) : JsonAdapter<Any>() {

    companion object {
        val factory = SingleToArrayAdapterFactory()
    }

    override fun fromJson(reader: JsonReader): Any? =
        if (reader.peek() != JsonReader.Token.BEGIN_ARRAY) {
            Collections.singletonList(elementAdapter.fromJson(reader))
        } else delegateAdapter.fromJson(reader)

    override fun toJson(writer: JsonWriter, value: Any?) =
        throw UnsupportedOperationException("SingleToArrayAdapter is only used to deserialize objects")

    class SingleToArrayAdapterFactory : JsonAdapter.Factory {
        override fun create(type: Type, annotations: Set<Annotation>, moshi: Moshi): JsonAdapter<Any>? {
            val delegateAnnotations = Types.nextAnnotations(annotations, SingleToArray::class.java) ?: return null
            if (Types.getRawType(type) !== List::class.java) throw IllegalArgumentException("Only List can be annotated with @SingleToArray. Found: $type")
            val elementType = Types.collectionElementType(type, List::class.java)
            val delegateAdapter: JsonAdapter<List<Any>> = moshi.adapter(type, delegateAnnotations)
            val elementAdapter: JsonAdapter<Any> = moshi.adapter(elementType)
            return SingleToArrayAdapter(delegateAdapter, elementAdapter)
        }
    }
}

Qualifier

Note: I had to add the @Target(FIELD).

@Retention(RUNTIME)
@Target(FIELD)
@JsonQualifier
annotation class SingleToArray

Usage

Annotate a field you want to make sure is parsed as a list with @SingleToArray.

data class Alert(
    @SingleToArray
    @Json(name = "alert")
    val alert: List<Warning>
)

and add the adapter factory to your Moshi instance:

val moshi = Moshi.Builder()
    .add(SingleToArrayAdapter.factory)
    .build()

Reference



Solution 1:[1]

the API returns either 1 object if there is only 1 or > 1 a list of objects.

Create an adapter that peeks to see if you're getting an array first. Here is exactly what you want. It includes a qualifier, so you can apply it only to lists that may have this behavior for single items. @SingleToArray List<Warning>.

There is another example of dealing with multiple formats here for further reading.

Solution 2:[2]

Another Solution

Based on Eric Cochran's Java version solution.

import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonAdapter.Factory
import com.squareup.moshi.JsonQualifier
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonReader.Token.BEGIN_ARRAY
import com.squareup.moshi.JsonWriter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import java.lang.reflect.Type
import java.util.Collections.singletonList
import java.util.Collections.emptyList
import kotlin.annotation.AnnotationRetention.RUNTIME
import kotlin.annotation.AnnotationTarget.PROPERTY
import org.junit.Assert.assertEquals

@Retention(RUNTIME)
@Target(PROPERTY)
@JsonQualifier
annotation class SingleOrList

object SingleOrListAdapterFactory : Factory {
    override fun create(
        type: Type,
        annotations: Set<Annotation>,
        moshi: Moshi
    ): JsonAdapter<*>? {
        val delegateAnnotations = Types.nextAnnotations(annotations, SingleOrList::class.java)
            ?: return null
        if (Types.getRawType(type) !== List::class.java) {
            throw IllegalArgumentException("@SingleOrList requires the type to be List. Found this type: $type")
        }
        val elementType = Types.collectionElementType(type, List::class.java)
        val delegateAdapter: JsonAdapter<List<Any?>?> = moshi.adapter(type, delegateAnnotations)
        val singleElementAdapter: JsonAdapter<Any?> = moshi.adapter(elementType)
        return object : JsonAdapter<List<Any?>?>() {
            override fun fromJson(reader: JsonReader): List<Any?>? =
                if (reader.peek() !== BEGIN_ARRAY)
                    singletonList(singleElementAdapter.fromJson(reader))
                else
                    delegateAdapter.fromJson(reader)
            override fun toJson(writer: JsonWriter, value: List<Any?>?) {
                if (value == null) return
                if (value.size == 1)
                    singleElementAdapter.toJson(writer, value[0])
                else
                    delegateAdapter.toJson(writer, value)
            }
        }
    }
}

class TheUnitTest {

    @JsonClass(generateAdapter = true)
    internal data class MockModel(
        @SingleOrList
        val thekey: List<String>
    )

    @Test
    @Throws(Exception::class)
    fun testAdapter() {
        val moshi = Moshi.Builder().add(SingleOrListAdapterFactory).build()
        val adapter: JsonAdapter<List<String>> = moshi.adapter(
            Types.newParameterizedType(
                List::class.java,
                String::class.java),
            SingleOrList::class.java
        )
        assertEquals(adapter.fromJson("[\"Alice\",\"Bob\"]"), listOf("Alice", "Bob"))
        assertEquals(adapter.toJson(listOf("Bob", "Alice")), "[\"Bob\",\"Alice\"]")

        assertEquals(adapter.fromJson("\"Alice\""), singletonList("Alice"))
        assertEquals(adapter.toJson(singletonList("Alice")), "\"Alice\"")

        assertEquals(adapter.fromJson("[]"), emptyList<String>())
        assertEquals(adapter.toJson(emptyList()), "[]")
    }

    @Test
    fun testDataClassUsage() {
        val j1 = """
            {
                "thekey": "value1"
            }
        """.trimIndent()
        val j2 = """
            {
                "thekey": [
                    "value1",
                    "value2",
                    "value3"
                ]
            }
        """.trimIndent()

        val o1 = MockModel::class.java.fromJson(j1, moshi)?.thekey
        val o2 = MockModel::class.java.fromJson(j2, moshi)?.thekey

        if (o1 != null && o2 != null) {
            assertEquals(o1.size, 1)
            assertEquals(o1[0], "value1")
            assertEquals(o2.size, 3)
            assertEquals(o2[0], "value1")
            assertEquals(o2[1], "value2")
            assertEquals(o2[2], "value3")
        }
    }
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Eric Cochran
Solution 2 George