How to create a call adapter for suspending functions in Retrofit?

21,073

Solution 1

Here is a working example of an adapter, which automatically wraps a response to the Result wrapper. A GitHub sample is also available.

// build.gradle

...
dependencies {
    implementation 'com.squareup.retrofit2:retrofit:2.6.1'
    implementation 'com.squareup.retrofit2:converter-gson:2.6.1'
    implementation 'com.google.code.gson:gson:2.8.5'
}
// test.kt

...
sealed class Result<out T> {
    data class Success<T>(val data: T?) : Result<T>()
    data class Failure(val statusCode: Int?) : Result<Nothing>()
    object NetworkError : Result<Nothing>()
}

data class Bar(
    @SerializedName("foo")
    val foo: String
)

interface Service {
    @GET("bar")
    suspend fun getBar(): Result<Bar>

    @GET("bars")
    suspend fun getBars(): Result<List<Bar>>
}

abstract class CallDelegate<TIn, TOut>(
    protected val proxy: Call<TIn>
) : Call<TOut> {
    override fun execute(): Response<TOut> = throw NotImplementedError()
    override final fun enqueue(callback: Callback<TOut>) = enqueueImpl(callback)
    override final fun clone(): Call<TOut> = cloneImpl()

    override fun cancel() = proxy.cancel()
    override fun request(): Request = proxy.request()
    override fun isExecuted() = proxy.isExecuted
    override fun isCanceled() = proxy.isCanceled

    abstract fun enqueueImpl(callback: Callback<TOut>)
    abstract fun cloneImpl(): Call<TOut>
}

class ResultCall<T>(proxy: Call<T>) : CallDelegate<T, Result<T>>(proxy) {
    override fun enqueueImpl(callback: Callback<Result<T>>) = proxy.enqueue(object: Callback<T> {
        override fun onResponse(call: Call<T>, response: Response<T>) {
            val code = response.code()
            val result = if (code in 200 until 300) {
                val body = response.body()
                Result.Success(body)
            } else {
                Result.Failure(code)
            }

            callback.onResponse(this@ResultCall, Response.success(result))
        }

        override fun onFailure(call: Call<T>, t: Throwable) {
            val result = if (t is IOException) {
                Result.NetworkError
            } else {
                Result.Failure(null)
            }

            callback.onResponse(this@ResultCall, Response.success(result))
        }
    })

    override fun cloneImpl() = ResultCall(proxy.clone())
}

class ResultAdapter(
    private val type: Type
): CallAdapter<Type, Call<Result<Type>>> {
    override fun responseType() = type
    override fun adapt(call: Call<Type>): Call<Result<Type>> = ResultCall(call)
}

class MyCallAdapterFactory : CallAdapter.Factory() {
    override fun get(
        returnType: Type,
        annotations: Array<Annotation>,
        retrofit: Retrofit
    ) = when (getRawType(returnType)) {
        Call::class.java -> {
            val callType = getParameterUpperBound(0, returnType as ParameterizedType)
            when (getRawType(callType)) {
                Result::class.java -> {
                    val resultType = getParameterUpperBound(0, callType as ParameterizedType)
                    ResultAdapter(resultType)
                }
                else -> null
            }
        }
        else -> null
    }
}

/**
 * A Mock interceptor that returns a test data
 */
class MockInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): okhttp3.Response {
        val response = when (chain.request().url().encodedPath()) {
            "/bar" -> """{"foo":"baz"}"""
            "/bars" -> """[{"foo":"baz1"},{"foo":"baz2"}]"""
            else -> throw Error("unknown request")
        }

        val mediaType = MediaType.parse("application/json")
        val responseBody = ResponseBody.create(mediaType, response)

        return okhttp3.Response.Builder()
            .protocol(Protocol.HTTP_1_0)
            .request(chain.request())
            .code(200)
            .message("")
            .body(responseBody)
            .build()
    }
}

suspend fun test() {
    val mockInterceptor = MockInterceptor()
    val mockClient = OkHttpClient.Builder()
        .addInterceptor(mockInterceptor)
        .build()

    val retrofit = Retrofit.Builder()
        .baseUrl("https://mock.com/")
        .client(mockClient)
        .addCallAdapterFactory(MyCallAdapterFactory())
        .addConverterFactory(GsonConverterFactory.create())
        .build()

    val service = retrofit.create(Service::class.java)
    val bar = service.getBar()
    val bars = service.getBars()
    ...
}
...

Solution 2

