Retrofit an GSON: deserialize a specific object type based on the value of a field


#1

Hi,

I’m trying to deserialize a specific object type based on the value of a field.

this is my json:

    {
     "orderId": 123121,
"note": "notas raul",
"percent": [
{
"category": "Items",
"items": [
{
"category_check": false,
"cccategoryID": 10,
"cccategoryname": " Items",
"itembudgetID": 122,
"description": "Sewer / Septic"
}
]
}
],
"type": 1
}

and this other JSON

{
"orderId": 123123,
"note": null,
"percent": [
{
"id": "123123_1",
"itembudgetID": 123123,
"powID": 0,
"itemno": "1",
"info_description": "blue",
"description": "description",
"subdescription": "",
"linepercent": 0
}
],
"type": 2
}

and the parameter “percent” is different, according to the “typeinsreport”

some help from how you could do it in kotlin, use retrofit and GSON


#2

One way to deal with it is to have percent include [optional] properties for both, then you can use typeinsreport to distinguish between the two when mapping to your business objects. This is the simplest, and cleanest, in my opinion.

You can try a RuntimeTypeAdapterFactory, you likely need to make the carrying class polymorphic, and not percent. You will end up with at least 5 classes, and will need to work around the type identification system.

Otherwise you will likely need to implement a custom deserializer.

I would highly recommend trying option number one first.


#3

Looking at the examples you posted the second option I listed is not really even an option without significant effort. Just use the first option instead.

 
data class PercentItem(

	@SerializedName("info_description")
	val infoDescription: String? = null,

	@SerializedName("linepercent")
	val linepercent: Int? = null,

	@SerializedName("itembudgetID")
	val itembudgetID: Int? = null,

	@SerializedName("subdescription")
	val subdescription: String? = null,

	@SerializedName("description")
	val description: String? = null,

	@SerializedName("id")
	val id: String? = null,

	@SerializedName("itemno")
	val itemno: String? = null,

	@SerializedName("category")
	val category: String? = null,

	@SerializedName("items")
	val items: List<ItemsItem>? = null,

	@SerializedName("powID")
	val powID: Int? = null
)


data class ItemsItem(

	@SerializedName("cccategoryID")
	val cccategoryID: Int? = null,

	@SerializedName("category_check")
	val categoryCheck: Boolean? = null,

	@SerializedName("itembudgetID")
	val itembudgetID: Int? = null,

	@SerializedName("description")
	val description: String? = null,

	@SerializedName("cccategoryname")
	val cccategoryname: String? = null
)


data class Response(

    @SerializedName("order_id")
    val orderId: Int,

    @SerializedName("typeinsreport")
    val typeinsreport: Int,

    @SerializedName("note")
    val note: String? = null,

    @SerializedName("percent")
    val percent: List<PercentItem> = emptyList()
)

then do something equivalent to this:

        val myBizObjects = responses.map {
            when (it.typeinsreport) {
                1 -> TODO("map to new Percent 1 type")
                2 -> TODO("map to new Percent 2 type")
                else -> {
                    TODO("Unexpected")
                }
            }
        }

#4

Before I thank you for your response.

Today in the afternoon I think I found an alternative, I do not know if it would be the best, I put the code

data class PercentResponse (
    var orderId: Int = 0,
    var type: Int = 0,
    var note: String? = "",
    var percent: TypePercent? = null)


data class TypePercent (
    var type1: ArrayList<Type1> = ArrayList(),
    var type2: ArrayList<ItemType2> = ArrayList()
)


data class Type1 (
    val category: String = "",
    val items: ArrayList<ItemType1> = ArrayList()
)

class PercentDeserializer : JsonDeserializer<PercentResponse> {

// @Throws(JsonParseException::class)
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext):     PercentInspectionResponse {
        val jsonObject = json.asJsonObject
        val gson = Gson()

        val percent = PercentResponse()
        percent.orderId = jsonObject.get("orderId").asInt
        percent.type = jsonObject.get("type").asInt
        val noteString = jsonObject.get("note")
        if (!noteString.isJsonNull) {
            percent.note = noteString.asString
        }

        val typePercent= TypePercentInspection()

        val type = jsonObject.get("type").asInt

        if (1 == type) {
            val percentTypes = jsonObject.getAsJsonArray("percent")
            for (percent in percentTypes) {
                val percentClass= gson.fromJson(percent, Type1::class.java)
                typePercent.type1.add(percentClass)
            }
        }

        if (2 == type) {
            val percentTypes = jsonObject.getAsJsonArray("percent")
            for (percent in percentTypes) {
                val percentClass= gson.fromJson(percent, ItemType2::class.java)
                typePercent.type2.add(percentClass)
            }
        }

        percent.percent = typePercent

        return percent
    }
  }

and that register it to the Gson

