sandwich

🥪 Sandwich is an adaptable and lightweight sealed API library designed for handling API responses and exceptions in Kotlin for Retrofit, Ktor, and Kotlin Multiplatform.

APACHE-2.0 License

Stars
1.5K
Committers
7

Bot releases are hidden (Show)

sandwich - 1.2.5

Published by skydoves over 2 years ago

What's Changed

New Contributors

Full Changelog: https://github.com/skydoves/sandwich/compare/1.2.4...1.2.5

sandwich - 1.2.4

Published by skydoves over 2 years ago

🎉 1.2.4 has been released! 🎉

What's Changed

Full Changelog: https://github.com/skydoves/sandwich/compare/1.2.3...1.2.4

sandwich - 1.2.3

Published by skydoves almost 3 years ago

🎉 1.2.3 has been released! 🎉

What's Changed

  • Improved documentation for Dokka and README.
  • Updated OkHttp to 4.9.3 by @skydoves in #43
  • Updated Gradle dependencies by @skydoves in #43

Full Changelog: https://github.com/skydoves/Sandwich/compare/1.2.2...1.2.3

sandwich - 1.2.2

Published by skydoves almost 3 years ago

🥪 A new 1.2.2 stable has been released!

What's Changed

Full Changelog: https://github.com/skydoves/Sandwich/compare/1.2.1...1.2.2

sandwich - 1.2.1

Published by skydoves about 3 years ago

🥪 Released a new version 1.2.1! 🥪

What's New?

  • Added new extensions map for the ApiResponse.Success and ApiResponse.Failure.Error using a lambda receiver. (#26)
  • Added new functions suspendCombine and suspendRequest for the DataSourceResponse. (#27)
  • Added a sandwichGlobalContext for operating the sandwichOperator when it extends the [ApiResponseSuspendOperator]. (#28)
  • Updated coroutines to 1.5.0
  • Added explicit modifiers based on the strict Kotlin API mode internally.
sandwich - 1.2.0

Published by skydoves over 3 years ago

🥪 Released a new version 1.2.0! 🥪

You can check the migration codes here Pokedex(#35).

What's New?

Now the data property is a non-nullable type

The data property in the ApiResponse.Success is non-nullable from this release.
Previously, the data property in the ApiResponse would be null-able if the response has been succeeded but there is an empty-body response regardless of the status code. It will throw NoContentException if we try to access the data property for the 204 and 205 cases (succeeded but empty body). Thanks, @jakoss for discussing this (#20).

EmptyBodyInterceptor

If we want to bypass the NoContentException and handle it as an empty body response, we can use the EmptyBodyInterceptor. Then we will not get the NoContentException if we try to access the data property for the 204 and 205 response code.

 OkHttpClient.Builder()
   .addInterceptor(EmptyBodyInterceptor())
   .build()

create factories

Now we should create the factory classes using the create() method.

.addCallAdapterFactory(CoroutinesResponseCallAdapterFactory.create())

getOrElse

We can get the data or default value based on the success or failed response.
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())
sandwich - 1.1.0

Published by skydoves over 3 years ago

🎉 Released a new version 1.1.0! 🎉

What's New?

  • Now we can retrieve the encapsulated success data from the ApiResponse directly using 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()
}
sandwich - 1.0.9

Published by skydoves almost 4 years ago

🎉 Released a new version 1.0.9! 🎉

What's New?

- onSuccess and suspendOnSuccess can receive ApiSuccessModelMapper as a parameter.

If we want to get the transformed data from the start in the lambda, we can pass the mapper as a parameter for the suspendOnSuccess.

.suspendOnSuccess(SuccessPosterMapper) {
    val poster = this
}

- onError and suspendOnError can receive ApiErrorModelMapper as a parameter.

// Maps the ApiResponse.Failure.Error to a custom error model using the mapper.
response.onError(ErrorEnvelopeMapper) {
    val code = this.code
    val message = this.message
}

- Added a new extension toLiveData and toSuspendLiveData with a transformer lambda.

If we want to transform the original data and get a LiveData which contains transformed data using successful data if the response is a ApiResponse.Success.

posterListLiveData = liveData(viewModelScope.coroutineContext + Dispatchers.IO) {
  emitSource(
   disneyService.fetchDisneyPosterList()
    .onError {
      // handle the error case
    }.onException {
      // handle the exception case
    }.toLiveData {
      this.onEach { poster -> poster.date = SystemClock.currentThreadTimeMillis() }
    }) // returns an observable LiveData
   }

- Added a new extension toFlow and toSsuspendFlow with a transformer lambda.

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 {
    // stub error case
  }.onException {
    // stub exception case
  }.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)

- Added a new transformer onProcedure and suspendOnProcedure expressions.

We can pass onSuccess, onError, and onException as arguments.

.suspendOnProcedure(
              // handle the case when the API request gets a successful response.
              onSuccess = {
                Timber.d("$data")

                data?.let { emit(it) }
              },
              // handle the case 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("[Code: $code]: $message")
                }
              },
              // handle the case when the API request gets a exception response.
              // e.g., network connection error.
              onException = {
                Timber.d(message())
                toastLiveData.postValue(message())
              }
            )
