🥪 A lightweight Android network response API for handling data and error response with
transformation extensions using Retrofit.
🥪 Sandwich has been downloaded in more than 30k Android projects all over the world!
Add a dependency code to your module's build.gradle file.
dependencies {
implementation "com.github.skydoves:sandwich:1.0.7"
}You can reference the use cases of this library in the below repositories.
- Pokedex - 🗡️ Android Pokedex using Hilt, Motion, Coroutines, Flow, Jetpack (Room, ViewModel, LiveData) based on MVVM architecture.
- DisneyMotions - 🦁 A Disney app using transformation motions based on MVVM (ViewModel, Coroutines, LiveData, Room, Repository, Koin) architecture.
- MarvelHeroes - ❤️ A sample Marvel heroes application based on MVVM (ViewModel, Coroutines, LiveData, Room, Repository, Koin) architecture.
- TheMovies2 - 🎬 A demo project using The Movie DB based on Kotlin MVVM architecture and material design & animations.
ApiResponse is an interface for constructing standard responses from the response of the retrofit call. It provides useful extensions for handling data from the successful and error response.
We can get ApiResponse using the scope extension request from the Call. The below example is the basic of getting an ApiResponse from an instance of the Call.
interface DisneyService {
@GET("/")
fun fetchDisneyPosterList(): Call<List<Poster>>
}
val disneyService = retrofit.create(DisneyService::class.java)
// Request REST call asynchronously and get an ApiResponse model.
disneyService.fetchDisneyPosterList().request { response ->
when (response) {
is ApiResponse.Success -> {
// stub success case
livedata.post(response.data)
}
is ApiResponse.Failure.Error -> {
// stub error case
Timber.d(message())
// handling error based on status code.
when (statusCode) {
StatusCode.InternalServerError -> toastLiveData.postValue("InternalServerError")
StatusCode.BadGateway -> toastLiveData.postValue("BadGateway")
else -> toastLiveData.postValue("$statusCode(${statusCode.code}): ${message()}")
}
}
is ApiResponse.Failure.Exception -> {
// stub exception case
}
}
}A standard API Success response class from OkHttp request calls.
We can get the body data of the response, StatusCode, Headers and etc from the ApiResponse.Success.
val data: List<Poster>? = response.data
val statusCode: StatusCode = response.statusCode
val headers: Headers = response.headersAPI format does not match or applications need to handle errors. e.g., Internal server error.
val errorBody: ResponseBody? = response.errorBody
val statusCode: StatusCode = response.statusCode
val headers: Headers = response.headersAn unexpected exception occurs while creating the request or processing the response in the client. e.g., Network connection error.
We can handle response cases conveniently using extensions.
We can use these scope functions to the ApiResponse, we can reduce the usage of the if/when clause.
disneyService.fetchDisneyPosterList().request { response ->
response.onSuccess {
// stub success case
livedata.post(response.data)
}.onError {
// stub error case
}.onException {
// stub exception case
}
}We can use suspension extensions for using suspend functions inside the scope.
In this case, we can use with CoroutinesResponseCallAdapterFactory.
flow {
val response = disneyService.fetchDisneyPosterList()
response.suspendOnSuccess {
emit(data)
}.suspendOnError {
// stub error case
}.suspendOnFailure {
// stub exception case
}
}.flowOn()Mapper can be used useful if we want to map the ApiResponse.Success or ApiResponse.Failure.Error to our custom model in our ApiResponse extension scopes.
We can map the ApiResponse.Success model to our custom model using the mapper extension.
object SuccessPosterMapper : ApiSuccessModelMapper<List<Poster>, Poster?> {
override fun map(apiErrorResponse: ApiResponse.Success<List<Poster>>): Poster? {
return apiErrorResponse.data?.first()
}
}
// Maps the success response data.
val poster: Poster? = map(SuccessPosterMapper)
or
// Maps the success response data using a lambda.
map(SuccessPosterMapper) { poster ->
livedata.post(poster) // we can use the `this` keyword instead.
}We can map the ApiResponse.Failure.Error model to our custom error model using the mapper extension.
data class ErrorEnvelope(
val code: Int,
val message: String
)
object ErrorEnvelopeMapper : ApiErrorModelMapper<ErrorEnvelope> {
override fun map(apiErrorResponse: ApiResponse.Failure.Error<*>): ErrorEnvelope {
return ErrorEnvelope(apiErrorResponse.statusCode.code, apiErrorResponse.message())
}
}
// Maps the error response.
response.onError {
// Maps the ApiResponse.Failure.Error to a custom error model using the mapper.
map(ErrorEnvelopeMapper) {
val code = this.code
val message = this.message
}
}We can use the suspend keyword in our service.
Build your retrofit using with CoroutinesResponseCallAdapterFactory call adapter factory.
.addCallAdapterFactory(CoroutinesResponseCallAdapterFactory())And use the suspend keyword in our service functions. The response type must be ApiResponse.
interface DisneyCoroutinesService {
@GET("DisneyPosters.json")
suspend fun fetchDisneyPosterList(): ApiResponse<List<Poster>>
}We can use like this; An example of using toLiveData.
class MainCoroutinesViewModel constructor(disneyService: DisneyCoroutinesService) : ViewModel() {
val posterListLiveData: LiveData<List<Poster>>
init {
posterListLiveData = liveData(viewModelScope.coroutineContext + Dispatchers.IO) {
emitSource(disneyService.fetchDisneyPosterList()
.onSuccess {
// stub success case
livedata.post(response.data)
}.onError {
// stub error case
}.onException {
// stub exception case
}.toLiveData()) // returns an observable LiveData
}
}
}We can cancel the executing works using a disposable() extension.
val disposable = call.request { response ->
// skip handling a response //
}.disposable()
// dispose the executing works
disposable.dispose()And we can use CompositeDisposable for canceling multiple resources at once.
class MainViewModel constructor(disneyService: DisneyService) : ViewModel() {
private val disposables = CompositeDisposable()
init {
disneyService.fetchDisneyPosterList()
.joinDisposable(disposables) // joins onto [CompositeDisposable] as a disposable.
.request {response ->
// skip handling a response //
}
}
override fun onCleared() {
super.onCleared()
if (!disposables.disposed) {
disposables.clear()
}
}
}We can merge multiple ApiResponses as one ApiResponse depending on the policy.
The below example is merging three ApiResponse as one if every three ApiResponses are successful.
disneyService.fetchDisneyPosterList(page = 0).merge(
disneyService.fetchDisneyPosterList(page = 1),
disneyService.fetchDisneyPosterList(page = 2),
mergePolicy = ApiResponseMergePolicy.PREFERRED_FAILURE
).onSuccess {
// handle response data..
}.onError {
// handle error..
}ApiResponseMergePolicy is a policy for merging response data depend on the success or not.
- IGNORE_FAILURE: Regardless of the merging order, ignores failure responses in the responses.
- PREFERRED_FAILURE (default): Regardless of the merging order, prefers failure responses in the responses.
ResponseDataSource is an implementation of the DataSource interface.
- Asynchronously send requests.
- A temporarily response data holder from the REST API call for caching data on memory.
- Observable for every response.
- Retry fetching data when the request gets failure.
- Concat another
DataSourceand request sequentially. - Disposable of executing works.
Combine a Call and lambda scope for constructing the DataSource.
val disneyService = retrofit.create(DisneyService::class.java)
val dataSource = ResponseDataSource<List<Poster>>()
dataSource.combine(disneyService.fetchDisneyPosterList()) { response ->
// stubs
}Request API network call asynchronously.
If the request is successful, this data source will hold the success response model.
In the next request after the success, request() returns the cached API response.
If we need to fetch a new response data or refresh, we can use invalidate().
dataSource.request()Retry fetching data (re-request) if your request got failure.
// retry fetching data 3 times with 5000 milli-seconds time interval when the request gets failure.
dataSource.retry(3, 5000L)Observes every response data ApiResponse from the API call request.
dataSource.observeResponse {
Timber.d("observeResponse: $it")
}We can limit the policy for retaining data on the temporarily internal storage.
The default policy is no retaining any fetched data from the network, but we can set the policy using dataRetainPolicy method.
// Retain fetched data on the memory storage temporarily.
// If request again, returns the retained data instead of re-fetching from the network.
dataSource.dataRetainPolicy(DataRetainPolicy.RETAIN)Invalidate a cached (holding) data and re-fetching the API request.
dataSource.invalidate()Concat an another DataSource and request API call sequentially if the API call getting successful.
val dataSource2 = ResponseDataSource<List<PosterDetails>>()
dataSource2.retry(3, 5000L).combine(disneyService.fetchDetails()) {
// stubs handling dataSource2 response
}
dataSource1
.request() // request() must be called before concat.
.concat(dataSource2) // request dataSource2's API call after the success of the dataSource1.
.concat(dataSource3) // request dataSource3's API call after the success of the dataSource2.we can observe fetched data via DataSource as a LiveData.
val posterListLiveData: LiveData<List<Poster>>
init {
posterListLiveData = disneyService.fetchDisneyPosterList().toResponseDataSource()
.retry(3, 5000L)
.dataRetainPolicy(DataRetainPolicy.RETAIN)
.request {
// ... //
}.asLiveData()
}We can make it joins onto CompositeDisposable as a disposable using the joinDisposable function. It must be called before request() method. The below example is using in ViewModel. We can clear the CompositeDisposable in the onCleared() override method.
private val disposable = CompositeDisposable()
init {
disneyService.fetchDisneyPosterList().toResponseDataSource()
// retry fetching data 3 times with 5000L interval when the request gets failure.
.retry(3, 5000L)
// joins onto CompositeDisposable as a disposable and dispose onCleared().
.joinDisposable(disposable)
.request {
// ... //
}
}
override fun onCleared() {
super.onCleared()
if (!disposable.disposed) {
disposable.clear()
}
}Here is the example of the ResponseDataSource in the MainViewModel.
class MainViewModel constructor(
private val disneyService: DisneyService
) : ViewModel() {
// request API call Asynchronously and holding successful response data.
private val dataSource = ResponseDataSource<List<Poster>>()
val posterListLiveData = MutableLiveData<List<Poster>>()
val toastLiveData = MutableLiveData<String>()
private val disposable = CompositeDisposable()
/** fetch poster list data from the network. */
fun fetchDisneyPosters() {
dataSource
// retry fetching data 3 times with 5000 time interval when the request gets failure.
.retry(3, 5000L)
// joins onto CompositeDisposable as a disposable and dispose onCleared().
.joinDisposable(disposable)
// combine network service to the data source.
.combine(disneyService.fetchDisneyPosterList()) { response ->
// handle the case when the API request gets a success response.
response.onSuccess {
Timber.d("$data")
posterListLiveData.postValue(data)
}
// handle the case when the API request gets a error response.
// e.g. internal server error.
.onError {
Timber.d(message())
// handling error based on status code.
when (statusCode) {
StatusCode.InternalServerError -> toastLiveData.postValue("InternalServerError")
StatusCode.BadGateway -> toastLiveData.postValue("BadGateway")
else -> toastLiveData.postValue("$statusCode(${statusCode.code}): ${message()}")
}
// map the ApiResponse.Failure.Error to a customized error model using the mapper.
map(ErrorEnvelopeMapper) {
Timber.d(this.toString())
}
}
// handle the case when the API request gets a exception response.
// e.g. network connection error.
.onException {
Timber.d(message())
toastLiveData.postValue(message())
}
}
// observe every API request responses.
.observeResponse {
Timber.d("observeResponse: $it")
}
// request API network call asynchronously.
// if the request is successful, the data source will hold the success data.
// in the next request after success, returns the cached API response.
// if you want to fetch a new response data, use invalidate().
.request()
}
override fun onCleared() {
super.onCleared()
if (!disposable.disposed) {
disposable.clear()
}
}
}We can get the DataSource directly from the Retrofit service.
Add a call adapter factory DataSourceCallAdapterFactory to your Retrofit builder.
And change the return type of your service Call to DataSource.
Retrofit.Builder()
...
.addCallAdapterFactory(DataSourceCallAdapterFactory())
.build()
interface DisneyService {
@GET("DisneyPosters.json")
fun fetchDisneyPosterList(): DataSource<List<Poster>>
}Here is an example of the DataSource in the MainViewModel.
class MainViewModel constructor(disneyService: DisneyService) : ViewModel() {
// request API call Asynchronously and holding successful response data.
private val dataSource: DataSource<List<Poster>>
init {
Timber.d("initialized MainViewModel.")
dataSource = disneyService.fetchDisneyPosterList()
// retry fetching data 3 times with 5000L interval when the request gets failure.
.retry(3, 5000L)
.observeResponse(object : ResponseObserver<List<Poster>> {
override fun observe(response: ApiResponse<List<Poster>>) {
// handle the case when the API request gets a success response.
response.onSuccess {
Timber.d("$data")
posterListLiveData.postValue(data)
}
}
})
.request() // must call request()We can get the DataSource directly from the Retrofit service using with suspend.
Retrofit.Builder()
...
.addCallAdapterFactory(CoroutinesDataSourceCallAdapterFactory())
.build()
interface DisneyService {
@GET("DisneyPosters.json")
fun fetchDisneyPosterList(): DataSource<List<Poster>>
}Here is an exmaple of the DataSource in the MainViewModel.
class MainCoroutinesViewModel constructor(disneyService: DisneyCoroutinesService) : ViewModel() {
val posterListLiveData: LiveData<List<Poster>>
init {
Timber.d("initialized MainViewModel.")
posterListLiveData = liveData(viewModelScope.coroutineContext + Dispatchers.IO) {
emitSource(disneyService.fetchDisneyPosterList().toResponseDataSource()
// retry fetching data 3 times with 5000L interval when the request gets failure.
.retry(3, 5000L)
// a retain policy for retaining data on the internal storage
.dataRetainPolicy(DataRetainPolicy.RETAIN)
// request API network call asynchronously.
.request {
// handle the case when the API request gets a success response.
onSuccess {
Timber.d("$data")
}.onError { // handle the case when the API request gets a error response.
Timber.d(message())
}.onException { // handle the case when the API request gets a exception response.
Timber.d(message())
}
}.asLiveData())
}
}
}We can change DataSource to ResponseDataSource after getting instance from network call using the below method.
private val dataSource: ResponseDataSource<List<Poster>>
init {
dataSource = disneyService.fetchDisneyPosterList().toResponseDataSource()
//...
}Support it by joining stargazers for this repository. ⭐
And follow me for my next creations! 🤩
Copyright 2020 skydoves (Jaewoong Eum)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.