@Provides
@Singleton
internal fun provideRetrofit(): Retrofit {
    val percentDeserializer =
            GsonBuilder().registerTypeAdapter(PercentResponse::class.java, PercentDeserializer()).create()

    return Retrofit.Builder()
            .baseUrl(API_URL)
            // .addConverterFactory(GsonConverterFactory.create())
            .addConverterFactory(GsonConverterFactory.create(percentDeserializer))
            .client(createClient())
            .build()
}

So I end up doing it, I think it looks like the one option you recommended me, right?


#5

Yes. It’s the third option, custom deserializer.


#6

I tend to separate IO from business logic, so the need to have the deserializer return objects exactly as needed by the core logic is zero, it is going to have to pass through validation and consistency checking regardless.

Unless it has been renamed, your discriminating field was named typeinsreport in your initial example, you have it named type here (jsonObject.get("type").asInt).

There are a few more issues given a quick glance, let me know if you need me to point them out (can only happen much later, I need to go).


#7

Before I go, do you think it would be possible to edit your initial post to show the JSON better formatted?

When I made my initial test I assumed the input would come in a single array or list of some sort, e.g.

[  
  {
    "order_id": 123121,
    "note": "notas raul",
    "percent": [
      {
        "category": "Items",
        "items": [
          {
            "category_check": false,
            "cccategoryID": 10,
            "cccategoryname": " Items",
            "itembudgetID": 122,
            "description": "Sewer / Septic"
          }
        ]
      }
    ],
    "typeinsreport": 1
  },
  {
    "order_id": 123123,
    "note": null,
    "percent": [
      {
        "id": "123123_1",
        "itembudgetID": 123123,
        "powID": 0,
        "itemno": "1",
        "info_description": "blue",
        "description": "description",
        "subdescription": "",
        "linepercent": 0
      }
    ],
    "typeinsreport": 2
  }
// .... ,{ }, etc.
]


But your subsequent code treats them like separate arrays.


#8

sorry, modify my initial json.

There are a few more issues given a quick glance, let me know if you need me to point them out (can only happen much later, I need to go).

please


#9
  • Gson does not specify deserialize with @NotNull parameters, it is possible to get an NPE, and not know why (without a lot of digging).

  • You define (and construct) PercentResponse but return PercentInspectionResponse, same with TypePercentInspection.

  • You don’t use @SerializedName. While Gson allows this, it couples your implementation to the outside world in an unsatisfactory way. You want to be able to change the two independently.

  • You are checking an intput (type) that has disjoint (non-overlapping) values (integers) with two ifs. This is not a “bug”, but a code smell.

  • You probably did not try to compile this code. As you define data classes but try to use them like normal classes.

  • Creating a Gson object inside your deserializer.

Here is a quick conversion. It’s based on my limited understanding of the requirements.

// All code compiled with Kotlin 1.3.10
class ResponseDeserializer : JsonDeserializer<Response> {
    override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): Response {
        checkNotNull(json)  // throws error here, instead of somewhere inside Gson.
        checkNotNull(typeOfT)
        checkNotNull(context)

        val jsonObject = json.asJsonObject
        val type = jsonObject.get("type").asInt

        return Response(
            jsonObject.get("orderId").asInt,
            type,
            jsonObject.get("note").asStringOrNull,
            when (type) {
                1 -> jsonObject.extractList<PercentItem.PercentType1>("percent", context)
                2 -> jsonObject.extractList<PercentItem.PercentType2>("percent", context)
                else -> TODO("Unexpected percent type")
            }
        )
    }

    private inline fun <reified T> JsonObject.extractList(memberName: String, context: JsonDeserializationContext): List<T> {
        return this.getAsJsonArray(memberName).map {
            context.deserialize<T>(it, T::class.java)
        }
    }
}

private val JsonElement.asStringOrNull: String?
    get() = when {
        this.isJsonNull -> null
        else -> this.asString
    }

Models

data class Response(
    @SerializedName("orderId")
    val orderId: Int,
    @SerializedName("type")
    val type: Int,
    @SerializedName("note")
    val note: String? = null,
    @SerializedName("percent")
    val percent: List<PercentItem> = emptyList()
)

sealed class PercentItem {
    data class PercentType1(
        @SerializedName("category")
        val category: String,
        @SerializedName("items")
        val items: List<Item>?
    ) : PercentItem()

    data class PercentType2(
        @SerializedName("id")
        val id: String,
        @SerializedName("itembudgetID")
        val itemBudgetID: Int,
        @SerializedName("powID")
        val powID: Int,
        @SerializedName("itemno")
        val itemNo: String,
        @SerializedName("info_description")
        val infoDescription: String,
        @SerializedName("description")
        val description: String,
        @SerializedName("subdescription")
        val subDescription: String,
        @SerializedName("linepercent")
        val linePercent: Int
    ) : PercentItem()
}

