Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/Custom Rx Adapter for Network Response Handling #13

Merged
merged 4 commits into from
May 11, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package app.web.drjackycv.data.network

typealias GenericNetworkResponse<S> = NetworkResponse<S, ErrorBody>

interface BaseApiService
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import javax.inject.Singleton
@Singleton
class BaseRetrofit @Inject constructor(
private val okHttpClient: OkHttpClient,
private val gson: Gson
private val gson: Gson,
) {

val retrofit = Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.addCallAdapterFactory(RxJavaCustomCallAdapterFactory.create()) //Has to be on top of the other adapters
.addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(RxJava3CallAdapterFactory.createWithScheduler(Schedulers.io()))
.client(okHttpClient)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package app.web.drjackycv.data.network

data class ErrorBody(
val statusCode: Int,
val error: String,
val message: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package app.web.drjackycv.data.network

import java.io.IOException

/**
* Represents the result of making a network request.
*
* @param T success body type for 2xx response.
* @param U error body type for non-2xx response.
*/
sealed class NetworkResponse<out T : Any, out U : Any> {

/**
* A request that resulted in a response with a 2xx status code that has a body.
*/
data class Success<T : Any>(val body: T) : NetworkResponse<T, Nothing>()

/**
* A request that resulted in a response with a non-2xx status code.
*/
data class ApiError<U : Any>(val body: U?, val code: Int?) :
NetworkResponse<Nothing, U>()

/**
* A request that didn't result in a response.
*/
data class NetworkError(val error: IOException) :
NetworkResponse<Nothing, Nothing>()

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package app.web.drjackycv.data.network

import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.functions.Function
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.CallAdapter
import retrofit2.Converter
import retrofit2.HttpException
import java.io.IOException
import java.lang.reflect.Type

internal class RxJavaCustomCallAdapter<T : Any, U : Any>(
private val successBodyType: Type,
private val delegateAdapter: CallAdapter<T, Observable<T>>,
private val errorConverter: Converter<ResponseBody, U>,
private val isFlowable: Boolean,
private val isSingle: Boolean,
private val isMaybe: Boolean,
) : CallAdapter<T, Any> {

override fun adapt(call: Call<T>): Any =
delegateAdapter.adapt(call)
.flatMap {
Observable.just<NetworkResponse<T, U>>(
NetworkResponse.Success(
it
)
)
}
.onErrorResumeNext(
Function<Throwable, Observable<NetworkResponse<T, U>>> { throwable ->
when (throwable) {
is HttpException -> {
val error = throwable.response()?.errorBody()
val errorBody = when {
error == null -> null
error.contentLength() == 0L -> null
else -> {
try {
errorConverter.convert(error)
} catch (e: Exception) {
return@Function Observable.just(
NetworkResponse.NetworkError(
IOException(
"Couldn't deserialize error body: ${error.string()}",
e
)
)
)
}
}
}
val apiError = NetworkResponse.ApiError(
errorBody,
throwable.response()?.code()
)
Observable.just(apiError)
}
is IOException -> {
Observable.just(
NetworkResponse.NetworkError(
throwable
)
)
}
else -> {
throw throwable
}
}
}).run {
when {
isFlowable -> this.toFlowable(BackpressureStrategy.LATEST)
isSingle -> this.singleOrError()
isMaybe -> this.singleElement()
else -> this
}
}

override fun responseType(): Type = successBodyType

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package app.web.drjackycv.data.network

import com.google.gson.reflect.TypeToken
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import retrofit2.CallAdapter
import retrofit2.Retrofit
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type

/**
* A [CallAdapter.Factory] which allows [NetworkResponse] objects to be returned from RxJava
* streams.
*
* Adding this class to [Retrofit] allows you to return [Observable], [Flowable], [Single], or
* [Maybe] types parameterized with [NetworkResponse] from service methods.
*
* Note: This adapter must be registered before an adapter that is capable of adapting RxJava
* streams.
*/
class RxJavaCustomCallAdapterFactory private constructor() : CallAdapter.Factory() {

companion object {
@JvmStatic
fun create() = RxJavaCustomCallAdapterFactory()
}

override fun get(
returnType: Type,
annotations: Array<Annotation>,
retrofit: Retrofit,
): CallAdapter<*, *>? {
val rawType = getRawType(returnType)

val isFlowable = rawType === Flowable::class.java
val isSingle = rawType === Single::class.java
val isMaybe = rawType === Maybe::class.java
if (rawType !== Observable::class.java && !isFlowable && !isSingle && !isMaybe) {
return null
}

if (returnType !is ParameterizedType) {
throw IllegalStateException(
"${rawType.simpleName} return type must be parameterized as " +
"${rawType.simpleName}<Foo> or ${rawType.simpleName}<? extends Foo>"
)
}

val observableEmissionType = getParameterUpperBound(0, returnType)
if (getRawType(observableEmissionType) != NetworkResponse::class.java) {
return null
}

if (observableEmissionType !is ParameterizedType) {
throw IllegalStateException(
"NetworkResponse must be parameterized as NetworkResponse<SuccessBody, ErrorBody>"
)
}

val successBodyType = getParameterUpperBound(0, observableEmissionType)
val delegateType = TypeToken.getParameterized(
Observable::class.java,
successBodyType
)
val delegateAdapter = retrofit.nextCallAdapter(
this,
delegateType.type,
annotations
)

val errorBodyType = getParameterUpperBound(1, observableEmissionType)
val errorBodyConverter = retrofit.nextResponseBodyConverter<Any>(
null,
errorBodyType,
annotations
)

@Suppress("UNCHECKED_CAST") // Type of delegateAdapter is not known at compile time.
return RxJavaCustomCallAdapter(
successBodyType,
delegateAdapter as CallAdapter<Any, Observable<Any>>,
errorBodyConverter,
isFlowable,
isSingle,
isMaybe
)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package app.web.drjackycv.data.products.datasource

import androidx.paging.PagingState
import androidx.paging.rxjava3.RxPagingSource
import app.web.drjackycv.data.network.NetworkResponse
import app.web.drjackycv.data.products.entity.BeerMapper
import app.web.drjackycv.data.products.remote.ProductsApi
import app.web.drjackycv.domain.base.Failure
Expand Down Expand Up @@ -30,28 +31,36 @@ class ProductsPagingSource @Inject constructor(

return productsApi.getBeersList(position, params.loadSize)
.subscribeOn(Schedulers.io())
.map { listBeerResponse ->
listBeerResponse.map {
BeerMapper().mapLeftToRight(it)
.map { response ->
when (response) {
is NetworkResponse.Success -> {
val list = response.body.map {
BeerMapper().mapLeftToRight(it)
}

toLoadResult(list, position)
}
is NetworkResponse.ApiError -> {
LoadResult.Error(Failure.Api(response.body?.message))
}
is NetworkResponse.NetworkError -> {
LoadResult.Error(Failure.NoInternet(response.error.message))
}
else -> {
LoadResult.Error(Failure.Unknown())
}
}
}
.map { toLoadResult(it, position) }
.onErrorReturn { throwable ->
when (throwable) {
is UnknownHostException, is SocketTimeoutException -> {
LoadResult.Error(
Failure.NoInternet(throwable.message)
)
LoadResult.Error(Failure.NoInternet(throwable.message))
}
is TimeoutException -> {
LoadResult.Error(
Failure.Timeout(throwable.message)
)
LoadResult.Error(Failure.Timeout(throwable.message))
}
else -> {
LoadResult.Error(
Failure.Unknown(throwable.message)
)
LoadResult.Error(Failure.Unknown(throwable.message))
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
package app.web.drjackycv.data.products.remote

import app.web.drjackycv.data.network.BaseApiService
import app.web.drjackycv.data.network.GenericNetworkResponse
import app.web.drjackycv.data.products.entity.BeerResponse
import io.reactivex.rxjava3.core.Single
import retrofit2.http.GET
import retrofit2.http.Query

interface ProductsApi {
interface ProductsApi : BaseApiService {

@GET("beers")
fun getBeersList(
/*@Query("ids") ids: String,*/
@Query("page") page: Int = 1,
@Query("per_page") perPage: Int = 40
): Single<List<BeerResponse>>
@Query("per_page") perPage: Int = 40,
): Single<GenericNetworkResponse<List<BeerResponse>>>

@GET("beers")
suspend fun getBeersListByCoroutine(
/*@Query("ids") ids: String,*/
@Query("page") page: Int = 1,
@Query("per_page") perPage: Int = 40
@Query("per_page") perPage: Int = 40,
): List<BeerResponse>

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package app.web.drjackycv.domain.base

sealed class Failure(var msg: String?, var retryAction: () -> Unit) : Throwable() {

class Timeout(msg: String?) : Failure(msg, {})
class Api(msg: String? = null) : Failure(msg, {})

class NoInternet(msg: String?) : Failure(msg, {})
class Timeout(msg: String? = null) : Failure(msg, {})

class Unknown(msg: String?) : Failure(msg, {})
class NoInternet(msg: String? = null) : Failure(msg, {})

class Unknown(msg: String? = null) : Failure(msg, {})

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ class GetBeersListByCoroutineUseCase @Inject constructor(

}

inline class GetBeersListByCoroutineParams(val ids: String)
@JvmInline
value class GetBeersListByCoroutineParams(val ids: String)
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ class GetBeersListUseCase @Inject constructor(

}

inline class GetBeersListParams(val ids: String)
@JvmInline
value class GetBeersListParams(val ids: String)
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ open class BaseViewModel @Inject constructor() : ViewModel(),
is Failure.NoInternet -> {
Failure.NoInternet(resources.getString(R.string.error_no_internet))
}
is Failure.Api -> {
Failure.Api(throwable.msg)
}
is Failure.Timeout -> {
Failure.Timeout(resources.getString(R.string.error_timeout))
}
Expand Down
Loading