Agung
Agung

Reputation: 13843

how to flatten nested JSON into single class using retrofit and gson converter?

I have a nested JSON like this from Server, as you can see there is a nested data in location

{

    "id": "18941862",
    "name": "Pizza Maru",
    "url": "https://www.zomato.com/jakarta/pizza-maru-1-thamrin?utm_source=api_basic_user&utm_medium=api&utm_campaign=v2.1",
    "location": {
        "address": "Grand Indonesia Mall, East Mall, Lantai 3A, Jl. M.H. Thamrin No. 1, Thamrin, Jakarta",
        "locality": "Grand Indonesia Mall, Thamrin",
        "city": "Jakarta",
        "city_id": 74,
        "latitude": "-6.1954467635",
        "longitude": "106.8216102943",
        "zipcode": "",
        "country_id": 94,
        "locality_verbose": "Grand Indonesia Mall, Thamrin, Jakarta"
    },

    "currency": "IDR"

}

I am using retrofit and using gson converter. usually I need to make 2 data class for something like this to map JSON into POJO. so I need to make Restaurant class and also Location class, but I need to flatten that json object into single Restaurant class, like this

data class Restaurant :  {

    var id: String
    var name: String
    var url: String
    var city: String
    var latitude: Double
    var longitude: Double
    var zipcode: String
    var currency: String 

}

how to do that if I am using retrofit and gson converter ?

java or kotlin are ok

Upvotes: 2

Views: 1078

Answers (1)

Ace
Ace

Reputation: 2218

This solution is a silver bullet for this problem and cannot be appreciated enough.

Take this Kotlin file first:

/**
 * credits to https://github.com/Tishka17/gson-flatten for inspiration
 * Author: A$CE
 */

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FIELD)
annotation class Flatten(val path: String)

class FlattenTypeAdapterFactory(
    private val pathDelimiter: String = "."
): TypeAdapterFactory {

    override fun <T: Any?> create(gson: Gson, type: TypeToken<T>): TypeAdapter<T> {
        val delegateAdapter = gson.getDelegateAdapter(this, type)
        val defaultAdapter = gson.getAdapter(JsonElement::class.java)
        val flattenedFieldsCache = buildFlattenedFieldsCache(type.rawType)

        return object: TypeAdapter<T>() {

            @Throws(IOException::class)
            override fun read(reader: JsonReader): T {
                // if this class has no flattened fields, parse it with regular adapter
                if(flattenedFieldsCache.isEmpty())
                    return delegateAdapter.read(reader)
                // read the whole json string into a jsonElement
                val rootElement = defaultAdapter.read(reader)
                // if not a json object (array, string, number, etc.), parse it
                if(!rootElement.isJsonObject)
                    return delegateAdapter.fromJsonTree(rootElement)
                // it's a json object of type T, let's deal with it
                val root = rootElement.asJsonObject
                // parse each field
                for(field in flattenedFieldsCache) {
                    var element: JsonElement? = root
                    // dive down the path to find the right element
                    for(node in field.path) {
                        // can't dive down null elements, break
                        if(element == null) break
                        // reassign element to next node down
                        element = when {
                            element.isJsonObject -> element.asJsonObject[node]
                            element.isJsonArray -> try {
                                element.asJsonArray[node.toInt()]
                            } catch(e: Exception) { // NumberFormatException | IndexOutOfBoundsException
                                null
                            }
                            else -> null
                        }
                    }
                    // lift deep element to root element level
                    root.add(field.name, element)
                    // this keeps nested element un-removed (i suppose for speed)
                }
                // now parse flattened json
                return delegateAdapter.fromJsonTree(root)
            }

            override fun write(out: JsonWriter, value: T) {
                throw UnsupportedOperationException()
            }
        }.nullSafe()
    }

    // build a cache for flattened fields's paths and names (reflection happens only here)
    private fun buildFlattenedFieldsCache(root: Class<*>): Array<FlattenedField> {
        // get all flattened fields of this class
        var clazz: Class<*>? = root
        val flattenedFields = ArrayList<Field>()
        while(clazz != null) {
            clazz.declaredFields.filterTo(flattenedFields) {
                it.isAnnotationPresent(Flatten::class.java)
            }
            clazz = clazz.superclass
        }

        if(flattenedFields.isEmpty()) {
            return emptyArray()
        }
        val delimiter = pathDelimiter
        return Array(flattenedFields.size) { i ->
            val ff = flattenedFields[i]
            val a = ff.getAnnotation(Flatten::class.java)!!
            val nodes = a.path.split(delimiter)
                .filterNot { it.isEmpty() } // ignore multiple or trailing dots
                .toTypedArray()
            FlattenedField(ff.name, nodes)
        }
    }

    private class FlattenedField(val name: String, val path: Array<String>)
}

Then add it to Gson like this:

val gson = GsonBuilder()
            .registerTypeAdapterFactory(FlattenTypeAdapterFactory())
            .create()
Retrofit.Builder()
            .baseUrl(baseUrl)
            ...
            .addConverterFactory(GsonConverterFactory.create(gson))
            .build()

Using your example, you can get the pojo parsed like this:

// prefer constructor properties
// prefer val over var
// prefer added @SerializedName annotation even to same-name properties:
// to future proof and for easier proguard rule config
data class Restaurant(
    @SerializedName("id") val id: String,
    @SerializedName("name") val name: String,
    @SerializedName("url") val url: String,
    @Flatten("location.city") val city: String,
    @Flatten("location.latitude") val latitude: Double,
    @Flatten("location.longitude") val longitude: Double,
    @Flatten("location.zipcode") val zipcode: String,
    @SerializedName("currency") var currency: String 
)

You can even write a path down an array, e.g @Flatten("friends.0.name") = get first friend's name. For more info, visit this repo

Note, however, that I stripped down the TypeAdapter to only read/consume json objects. You can implement write() if you want to use it to write json too.

You are welcome.

Upvotes: 1

Related Questions