Search code examples
androidkotlingsonretrofit

How to change root of JSON response before cast to typed entity?


Task

The remote server returns a response with a body:

{
    done: true,
    response: {
        result: {
            // data
        }
    }
}

In case of an error, a response with 200 status and a body:

{
    done: false,
    error: {
        message: ""
    }
}

Before JSON is transferred to typed entities, I want to set the result data as the root of the response.

{
    // data
}

I can't create a wrapper model for all functions of Retrofit's interface (this is a long story why).

My solution

I have created a custom factory ProxyGsonConverterFactory (based on GsonConverterFactory.java). It works as expected.

Look at GsonResponseBodyConverter class. In order to change the json root, the class uses org.json.JSONObject. It parses a string into an object and gets resultData. Then convert the resultData back to string and use gson to cast it to generic T.

class ProxyGsonConverterFactory (
    private val gson: Gson
) : Converter.Factory() {

    override fun responseBodyConverter(
        type: Type, annotations: Array<Annotation>, retrofit: Retrofit
    ): Converter<ResponseBody, *> {
        val adapter = gson.getAdapter(TypeToken.get(type))
        return GsonResponseBodyConverter(gson, adapter)
    }

    override fun requestBodyConverter(
        type: Type,
        parameterAnnotations: Array<Annotation>,
        methodAnnotations: Array<Annotation>,
        retrofit: Retrofit
    ): Converter<*, RequestBody> {
        val adapter = gson.getAdapter(TypeToken.get(type))
        return GsonRequestBodyConverter(gson, adapter)
    }
}

internal class GsonResponseBodyConverter<T>(
    private val gson: Gson,
    private val adapter: TypeAdapter<T>
) : Converter<ResponseBody, T> {

    @Throws(IOException::class)
    override fun convert(value: ResponseBody): T {
        val jsonObject = JSONObject(value.string())
        val jsonPayload = jsonObject.optJSONObject("response")?.opt("result")
        if (jsonPayload == null) {
            val errorMessage = jsonObject.optJSONObject("error")?.toString()
            throw ProxyExecutionException(errorMessage ?: "GsonResponseBodyConverter: payload is null")
        }
        
        val modifiedJsonStr = jsonPayload.toString()
        val jsonReader = gson.newJsonReader(modifiedJsonStr.reader())
        val result = adapter.read(jsonReader)
        if (jsonReader.peek() != JsonToken.END_DOCUMENT) {
            throw JsonIOException("JSON document was not fully consumed.")
        }
        jsonReader.close()
        return result
    }
}


internal class GsonRequestBodyConverter<T>(
    private val gson: Gson,
    private val adapter: TypeAdapter<T>
) : Converter<T, RequestBody> {

    @Throws(IOException::class)
    override fun convert(value: T): RequestBody {
        val buffer = Buffer()
        val writer = OutputStreamWriter(buffer.outputStream(), StandardCharsets.UTF_8)
        val jsonWriter = gson.newJsonWriter(writer)
        adapter.write(jsonWriter, value)
        jsonWriter.close()
        return buffer.readByteString().toRequestBody(MEDIA_TYPE)
    }

    companion object {
        private val MEDIA_TYPE: MediaType = "application/json; charset=UTF-8".toMediaType()
    }
}

Issue

The GsonResponseBodyConverter parses a JSON string twice. With org.json.JSONObject it takes more time. Can I solve the task with only one parse?

For example, the function ProxyGsonConverterFactory.responseBodyConverter contains Type of generic T to create gson adapter. Can we put adapter of T to parent adapter ResponseWrapper in order to parse one time and then return only T body?

data class ResponseWrapper <T>(
    val done: boolean
    val response: ResponseBody<T>
)

data class ResponseBody <T>(
    val result: T
)

Solution

  • The GsonResponseBodyConverter parses a JSON string twice. With org.json.JSONObject it takes more time. Can I solve the task with only one parse?

    Yes, Gson provides API similar to the org.json.* one with its JsonElement class and subclasses. Additionally there is TypeAdapter#fromJsonTree(JsonElement) so you can directly use the parsed value without having to create an intermediate String again, for example:

    override fun convert(value: ResponseBody): T {
        val responseJson = gson.fromJson(value.charStream(), JsonObject::class.java)
        val resultJson: JsonElement? = responseJson.getAsJsonObject("response")
            ?.get("result")
        if (resultJson != null) {
            return adapter.fromJsonTree(resultJson)
        }
    
        val errorMessage = responseJson.getAsJsonObject("error")
            ?.getAsJsonPrimitive("message")?.asString
        throw ProxyExecutionException(errorMessage ?: "GsonResponseBodyConverter: payload is null")
    }
    

    The disadvantage with this is that it still has to fully parse the response as JsonObject first before actually deserializing it.

    If you know that "done": ... always comes first, or make assumptions that "response": ... always means success, you can implement this more efficiently by directly inspecting the JSON from the JsonReader. The disadvantage is that this becomes more verbose and error-prone:

    override fun convert(value: ResponseBody): T {
        val jsonReader = gson.newJsonReader(value.charStream())
        var result: T? = null
    
        jsonReader.beginObject()
        while (jsonReader.hasNext()) {
            val name = jsonReader.nextName()
            when (name) {
                "response" -> {
                    if (result != null) {
                        throw ProxyExecutionException("Duplicate result")
                    }
                    
                    jsonReader.beginObject()
                    require(jsonReader.nextName() == "result")
                    
                    // If you don't want to validate the remainder of the JSON data,
                    // you can instead directly return the result here
                    result = adapter.read(jsonReader)
                    jsonReader.endObject()
                }
                "error" -> {
                    // You might want to implement more lenient handling here if
                    // no message exists
                    jsonReader.beginObject()
                    require(jsonReader.nextName() == "message")
                    val message = jsonReader.nextString()
                    throw ProxyExecutionException(message)
                }
                // Ignore other properties
                // You might want to handle this differently
                else -> jsonReader.skipValue()
            }
        }
        jsonReader.endObject()
        if (jsonReader.peek() != JsonToken.END_DOCUMENT) {
            throw JsonIOException("JSON document was not fully consumed.")
        }
    
        return result ?:
            throw ProxyExecutionException("Response contains neither error not success payload")
    
    }
    

    (Note that I haven't tested this yet, and it might need adjustments for error handling or handling of unexpected JSON data, but it should give a rough idea of how to implement it.)