When you use Retrofit 2.6.0 with coroutines you don't need a wrapper anymore. It should look like below:

@GET("user")
suspend fun getUser(): User

You don't need MyResponseWrapper anymore, and when you call it, it should look like

runBlocking {
   val user: User = service.getUser()
}

To get the retrofit Response you can do the following:

@GET("user")
suspend fun getUser(): Response<User>

You also don't need the MyWrapperAdapterFactory or the MyWrapperAdapter.

Hope this answered your question!

Edit CommonsWare@ has also mentioned this in the comments above

Edit Handling error could be as follow:

sealed class ApiResponse<T> {
    companion object {
        fun <T> create(response: Response<T>): ApiResponse<T> {
            return if(response.isSuccessful) {
                val body = response.body()
                // Empty body
                if (body == null || response.code() == 204) {
                    ApiSuccessEmptyResponse()
                } else {
                    ApiSuccessResponse(body)
                }
            } else {
                val msg = response.errorBody()?.string()
                val errorMessage = if(msg.isNullOrEmpty()) {
                    response.message()
                } else {
                    msg
                }
                ApiErrorResponse(errorMessage ?: "Unknown error")
            }
        }
    }
}

class ApiSuccessResponse<T>(val data: T): ApiResponse<T>()
class ApiSuccessEmptyResponse<T>: ApiResponse<T>()
class ApiErrorResponse<T>(val errorMessage: String): ApiResponse<T>()

Where you just need to call create with the response as ApiResponse.create(response) and it should return correct type. A more advanced scenario could be added here as well, by parsing the error if it is not just a plain string.

Solution 3

This question came up in the pull request where suspend was introduced to Retrofit.

matejdro: From what I see, this MR completely bypasses call adapters when using suspend functions. I'm currently using custom call adapters for centralising parsing of error body (and then throwing appropriate exceptions), smilarly to the official retrofit2 sample. Any chance we get alternative to this, some kind of adapter that is injected between here?

It turns out this is not supported (yet?).

Source: https://github.com/square/retrofit/pull/2886#issuecomment-438936312


For error handling I went for something like this to invoke api calls:

suspend fun <T : Any> safeApiCall(call: suspend () -> Response<T>): MyWrapper<T> {
    return try {
        val response = call.invoke()
        when (response.code()) {
            // return MyWrapper based on response code
            // MyWrapper is sealed class with subclasses Success and Failure
        }
    } catch (error: Throwable) {
        Failure(error)
    }
}
Share:
21,073
harold_admin
Author by

harold_admin

Updated on November 01, 2020

