skydoves / Sandwich
Programming Languages
Projects that are alternatives of or similar to Sandwich
Sandwich
🥪 A lightweight and standardized Android network response interface for handling successful data and error responses.
Why Sandwich?
Sandwich was invented for constructing the standardized response interface from the network response. We can handle successful data, error response, and an exceptional case intuitively using useful extensions of the interface. So we don't need to design and implement wrapper classes like Resource
or Result
, and it helps to reduce our work time and makes focus on only business codes. Sandwich supports handling error responses globally, Mapper, Operator, and great compatibilities like toLiveData or toFlow. Also, we can implement great harmony with coroutines and flow in our projects using this library.
Download
🥪 Sandwich has been downloaded in more than 50k Android projects all over the world!
Gradle
Add below codes to your root build.gradle
file (not your module build.gradle file).
allprojects {
repositories {
mavenCentral()
}
}
And add a dependency code to your module's build.gradle
file.
dependencies {
implementation "com.github.skydoves:sandwich:1.0.9"
}
Usecase
You can reference the good 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.
Table of contents
- ApiResponse
- onSuccess, onError, onException
- ApiResponse for coroutines
- suspendOnSuccess, suspendOnError, suspendOnException
- Mapper
- Operator, Operator for coroutines, Global Operator
- Merge
- toLiveData. toFlow
- ResponseDataSource
Usage
ApiResponse
ApiResponse
is an interface for constructing standard responses from the response of the retrofit call. It provides useful extensions for handling successful data and error responses. 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)
// fetches a model list from the network and getting [ApiResponse] asynchronously.
disneyService.fetchDisneyPosterList().request { response ->
when (response) {
// handles the success case when the API request gets a successful response.
is ApiResponse.Success -> {
posterDao.insertPosterList(response.data)
livedata.post(response.data)
}
// handles error cases when the API request gets an error response.
// e.g., internal server error.
is ApiResponse.Failure.Error -> {
// stub error case
Timber.d(message())
// handles error cases depending on the status code.
when (statusCode) {
StatusCode.InternalServerError -> toastLiveData.postValue("InternalServerError")
StatusCode.BadGateway -> toastLiveData.postValue("BadGateway")
else -> toastLiveData.postValue("$statusCode(${statusCode.code}): ${message()}")
}
}
// handles exceptional cases when the API request gets an exception response.
// e.g., network connection error, timeout.
is ApiResponse.Failure.Exception -> {
// stub exception case
}
}
}
ApiResponse.Success
A standard Success response interface from Retrofit network responses.
We can get the successful 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.headers
ApiResponse.Failure.Error
A standard failure response interface from Retrofit network responses.
API communication conventions do 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.headers
ApiResponse.Failure.Exception
An unexpected exception occurs while creating requests or processing an response in the client side. e.g., Network connection error.
ApiResponse Extensions
We can handle response cases conveniently using extensions.
onSuccess, onError, onException
We can use these scope functions to the ApiResponse
, we handle the response cases without using the if-else/when clause.
Each scope will be executed or not depending on the type of the ApiResponse
. (success, error, exception)
disneyService.fetchDisneyPosterList().request { response ->
response.onSuccess {
// this scope will be only executed if the request would successful.
// handle the success case
}.onError {
// this scope will be only executed when the request would get errors.
// handle the error case
}.onException {
// this scope will be only executed when the request would get exceptions.
// handle the exception case
}
}
ApiResponse for coroutines
We can use the suspend
keyword in our Retrofit services and gets ApiResponse<*>
as a response type.
Build your Retrofit using the CoroutinesResponseCallAdapterFactory
call adapter factory.
.addCallAdapterFactory(CoroutinesResponseCallAdapterFactory())
We should make normal service functions as suspension functions using the suspend
keyword. And we can get the ApiResponse<*>
as a response type. So we can get the ApiResponse
from the Retrofit service call, and handle them right away using extensions.
interface DisneyCoroutinesService {
@GET("DisneyPosters.json")
suspend fun fetchDisneyPosterList(): ApiResponse<List<Poster>>
}
We can use like the below.
class MainCoroutinesViewModel constructor(disneyService: DisneyCoroutinesService) : ViewModel() {
val posterListLiveData: MutableLiveData<List<Poster>>
init {
val response = disneyService.fetchDisneyPosterList()
response.onSuccess {
// handles the success case when the API request gets a successful response.
posterDao.insertPosterList(data)
posterListLiveData.post(data)
}.onError {
// handles error cases when the API request gets an error response.
}.onException {
// handles exceptional cases when the API request gets an exception response.
}
}
}
}
suspendOnSuccess, suspendOnError, suspendOnException
We can use suspension extensions for invoking suspension related functions inside scopes. These extensions are not functionally different from the onSuccess
, onError
, and onException
extensions.
Generally, we can use this way on the repository pattern.
flow {
val response = disneyService.fetchDisneyPosterList()
response.suspendOnSuccess {
posterDao.insertPosterList(data)
emit(data)
}.suspendOnError {
// handles error cases
}.suspendOnFailure {
// handles exceptional cases
}
}.flowOn(Dispatchers.IO)
Mapper
Mapper is useful when we want to transform the ApiResponse.Success
or ApiResponse.Failure.Error
to our custom model in our ApiResponse
extension scopes.
ApiSuccessModelMapper
We can map the ApiResponse.Success
model to our custom model using the SuccessPosterMapper<T, R>
and map
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)
We can use the map
extension with a lambda.
// Maps the success response data using a lambda.
map(SuccessPosterMapper) { poster ->
emit(poster) // we can use the `this` keyword instead of the poster.
}
If we want to get the transformed data from the start in the lambda, we can give the mapper as a parameter for the onSuccess
or suspendOnSuccess
.
.suspendOnSuccess(SuccessPosterMapper) {
val poster = this
}
ApiErrorModelMapper
We can map the ApiResponse.Failure.Error
model to our custom error model using the ApiErrorModelMapper<T>
and map
extension.
// Create your custom error model.
data class ErrorEnvelope(
val code: Int,
val message: String
)
// An error response mapper.
// Create an instance of your custom model using the `ApiResponse.Failure.Error` in the `map`.
object ErrorEnvelopeMapper : ApiErrorModelMapper<ErrorEnvelope> {
override fun map(apiErrorResponse: ApiResponse.Failure.Error<*>): ErrorEnvelope {
return ErrorEnvelope(apiErrorResponse.statusCode.code, apiErrorResponse.message())
}
}
// Maps an error response.
response.onError {
// Maps an ApiResponse.Failure.Error to a custom error model using the mapper.
map(ErrorEnvelopeMapper) {
val code = this.code
val message = this.message
}
}
If we want to get the transformed data from the start in the lambda, we can give the mapper as a parameter for the onError
or suspendOnError
.
.suspendOnError(ErrorEnvelopeMapper) {
val message = this.message
}
Operator
We can delegate the onSuccess
, onError
, onException
using the operator
extension and ApiResponseOperator
. Operator is very useful if we want to handle ApiResponse
s standardly or reduce the role of the ViewModel
and Repository
. Here is an example of standardized error and exception handing.
ViewModel
We can delegate and operate the CommonResponseOperator
using the operate
extension.
disneyService.fetchDisneyPosterList().operator(
CommonResponseOperator(
success = {
data?.let { emit(it) }
Timber.d("$success.data")
},
application = getApplication()
)
)
CommonResponseOperator
The CommonResponseOperator
extends ApiResponseOperator
with the onSuccess
, onError
, onException
override methods. They will be executed depending on the type of the ApiResponse
.
/** A common response operator for handling [ApiResponse]s regardless of its type. */
class CommonResponseOperator<T> constructor(
private val success: suspend (ApiResponse.Success<T>) -> Unit,
private val application: Application
) : ApiResponseOperator<T>() {
// handles error cases when the API request gets an error response.
override fun onSuccess(apiResponse: ApiResponse.Success<T>) = success(apiResponse)
// handles error cases depending on the status code.
// e.g., internal server error.
override fun onError(apiResponse: ApiResponse.Failure.Error<T>) {
apiResponse.run {
Timber.d(message())
// map the ApiResponse.Failure.Error to a customized error model using the mapper.
map(ErrorEnvelopeMapper) {
Timber.d("[Code: $code]: $message")
}
}
}
// handles exceptional cases when the API request gets an exception response.
// e.g., network connection error, timeout.
override fun onException(apiResponse: ApiResponse.Failure.Exception<T>) {
apiResponse.run {
Timber.d(message())
toast(message())
}
}
}
Operator for coroutines
If we want to operate and delegate a suspension lambda to the operator, we can use the suspendOperator
extension and ApiResponseSuspendOperator
class.
ViewModel
We can use suspension functions like emit
in the success
lambda.
flow {
disneyService.fetchDisneyPosterList().suspendOperator(
CommonResponseOperator(
success = {
data?.let { emit(it) }
Timber.d("$success.data")
},
application = getApplication()
)
)
}.flowOn(Dispatchers.IO)
CommonResponseOperator
The CommonResponseOperator
extends ApiResponseSuspendOperator
with suspend override methods.
class CommonResponseOperator<T> constructor(
private val success: suspend (ApiResponse.Success<T>) -> Unit,
private val application: Application
) : ApiResponseSuspendOperator<T>() {
// handles the success case when the API request gets a successful response.
override suspend fun onSuccess(apiResponse: ApiResponse.Success<T>) = success(apiResponse)
// ... //
Global operator
We can operate an operator globally all ApiResponse
s in our application using the SandwichInitializer
. So we don't need to create every instance of the Operators or use dependency injection for handling common operations. Here is an example of handling a global operator about the ApiResponse.Failure.Error
and ApiResponse.Failure.Exception
. In this example, We will handle ApiResponse.Success
manually.
Application class
We can initialize the global operator on the SandwichInitializer.sandwichOperator
. It is recommended to initialize it in the Application class.
class SandwichDemoApp : Application() {
override fun onCreate() {
super.onCreate()
// We will handle only the error and exceptional cases,
// so we don't need to mind the generic type of the operator.
SandwichInitializer.sandwichOperator = GlobalResponseOperator<Any>(this)
// ... //
GlobalResponseOperator
The GlobalResponseOperator
can extend any operator (ApiResponseSuspendOperator
or ApiResponseOperator
)
class GlobalResponseOperator<T> constructor(
private val application: Application
) : ApiResponseSuspendOperator<T>() {
// The body is empty, because we will handle the success case manually.
override suspend fun onSuccess(apiResponse: ApiResponse.Success<T>) { }
// handles error cases when the API request gets an error response.
// e.g., internal server error.
override suspend fun onError(apiResponse: ApiResponse.Failure.Error<T>) {
withContext(Dispatchers.Main) {
apiResponse.run {
Timber.d(message())
// handling error based on status code.
when (statusCode) {
StatusCode.InternalServerError -> toast("InternalServerError")
StatusCode.BadGateway -> toast("BadGateway")
else -> toast("$statusCode(${statusCode.code}): ${message()}")
}
// map the ApiResponse.Failure.Error to a customized error model using the mapper.
map(ErrorEnvelopeMapper) {
Timber.d("[Code: $code]: $message")
}
}
}
}
// handles exceptional cases when the API request gets an exception response.
// e.g., network connection error, timeout.
override suspend fun onException(apiResponse: ApiResponse.Failure.Exception<T>) {
withContext(Dispatchers.Main) {
apiResponse.run {
Timber.d(message())
toast(message())
}
}
}
private fun toast(message: String) {
Toast.makeText(application, message, Toast.LENGTH_SHORT).show()
}
}
ViewModel
We don't need to use the operator
expression. The global operator will be operated automatically, so we should handle only the ApiResponse.Success
.
flow {
disneyService.fetchDisneyPosterList().
suspendOnSuccess {
data?.let { emit(it) }
}
}.flowOn(Dispatchers.IO).asLiveData()
Merge
We can merge multiple ApiResponse
s as one ApiResponse
depending on the policy.
The below example is merging three ApiResponse
as one if every three ApiResponse
s are successful.
disneyService.fetchDisneyPosterList(page = 0).merge(
disneyService.fetchDisneyPosterList(page = 1),
disneyService.fetchDisneyPosterList(page = 2),
mergePolicy = ApiResponseMergePolicy.PREFERRED_FAILURE
).onSuccess {
// handles the success case when the API request gets a successful response.
}.onError {
// handles error cases when the API request gets an error response.
}
ApiResponseMergePolicy
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.
toLiveData
We can get a LiveData
that contains successful data if the response is an ApiResponse.Success
. If our goal is only getting a LiveData that holds successful data, we can emit the onSuccess
extension.
posterListLiveData = liveData(viewModelScope.coroutineContext + Dispatchers.IO) {
emitSource(
disneyService.fetchDisneyPosterList()
.onError {
// handles error cases when the API request gets an error response.
}.onException {
// handles exceptional cases when the API request gets an exception response.
}.toLiveData()) // returns an observable LiveData
}
If we want to transform the original data and get a LiveData
that contains transformed data using successful data if the response is an ApiResponse.Success
.
posterListLiveData = liveData(viewModelScope.coroutineContext + Dispatchers.IO) {
emitSource(
disneyService.fetchDisneyPosterList()
.onError {
// handles error cases when the API request gets an error response.
}.onException {
// handles exceptional cases when the API request gets an exception response.
}.toLiveData {
this.onEach { poster -> poster.date = SystemClock.currentThreadTimeMillis() }
}) // returns an observable LiveData
}
toFlow
We can get a Flow
that emits successful data if the response is an ApiResponse.Success
and the data is not null.
disneyService.fetchDisneyPosterList()
.onError {
// handles error cases when the API request gets an error response.
}.onException {
// handles exceptional cases when the API request gets an exception response.
}.toFlow() // returns a coroutines flow
.flowOn(Dispatchers.IO)
If we want to transform the original data and get a flow
that contains transformed data using successful data if the response is an ApiResponse.Success
and the data is not null.
val response = pokedexClient.fetchPokemonList(page = page)
response.toFlow { pokemons ->
pokemons.forEach { pokemon -> pokemon.page = page }
pokemonDao.insertPokemonList(pokemons)
pokemonDao.getAllPokemonList(page)
}.flowOn(Dispatchers.IO)
ResponseDataSource
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
DataSource
and request sequentially. - Disposable of executing works.
Combine
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
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
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)
ObserveResponse
Observes every response data ApiResponse
from the API call request.
dataSource.observeResponse {
Timber.d("observeResponse: $it")
}
RetainPolicy
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
Invalidate a cached (holding) data and re-fetching the API request.
dataSource.invalidate()
Concat
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.
asLiveData
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()
}
Disposable
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 ->
// handles the success case when the API request gets a successful response.
response.onSuccess {
Timber.d("$data")
posterListLiveData.postValue(data)
}
// handles error cases when the API request gets an 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())
}
}
// handles exceptional cases when the API request gets an exception response.
// e.g. network connection error, timeout.
.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()
}
}
}
DataSourceCallAdapterFactory
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()
CoroutinesDataSourceCallAdapterFactory
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())
}
}
}
toResponseDataSource
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()
//...
}
Find this library useful? ❤️
Support it by joining stargazers for this repository. ⭐️
And follow me for my next creations! 🤩
License
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.