Last active
January 20, 2024 00:47
-
-
Save Vivekban/819addc7fd8e86a198a9d206385073d9 to your computer and use it in GitHub Desktop.
Retrofit CallAdapter to handle the API calls errors and success states at single source.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.vivek.githubapisample.api | |
import com.vivek.githubapisample.BuildConfig | |
import okhttp3.OkHttpClient | |
import okhttp3.logging.HttpLoggingInterceptor | |
import retrofit2.Retrofit | |
import retrofit2.converter.moshi.MoshiConverterFactory | |
/** | |
* This class will be responsible to provide all kind Retrofit Client for making networking calls. | |
*/ | |
object ApiClient { | |
/** | |
* This method will provide the Retrofit client with the given base url By default it will | |
* logs all network calls in debug mode | |
* | |
* @param baseUrl The base url of the api | |
* @return The Retrofit client | |
*/ | |
fun getClient(baseUrl: String): Retrofit { | |
val logger = | |
HttpLoggingInterceptor().apply { | |
level = when (BuildConfig.DEBUG) { | |
true -> HttpLoggingInterceptor.Level.BODY | |
false -> HttpLoggingInterceptor.Level.NONE | |
} | |
} | |
val client = OkHttpClient.Builder() | |
.addInterceptor(logger) | |
.build() | |
return Retrofit.Builder() | |
.baseUrl(baseUrl) | |
.client(client) | |
.addConverterFactory(MoshiConverterFactory.create()) | |
.addCallAdapterFactory(ResultCallAdapterFactory()) | |
.build() | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.vivek.githubapisample.common.data | |
import androidx.compose.runtime.Immutable | |
import kotlinx.coroutines.flow.Flow | |
import kotlinx.coroutines.flow.catch | |
import kotlinx.coroutines.flow.map | |
import kotlinx.coroutines.flow.onStart | |
/** | |
* A generic class that holds a value with its loading status. | |
*/ | |
@Immutable | |
sealed class AppResult<out T> { | |
/** | |
* Represents a successful outcome. | |
* | |
* @param data The encapsulated value. | |
*/ | |
data class Success<T>(val data: T) : AppResult<T>() | |
/** | |
* Represents a failed outcome. | |
* | |
* @param exception The encapsulated [Throwable] exception. | |
*/ | |
data class Error(val exception: Throwable? = null) : AppResult<Nothing>() | |
/** | |
* Represents a loading state. | |
*/ | |
data object Loading : AppResult<Nothing>() | |
/** | |
* Returns `true` if this instance represents a successful outcome. | |
*/ | |
fun isSuccess(): Boolean = this is Success | |
/** | |
* Returns `true` if this instance represents a failed outcome. | |
*/ | |
fun isError(): Boolean = this is Error | |
/** | |
* Returns `true` if this instance represents a failed outcome. | |
*/ | |
fun isLoading(): Boolean = this is Loading | |
/** | |
* Returns the encapsulated value if this instance represents [success][AppResult.isSuccess] or `null` | |
* if it is [failure][AppResult.isError]. | |
*/ | |
fun getOrNull(): T? = | |
when (this) { | |
is Success -> this.data | |
else -> null | |
} | |
/** | |
* Returns the encapsulated [Throwable] exception if this instance represents [failure][isError] or `null` | |
* if it is [success][isSuccess]. | |
*/ | |
fun exceptionOrNull(): Throwable? = | |
when (this) { | |
is Error -> this.exception | |
else -> null | |
} | |
} | |
inline val Throwable.asAppResult get() = AppResult.Error(this) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.vivek.githubapisample.api | |
import com.vivek.githubapisample.common.data.AppResult | |
import com.vivek.githubapisample.common.data.ErrorResponse | |
import com.vivek.githubapisample.common.data.asAppResult | |
import com.vivek.githubapisample.common.data.asResult | |
import okhttp3.Request | |
import okhttp3.ResponseBody | |
import okio.Timeout | |
import retrofit2.Call | |
import retrofit2.CallAdapter | |
import retrofit2.Callback | |
import retrofit2.Converter | |
import retrofit2.Response | |
import retrofit2.Retrofit | |
import java.io.IOException | |
import java.lang.reflect.ParameterizedType | |
import java.lang.reflect.Type | |
import javax.net.ssl.HttpsURLConnection | |
/** | |
* Custom Retrofit CallAdapter to handle the API calls errors and success states, by this we can | |
* get response in form of [AppResult] | |
* | |
* For more details about it check here | |
* [Medium](https://proandroiddev.com/create-retrofit-calladapter-for-coroutines-to-handle-response-as-states-c102440de37a) | |
*/ | |
class AppResultCallAdapter<R>( | |
private val responseType: Type, | |
private val errorBodyConverter: Converter<ResponseBody, ErrorResponse> | |
) : CallAdapter<R, Call<AppResult<R>>> { | |
override fun responseType(): Type { | |
return responseType | |
} | |
override fun adapt(call: Call<R>): Call<AppResult<R>> { | |
return AppResultCall(call, errorBodyConverter) | |
} | |
private class AppResultCall<R>( | |
private val delegate: Call<R>, | |
private val errorConverter: Converter<ResponseBody, ErrorResponse> | |
) : Call<AppResult<R>> { | |
override fun enqueue(callback: Callback<AppResult<R>>) { | |
delegate.enqueue(object : Callback<R> { | |
override fun onResponse(call: Call<R>, response: Response<R>) { | |
val body = response.body() | |
val code = response.code() | |
val error = response.errorBody() | |
val result = if (response.isSuccessful) { | |
if (body != null) { | |
AppResult.Success(body) | |
} else { | |
AppException.EmptyBody().asAppResult | |
} | |
} else if (code == HttpsURLConnection.HTTP_NOT_FOUND) { | |
AppException.NotFound().asAppResult | |
} else { | |
val errorBody = when { | |
error == null -> null | |
error.contentLength() == 0L -> null | |
else -> try { | |
errorConverter.convert(error)?.message | |
} catch (ex: Exception) { | |
ex.message | |
} | |
} | |
errorBody?.let { | |
AppException.ApiError(errorBody, code).asAppResult | |
} ?: AppException.ApiError(null, code = code).asAppResult | |
} | |
callback.onResponse(this@AppResultCall, Response.success(result)) | |
} | |
override fun onFailure(call: Call<R>, t: Throwable) { | |
val networkResponse = when (t) { | |
is IOException -> AppException.NetworkError(t) | |
else -> AppException.Error(t.message, t) | |
} | |
callback.onResponse( | |
this@AppResultCall, | |
Response.success(AppResult.Error(networkResponse)) | |
) | |
} | |
}) | |
} | |
// Other methods delegate to the original Call | |
override fun execute(): Response<AppResult<R>> { | |
throw UnsupportedOperationException("execute not supported") | |
} | |
override fun isExecuted(): Boolean = delegate.isExecuted | |
override fun cancel() = delegate.cancel() | |
override fun isCanceled() = delegate.isCanceled | |
override fun request(): Request = delegate.request() | |
override fun timeout(): Timeout = delegate.timeout() | |
override fun clone(): Call<AppResult<R>> { | |
return AppResultCall(delegate.clone(), errorConverter) | |
} | |
} | |
} | |
class AppResultCallAdapterFactory : CallAdapter.Factory() { | |
override fun get( | |
returnType: Type, | |
annotations: Array<Annotation>, | |
retrofit: Retrofit | |
): CallAdapter<*, *>? { | |
// suspend functions wrap the response type in `Call` | |
if (Call::class.java != getRawType(returnType)) { | |
return null | |
} | |
// check first that the return type is `ParameterizedType` | |
check(returnType is ParameterizedType) { | |
"return type must be parameterized as Call<AppResult<<Foo>> or Call<AppResult<out Foo>>" | |
} | |
// get the response type inside the `Call` type | |
val responseType = getParameterUpperBound(0, returnType) | |
// if the response type is not ApiResponse then we can't handle this type, so we return null | |
if (getRawType(responseType) != AppResult::class.java) { | |
return null | |
} | |
// the response type is ApiResponse and should be parameterized | |
check(responseType is ParameterizedType) { "Response must be parameterized as AppResult<Foo> or AppResult<out Foo>" } | |
val successBodyType = getParameterUpperBound(0, responseType) | |
val errorBodyConverter = | |
retrofit.nextResponseBodyConverter<ErrorResponse>( | |
null, | |
ErrorResponse::class.java, | |
annotations | |
) | |
return AppResultCallAdapter<Any>(successBodyType, errorBodyConverter) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.vivek.githubapisample.common.data | |
import com.squareup.moshi.JsonClass | |
/** Error response comes in case of Api failure (non 2xx code) */ | |
@JsonClass(generateAdapter = true) | |
data class ErrorResponse( | |
val message: String, | |
) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.vivek.githubapisample.repo.data | |
import retrofit2.Retrofit | |
import retrofit2.http.GET | |
import retrofit2.http.Path | |
import retrofit2.http.Query | |
/** | |
* Implementation of [RepoRemoteSource] uses retrofit to perform Network operation to | |
* fetch repo related information | |
*/ | |
interface RepoService : RepoRemoteSource { | |
/** | |
* Get a list of repositories for a given username as [AppResult] | |
* | |
* @param username the username of the user | |
* @param page the page number of the results | |
* @param perPage the number of results per page | |
* @return a response containing a list of repositories | |
*/ | |
@GET("users/{username}/repos") | |
override suspend fun getRepositoryByUsername( | |
@Path("username") username: String, | |
@Query("page") page: Int, | |
@Query("per_page") perPage: Int, | |
): AppResult<List<RepoDto>> | |
companion object Factory { | |
/** | |
* Create a new instance of [RepoService] using the given [Retrofit] instance | |
* | |
* @param retrofit the retrofit instance to use | |
* @return a new instance of [RepoService] | |
*/ | |
fun create(retrofit: Retrofit): RepoService { | |
return retrofit.create(RepoService::class.java) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment