retrofit-adapters

Introduction: 🚆 Retrofit call adapters for modeling network responses using Kotlin Result, Jetpack Paging3, and Arrow Either.
More: Author   ReportBugs   
Tags:
Dimhof72@gmail.com-

Retrofit Adapters


License API Build Status Profile Dokka

🚆 Retrofit adapters for modeling network responses with Kotlin Result, Jetpack Paging3, and Arrow Either.

If you're interested in a more specified and lightweight Monad sealed API library for modeling Retrofit responses and handling exceptions, check out Sandwich.

Kotlin's Result

This library allows you to model your Retrofit responses with Kotlin's Result class.

Maven Central

Add the dependency below to your module's build.gradle.kts file:

dependencies {
    implementation("com.github.skydoves:retrofit-adapters-result:1.2.0")
}

ResultCallAdapterFactory

You can return Kotlin's Result class to the Retrofit's service methods by setting ResultCallAdapterFactory like the below:

val retrofit: Retrofit = Retrofit.Builder()
    .baseUrl("BASE_URL")
    .addConverterFactory(..)
    .addCallAdapterFactory(ResultCallAdapterFactory.create())
    .build()

Then you can return the Result class with the suspend keyword.

interface PokemonService {

  @GET("pokemon")
  suspend fun fetchPokemonList(
    @Query("limit") limit: Int = 20,
    @Query("offset") offset: Int = 0
  ): Result<PokemonResponse>
}

Finally, you will get the network response, which is wrapped by the Result class like the below:

viewModelScope.launch {
  val result = pokemonService.fetchPokemonList()
  if (result.isSuccess) {
    val data = result.getOrNull()
    // handle data
  } else {
    // handle error case
  }
}

Empty Content Response

You can confine the response type as Unit when you need to handle empty body (content) API requests like the below:

@POST("/users/info")
suspend fun updateUserInfo(@Body userRequest: UserRequest): Result<Unit>

Null Body as Failure

A successful response with no body is reported as Result.success(null) by default, even when the service method declares a non-null type. That null then surfaces far away from the call site. Set nullBodyAsFailure to receive a NullBodyException failure instead:

val retrofit: Retrofit = Retrofit.Builder()
  .baseUrl("BASE_URL")
  .addConverterFactory(..)
  .addCallAdapterFactory(ResultCallAdapterFactory.create(nullBodyAsFailure = true))
  .build()

Unit Tests by Injecting TestScope

You can also inject your custom CoroutineScope into the ResultCallAdapterFactory and execute network requests on the scope.

val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
val testScope = TestScope(testDispatcher)
val retrofit: Retrofit = Retrofit.Builder()
  .baseUrl("BASE_URL")
  .addConverterFactory(..)
  .addCallAdapterFactory(ResultCallAdapterFactory.create(testScope))
  .build()

Note: For more information about the Testing coroutines, check out the Testing Kotlin coroutines on Android.

Jetpack's Paging

This library allows you to return the paging source, which is parts of the Jetpack's Paging library.

Maven Central

Add the dependency below to your module's build.gradle file:

dependencies {
    implementation "com.github.skydoves:retrofit-adapters-paging:<version>"
}

PagingCallAdapterFactory

You can return Jetpack's PagingSource class to the Retrofit's service methods by setting PagingCallAdapterFactory like the below:

val retrofit: Retrofit = Retrofit.Builder()
    .baseUrl("BASE_URL")
    .addConverterFactory(..)
    .addCallAdapterFactory(PagingCallAdapterFactory.create())
    .build()

Then you can return the NetworkPagingSource class with the @PagingKeyConfig and @PagingKey annotations:

interface PokemonService {

  @GET("pokemon")
  @PagingKeyConfig(
    keySize = 20,
    mapper = PokemonPagingMapper::class
  )
  suspend fun fetchPokemonListAsPagingSource(
    @Query("limit") limit: Int = 20,
    @PagingKey @Query("offset") offset: Int = 0,
  ): NetworkPagingSource<PokemonResponse, Pokemon>
}

PagingKeyConfig and PagingKey

To return the NetworkPagingSource class, you must attach the @PagingKeyConfig and @PagingKey annotations to your Retrofit's service methods.

  • @PagingKeyConfig: Contains paging configurations for the network request and delivery them to the call adapter internally. You should set the keySize and mapper parameters.
  • @PagingKey: Marks the parameter in the service interface method as the paging key. This parameter will be paged by incrementing the page values continuously.

PagingMapper

You should create a paging mapper class, which extends the PagingMapper<T, R> interface like the below for transforming the original network response to the list of paging items. This class should be used in the @PagingKeyConfig annotation.

class PokemonPagingMapper : PagingMapper<PokemonResponse, Pokemon> {

  override fun map(value: PokemonResponse): List<Pokemon> {
    return value.results
  }
}

Compile Time Validation

The call adapter resolves the paging configuration reflectively, so a mistake normally shows up as a crash on the first request, and R8 shrinks the mapper classes away because a KClass annotation value is not a keep root. Apply the KSP processor to catch those at compile time and to instantiate mappers without reflection:

plugins {
    id("com.google.devtools.ksp")
}

dependencies {
    implementation("com.github.skydoves:retrofit-adapters-paging:<version>")
    ksp("com.github.skydoves:retrofit-adapters-paging-compiler:<version>")
}

It reports a missing @PagingKeyConfig or @PagingKey, a non Int paging key, a non suspend method, a keySize of zero or less, and a mapper that does not implement PagingMapper, cannot be instantiated, or whose type arguments do not match the returned NetworkPagingSource.

You will get the network response, which is wrapped by the NetworkPagingSource class like the below:

viewModelScope.launch {
  val pagingSource = pokemonService.fetchPokemonListAsPagingSource()
  val pagerFlow = Pager(PagingConfig(pageSize = 20)) { pagingSource }.flow
  stateFlow.emitAll(pagerFlow)
}

Finally, you should call the submitData method by your PagingDataAdapter to bind the paging data. If you want to learn more about the Jetpack's Paging, check out the Paging library.

Arrow's Either

This library allows you to model your Retrofit responses with arrow-kt's Either class.

Maven Central

Add the dependency below to your module's build.gradle file:

dependencies {
    implementation "com.github.skydoves:retrofit-adapters-arrow:<version>"
}

EitherCallAdapterFactory

You can return Arrow's Either class to the Retrofit's service methods by setting EitherCallAdapterFactory like the below:

val retrofit: Retrofit = Retrofit.Builder()
    .baseUrl("BASE_URL")
    .addConverterFactory(..)
    .addCallAdapterFactory(EitherCallAdapterFactory.create())
    .build()

Then you can return the Either class with the suspend keyword.

interface PokemonService {

  @GET("pokemon")
  suspend fun fetchPokemonListAsEither(
    @Query("limit") limit: Int = 20,
    @Query("offset") offset: Int = 0
  ): Either<Throwable, PokemonResponse>
}

Finally, you will get the network response, which is wrapped by the Either class like the below:

viewModelScope.launch {
  val either = pokemonService.fetchPokemonListAsEither()
  if (either.isRight()) {
    val data = either.orNull()
    // handle data
  } else {
    // handle error case
  }
}

Empty Content Response

You can confine the response type as Unit when you need to handle empty body (content) API requests like the below:

@POST("/users/info")
suspend fun updateUserInfo(@Body userRequest: UserRequest): Either<Throwable, Unit>

Null Body as Failure

EitherCallAdapterFactory takes the same nullBodyAsFailure flag, which reports a null body as a NullBodyException on the left instead of a right hand null:

.addCallAdapterFactory(EitherCallAdapterFactory.create(nullBodyAsFailure = true))

Unit Tests by Injecting TestScope

You can also inject your custom CoroutineScope into the EitherCallAdapterFactory and execute network requests on the scope.

val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
val testScope = TestScope(testDispatcher)
val retrofit: Retrofit = Retrofit.Builder()
  .baseUrl("BASE_URL")
  .addConverterFactory(..)
  .addCallAdapterFactory(EitherCallAdapterFactory.create(testScope))
  .build()

Note: For more information about the Testing coroutines, check out the Testing Kotlin coroutines on Android.

Error Body Deserialization

This library allows you to deserialize the error body of a Retrofit response into your own error class. Pick the artifact that matches the json library you already use, so nothing else is pulled into your build:

Artifact Json library
retrofit-adapters-serialization Kotlin Serialization
retrofit-adapters-moshi Moshi
retrofit-adapters-gson Gson

Maven Central

Add the one you need to your module's build.gradle file:

dependencies {
    implementation "com.github.skydoves:retrofit-adapters-serialization:<version>"
    // or
    implementation "com.github.skydoves:retrofit-adapters-moshi:<version>"
    // or
    implementation "com.github.skydoves:retrofit-adapters-gson:<version>"
}

Deserialize Error Body

Define your custom error class following your RESTful API format:

@Serializable
public data class ErrorMessage(
  val code: Int,
  val message: String
)

Then deserialize a failure with the deserializeHttpError extension:

val result = pokemonService.fetchPokemonList()
result.onSuccessSuspend {
  Timber.d("fetched as Result: $it")
}.onFailureSuspend { throwable ->
  val errorBody = throwable.deserializeHttpError<ErrorMessage>()
}

It returns null instead of throwing when the failure is not an HttpException, the error body is empty, or the payload does not match your error class. Unknown keys are ignored, and reading the error body does not consume it, so the same failure can be deserialized more than once.

Note: The Moshi artifact resolves generated adapters, so annotate your error model with @JsonClass(generateAdapter = true) and apply moshi-kotlin-codegen.

onFailureSuspendAsError and onLeftSuspendAsError

retrofit-adapters-result and retrofit-adapters-arrow carry no json dependency, so they take the deserializer as a parameter. That lets the same call site work with any of the three artifacts above:

result.onFailureSuspendAsError({ it.deserializeHttpError<ErrorMessage>() }) { errorModel ->
  // handle the error model
}

either.onLeftSuspendAsError({ it.deserializeHttpError<ErrorMessage>() }) { errorModel ->
  // handle the error model
}

Find this repository useful? :heart:

Support it by joining stargazers for this repository. :star:
Also, follow me on GitHub for my next creations! 🤩

License

Designed and developed by 2022 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.
Apps
About Me
GitHub: Trinea
Facebook: Dev Tools
AI Daily Digest