data class Item(
    @SerializedName("categoryID")
    val categoryID: Int,
    @SerializedName("category_check")
    val categoryCheck: Boolean,
    @SerializedName("itembudgetID")
    val budgetID: Int,
    @SerializedName("description")
    val description: String,
    @SerializedName("cccategoryname")
    val categoryName: String
)

Tests, mostly just seeing if the deserializer can make sense of the input.


class ResponseDeserializerTest {

    @Test
    fun type1Response_isNotNull() {
        val response = extract(inputType1)
        assertNotNull(response)
    }

    @Test
    fun type2Response_isNotNull() {
        val response = extract(inputType2)
        assertNotNull(response)
    }

    @Test
    fun type1Response_hasPercentType1Items() {
        val response = extract(inputType1)
        val list = response.percent.filterIsInstance(PercentItem.PercentType1::class.java)
        assertTrue(list.isNotEmpty())
    }

    @Test
    fun type1Response_hasNoPercentType2Items() {
        val response = extract(inputType1)
        val list = response.percent.filterIsInstance(PercentItem.PercentType2::class.java)
        assertTrue(list.isEmpty())
    }

    @Test
    fun type1Response_hasOnlyPercentType1Items() {
        val response = extract(inputType1)
        val all = response.percent.all {
            when (it) {
                is PercentItem.PercentType1 -> true
                else -> false
            }
        }
        assertTrue(all)
    }

    @Test
    fun type1Response_hasOnlyPercentType1ItemsFails() {
        val response = extract(inputType1Broken)
        val all = response.percent.all {
            when (it) {
                is PercentItem.PercentType1 -> !it.category.isNullOrBlank()
                else -> false
            }
        }
        assertFalse(all)
    }

    @Test
    fun type2Response_hasPercentType2Items() {
        val response = extract(inputType2)
        val list = response.percent.filterIsInstance(PercentItem.PercentType2::class.java)
        assertTrue(list.isNotEmpty())
    }

    @Test
    fun type2Response_hasNoPercentType1Items() {
        val response = extract(inputType2)
        val list = response.percent.filterIsInstance(PercentItem.PercentType1::class.java)
        assertTrue(list.isEmpty())
    }

    @Test
    fun responseList_isNotNull() {
        val response = extractArray(inputTypeArray)
        assertNotNull(response)
    }

    private val gson: Gson = GsonBuilder()
        .registerTypeAdapter(Response::class.java, ResponseDeserializer())
        .create()

    private fun extract(input: String): Response {
        return gson.fromJson(input, Response::class.java)
    }

    private fun extractArray(input: String): List<Response> {
        val type = object : TypeToken<List<Response>>() {}.type
        return gson.fromJson(input, type)
    }

    private val inputType1 = """{
        "orderId": 123121,
        "note": "notas raul",
        "percent": [
          {
            "category": "Items",
            "items": [
              {
                "category_check": false,
                "cccategoryID": 10,
                "cccategoryname": " Items",
                "itembudgetID": 122,
                "description": "Sewer / Septic"
              }
            ]
          }
        ],
        "type": 1
      }"""

    private val inputType2 = """{
        "orderId": 123123,
        "note": null,
        "percent": [
          {
            "id": "123123_1",
            "itembudgetID": 123123,
            "powID": 0,
            "itemno": "1",
            "info_description": "blue",
            "description": "description",
            "subdescription": "",
            "linepercent": 0
          }
        ],
        "type": 2
      }"""

    private val inputType1Broken = """{
        "orderId": 1234,
        "note": "Nada",
        "percent": [
          {
            "id": "123123_1",
            "itembudgetID": 123123,
            "powID": 0,
            "itemno": "1",
            "info_description": "blue",
            "description": "description",
            "subdescription": "",
            "linepercent": 0
          }, {
            "category": "Items",
            "items": [
              {
                "category_check": false,
                "cccategoryID": 10,
                "cccategoryname": " Items",
                "itembudgetID": 122,
                "description": "Sewer / Septic"
              }
            ]
          }
        ],
        "type": 1
      }"""

    private val inputTypeArray = """[$inputType1, $inputType2]"""
}

This code does not handle consistency checking. If you hand it a JSON string with "type": 1, but where the internal percent array does not match it will give you objects with nulls. Gson does not respect Kotlin’s property conventions.


#10

sorry, my English is very poor

I wanted to say if
Could you help me with that?


#11

Sorry I don’t understand, who should I ask? I’m only one person. I’m happy to look at it myself, but that is the best I can do.


#12

My pleasure.

You should please take the time to understand what it is doing. The code is the bare minimum that is needed. Needs checking for errors and consistency, at least.

Do you mean “it works very well”? If so, thanks.


#13

I’ll look at it after dinner.


#14

Do you mean “it works very well”? If so, thanks.

Yes