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

Maven Central
Jitpack

? Sandwich has been downloaded in more than 70k 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.2.1"
}

SNAPSHOT

Sandwich

Snapshots of the current development version of Sandwich are available, which track the latest versions.

repositories {
   maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' }
}

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

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.create())

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)

Retrieve success data

If we want to retrieve the encapsulated success data from the ApiResponse directly, we can use the below functionalities.

getOrNull

Returns the encapsulated data if this instance represents ApiResponse.Success or returns null if this is failed.

val data: List<Poster>? = disneyService.fetchDisneyPosterList().getOrNull()

getOrElse

Returns the encapsulated data if this instance represents ApiResponse.Success or returns a default value if this is failed.

val data: List<Poster>? = disneyService.fetchDisneyPosterList().getOrElse(emptyList())

getOrThrow

Returns the encapsulated data if this instance represents ApiResponse.Success or throws the encapsulated Throwable exception if this is failed.

try {
  val data: List<Poster>? = disneyService.fetchDisneyPosterList().getOrThrow()
} catch (e: Exception) {
  e.printStackTrace()
}

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 ApiResponses 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 = {
          emit(data)
          Timber.d("success data: $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 = {
          emit(data)
          Timber.d("success data: $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 ApiResponses 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 {
      emit(data)
    }
}.flowOn(Dispatchers.IO).asLiveData()

Merge

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 { 
  // 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.create())
    .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.create())
    .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()

    //...
  }

GitHub

https://github.com/skydoves/Sandwich