sandwich - 1.0.8

Published by skydoves almost 4 years ago

🎉 Released a new version 1.0.8! 🎉

What's New?

  • Added ApiResponseOperator and ApiResponseSuspendOperator.

Operator

We can delegate the onSuccess, onError, onException using the operator extension and ApiResponseOperator. Operators are very useful when 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 = { success ->
          success.data?.let {
            posterListLiveData.postValue(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>() {

  // handle the case when the API request gets a success response.
  override fun onSuccess(apiResponse: ApiResponse.Success<T>) = success(apiResponse)

  // handle the case when the API request gets a error response.
  // 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")
      }
    }
  }

  // handle the case when the API request gets a exception response.
  // e.g., network connection error.
  override fun onException(apiResponse: ApiResponse.Failure.Exception<T>) {
    apiResponse.run {
      Timber.d(message())
      toast(message())
    }
  }
}

Operator with coroutines

If we want to operate and delegate a suspending lambda to the operator, we can use the suspendOperator extension and ApiResponseSuspendOperator class.

ViewModel

We can use suspending function like emit in the success lambda.

flow {
  disneyService.fetchDisneyPosterList().suspendOperator(
      CommonResponseOperator(
        success = { success ->
          success.data?.let { emit(it) }
          Timber.d("$success.data")
        },
        application = getApplication()
      )
    )
}.flowOn(Dispatchers.IO).asLiveData()

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>() {

  // handle the case when the API request gets a success response.
  override suspend fun onSuccess(apiResponse: ApiResponse.Success<T>) = success(apiResponse)

  // skip //

Global operator

We can operate an operator globally on each ApiResponse 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 globally about the ApiResponse.Failure.Error and ApiResponse.Failure.Exception. 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 exception cases, 
    // so we don't need to mind the generic type of the operator.
    SandwichInitializer.sandwichOperator = GlobalResponseOperator<Any>(this)

    // skipp //

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>) { }

  // handle the case when the API request gets a 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")
        }
      }
    }
  }

  // handle the case when the API request gets a exception response.
  // e.g., network connection error.
  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()
sandwich - 1.0.7

Published by skydoves almost 4 years ago

🎉 Released a new version 1.0.7! 🎉

What's New?

  • Changed non-inline functions to inline classes.
  • Removed generating BuildConfig class.
  • Added ApiSuccessModelMapper for mapping data of the ApiResponse.Success to the custom model.
    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.
}
  • Added mapOnSuccess and mapOnError extensions for mapping success/error model to the custom model in their scope.
  • Added merge extension for ApiResponse for merging 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

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.
sandwich - 1.0.6

Published by skydoves almost 4 years ago

🎉 Released a new version 1.0.6! 🎉

What's New?

  • Added a Disposable interface and disposable() extension for canceling tasks when we want.
  • Added DisposableComposite is that a disposable container that can hold onto multiple other disposables.
  • Added joinDisposable extensions to DataSource and Call for creating a disposable and add easily.