Comments

  • harold_admin
    harold_admin over 3 years

    I need to create a retrofit call adapter which can handle such network calls:

    @GET("user")
    suspend fun getUser(): MyResponseWrapper<User>
    

    I want it to work with Kotlin Coroutines without using Deferred. I have already have a successful implementation using Deferred, which can handle methods such as:

    @GET("user")
    fun getUser(): Deferred<MyResponseWrapper<User>>
    

    But I want the ability make the function a suspending function and remove the Deferred wrapper.

    With suspending functions, Retrofit works as if there is a Call wrapper around the return type, so suspend fun getUser(): User is treated as fun getUser(): Call<User>

    My Implementation

    I have tried to create a call adapter which tries to handle this. Here is my implementation so far:

    Factory

    class MyWrapperAdapterFactory : CallAdapter.Factory() {
    
        override fun get(returnType: Type, annotations: Array<Annotation>, retrofit: Retrofit): CallAdapter<*, *>? {
    
            val rawType = getRawType(returnType)
    
            if (rawType == Call::class.java) {
    
                returnType as? ParameterizedType
                    ?: throw IllegalStateException("$returnType must be parameterized")
    
                val containerType = getParameterUpperBound(0, returnType)
    
                if (getRawType(containerType) != MyWrapper::class.java) {
                    return null
                }
    
                containerType as? ParameterizedType
                    ?: throw IllegalStateException("MyWrapper must be parameterized")
    
                val successBodyType = getParameterUpperBound(0, containerType)
                val errorBodyType = getParameterUpperBound(1, containerType)
    
                val errorBodyConverter = retrofit.nextResponseBodyConverter<Any>(
                    null,
                    errorBodyType,
                    annotations
                )
    
                return MyWrapperAdapter<Any, Any>(successBodyType, errorBodyConverter)
            }
            return null
        }
    

    Adapter

    class MyWrapperAdapter<T : Any>(
        private val successBodyType: Type
    ) : CallAdapter<T, MyWrapper<T>> {
    
        override fun adapt(call: Call<T>): MyWrapper<T> {
            return try {
                call.execute().toMyWrapper<T>()
            } catch (e: IOException) {
                e.toNetworkErrorWrapper()
            }
        }
    
        override fun responseType(): Type = successBodyType
    }
    
    runBlocking {
      val user: MyWrapper<User> = service.getUser()
    }
    

    Everything works as expected using this implementation, but just before the result of the network call is delivered to the user variable, I get the following error:

    java.lang.ClassCastException: com.myproject.MyWrapper cannot be cast to retrofit2.Call
    
        at retrofit2.HttpServiceMethod$SuspendForBody.adapt(HttpServiceMethod.java:185)
        at retrofit2.HttpServiceMethod.invoke(HttpServiceMethod.java:132)
        at retrofit2.Retrofit$1.invoke(Retrofit.java:149)
        at com.sun.proxy.$Proxy6.getText(Unknown Source)
        ...
    

    From Retrofit's source, here is the piece of code at HttpServiceMethod.java:185:

        @Override protected Object adapt(Call<ResponseT> call, Object[] args) {
          call = callAdapter.adapt(call); // ERROR OCCURS HERE
    
          //noinspection unchecked Checked by reflection inside RequestFactory.
          Continuation<ResponseT> continuation = (Continuation<ResponseT>) args[args.length - 1];
          return isNullable
              ? KotlinExtensions.awaitNullable(call, continuation)
              : KotlinExtensions.await(call, continuation);
        }
    

    I'm not sure how to handle this error. Is there a way to fix?

  • harold_admin
    harold_admin almost 5 years
    I know I don't need a wrapper, but I want a wrapper. The wrapper I want to use provides an alternate way to handle an any errors that may have occurred in the network call.
  • Hakem Zaied
    Hakem Zaied almost 5 years
    You can check if the response is successful as response.isSuccessful and then if it was failure you can handle the response body differently by parsing the response.errorBody() if there was an error body if not then response.message()
  • Mirjalal
    Mirjalal almost 5 years
    suspend fun getUser(): Response<User> helped me. thanks!
  • harold_admin
    harold_admin almost 5 years
    Thank you for highlighting that comment. It looks like the developers of Retrofit are aware of this problem. I guess we will just have to wait and see what happens.
  • JavierSegoviaCordoba
    JavierSegoviaCordoba almost 5 years
    I am trying to create a wrapper too for this case (handle error directly with the adapter). Anyone get it working?
  • maxbeaudoin
    maxbeaudoin almost 5 years
    @mikael any way this could be turned into an extension method?
  • harold_admin
    harold_admin over 4 years
    Thank you for this. I have tested it and it works. My issue on Retrofit's repo still remains open in hope of an official sample.
  • qnd-S
    qnd-S over 4 years
    Thanks for you solution! But it won't work if the response is a list of objects, e.g. Result<List<Foo>>. To complete the solution, we should change Type as the input of ResultAdapter, instead of Class<T>, so that responseType() function also return a type. in MyCallAdapterFactory, change ResultAdapter(getRawType(resultType)) to ResultAdapter<Any>(resultType)
  • aaronmarino
    aaronmarino over 4 years
    Thank you @qnd-S. I just wasted several hours trying to figure out why my Type wasn't being registered in Gson's registerTypeAdapter and your solution fixed it for me. @Valeriy Katkov, please consider updating your answer.
  • Valeriy Katkov
    Valeriy Katkov over 4 years
    Thanks @aaronmarino and qnd-S! I've updated the answer and the sample.
  • qnd-S
    qnd-S over 4 years
    Glad that it helps. Happy coding!
  • Daniel Wilson
    Daniel Wilson over 4 years
    ApiResponse does not work as a return type while the suspend keyword is in use, the call throws an error along the lines of Unable to invoke no-args constructor for interface
  • Roshaan Farrukh
    Roshaan Farrukh over 4 years
    This solution works, but if I cancel my coroutine would it cancel the request as well? @ValeriyKatkov
  • Mayokun
    Mayokun over 4 years
    Wow! Thanks for this, didn't know there was no need for a wrapper
  • Niroshan
    Niroshan over 4 years
    You can't relay on response.message() for error. its totally useless if you want to serializer your error. "Error handling" is not something where you just return a "String" :P
  • amram99
    amram99 almost 3 years
    This won't compile with Retrofit 2.8 because they added Call.timeout().