Disposable in Call

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()
    }
  }
}

Disposable in DataSource

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()
    }
  }
sandwich - 1.0.5

Published by skydoves about 4 years ago

🎉 Released a new version 1.0.5! 🎉

What's New?

  • Fixed: crashes on 400~500 error happened with using CoroutinesResponseCallAdapterFactory and CoroutinesDataSourceCallAdapterFactory. (#5)
  • Used kotlin 1.4.0 stable internally.
  • Used single abstract method conversions for interfaces.
sandwich - 1.0.4

Published by skydoves over 4 years ago

🎉 Released a new version 1.0.4! 🎉

What's New?

Added suspendOnSuccess, suspendOnFailure, suspendOnException extensions of the ApiResponse.

We can use them for handling suspend functions inside the lambda.
In this case, we should use with CoroutinesResponseCallAdapterFactory.

flow {
      val response = disneyService.fetchDisneyPosterList()
      response.suspendOnSuccess {
        emit(data)
      }.suspendOnError {
        // stub error case
      }.suspendOnFailure {
        // stub exception case
      }
    }
sandwich - 1.0.3

Published by skydoves over 4 years ago

🎉 Released a new version 1.0.3! 🎉

What's New?

Now Sandwich supports using with coroutines.
We can use the suspend function on services.

CoroutinesResponseCallAdapterFactory

We can build the Retrofit using with CoroutinesResponseCallAdapterFactory() call adapter factory.

addCallAdapterFactory(CoroutinesResponseCallAdapterFactory())

And we can use the suspend keyword in our service.

interface DisneyCoroutinesService {

  @GET("DisneyPosters.json")
  suspend fun fetchDisneyPosterList(): ApiResponse<List<Poster>>
}

And now 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 {
          Timber.d("$data")
        }
        .onError {
          Timber.d(message())
        }
        .onException {
          Timber.d(message())
        }.toLiveData())
    }

CoroutinesDataSourceCallAdapterFactory

We can build the Retrofit using with CoroutinesDataSourceCallAdapterFactory() call adapter factory.

addCallAdapterFactory(CoroutinesDataSourceCallAdapterFactory())

And we can get the response data as the DataSource type with suspend keyword.

interface DisneyCoroutinesService {

  @GET("DisneyPosters.json")
  suspend fun fetchDisneyPosterList(): DataSource<List<Poster>>
}

Here is an example of the using asLiveData.

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)
       .dataRetainPolicy(DataRetainPolicy.RETAIN)
       .request {
          // handle the case when the API request gets a success response.
          onSuccess {
            Timber.d("$data")
          }
    // -- skip --
    }.asLiveData())
 }
sandwich - 1.0.2

Published by skydoves over 4 years ago

🎉 Released a new version 1.0.2! 🎉

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 the 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)

asLiveData

we can observe fetched data via DataSource as a LiveData.

  • if the response is successful, it returns a [LiveData] which contains response data.
  • if the response is failure or exception, it returns an empty [LiveData].
val posterListLiveData: LiveData<List<Poster>>

init {
    posterListLiveData = disneyService.fetchDisneyPosterList().toResponseDataSource()
      .retry(3, 5000L)
      .dataRetainPolicy(DataRetainPolicy.RETAIN)
      .request {
        // ...
      }.asLiveData()
  }

toResponseDataSource

If we use DataSourceCallAdapterFactory, we can only get the DataSource interface instead of ResponseDataSource. So in this release, there is a new way to change DataSource to ResponseDataSource.
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()

    //...
  }
sandwich - 1.0.1

Published by skydoves over 4 years ago

Released a new version 1.0.1!

What's new?

  • Added DataSourceCallAdapterFactory.

We can get the DataSource directly from the Retrofit service.
Add 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 the 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()
sandwich - 1.0.0

Published by skydoves over 4 years ago

Released a first version 1.0.0!

Package Rankings
Top 10.51% on Repo1.maven.org
Badges
Extracted from project README
Maven Central
Related Projects