Skip to content

netcosports/Cache

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

15 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Cache

GitHub license Download

Cache is a lightweight Kotlin MultiPlatform (KMP) or Android-only library that makes Ktor ( KMM) and OkHttp (Android) networking and caching easy. Library provides a simple API for requesting the cache, the network or both in the specified order. Also it provides a simple API for using your current Databases in caching requests.

In KMP projects we use the Ktor Client with:

The Ktor Client is wrapped in a KtorHttpClientWrapper to create a CoroutineLoader instead of responses.

In Android-only projects, we use the OkHttp Client and OkHttp Caching feature.
You can create or generate a RetrofitServiceWrapper for the Retrofit Service to create CoroutineLoader or SingleLoader instead of responses.

A CoroutineLoader / SingleLoader can return a cached response, a network response or a Flow<ResponseWrapper<T>> / Observable<ResponseWrapper<T>> with both responses in the specified order.

Table of Contents

  1. KMP projects with Ktor. Android uses OkHttp Engine and iOS uses Darwin Engine
  2. KMP or Android projects with custom cache, such as DataBase
  3. Android-only projects with Retrofit and OkHttp

KMP projects with Ktor. Android uses OkHttp Engine and iOS uses Darwin Engine

You can retrieve a cached response, a network response or a Flow<ResponseWrapper<DATA>>:

val cachedData: DATA = coroutineLoader.cache() //suspend fun
val networkData: DATA = coroutineLoader.api() //suspend fun
val flow: Flow<ResponseWrapper<DATA>> = coroutineLoader.toFlow(MergeArguments.CACHE_AND_API)
    .map { responseWrapper ->
        if (responseWrapper.isCache) {
            "CACHE ${responseWrapper.data}"
        } else {
            "API ${responseWrapper.data}"
        }
    }

Gradle setup for KMP projects with Ktor

shared build.gradle

kotlin {
    sourceSets {
        val commonMain by getting {
            dependencies {
                // core module
                implementation(com.origins-digital.kmp.cache:cache-core:$VERSION)
                // coroutine extensions
                implementation(com.origins-digital.kmp.cache:cache-ktx:$VERSION)
                // KtorWrapper
                implementation(com.origins-digital.kmp.cache:cache-ktor-data:$VERSION)
            }
        }
    }
}

Using Ktor directly

If you have a Ktor request:

val client = io.ktor.client.HttpClient()
val repositories: List<Repository> =
    client.request("https://api.github.com/repositories").body() //suspend fun

First, wrap your Ktor Client:

// Both OkHttpClients must have the same Cache file.
private val cache = Cache(
    directory = File(app.cacheDir, "cache"),
    maxSize = 10L * 1024 * 1024, // 10 MiB
)

private val baseOkHttpClient = OkHttpClient.Builder().build()
private val cacheOkHttpClient = baseOkHttpClient.newBuilder()
    .setupCache(
        cache = cache,
        onlyCache = true,
        maxStale = OkHttpCacheInterceptor.DEFAULT_MAX_STALE
    )
    .build()
private val apiOkHttpClient = baseOkHttpClient.newBuilder()
    .setupCache(
        cache = cache,
        onlyCache = false,
        maxStale = OkHttpCacheInterceptor.DEFAULT_MAX_STALE
    )
    .addInterceptor(ChuckerInterceptor(app)) // Configure Chucker for API requests only
    .build()

val ktorHttpClientWrapper = KtorHttpClientUtils.createKtorHttpClientWrapper(
    okHttpClientDelegate = { isCache ->
        /* Configure OkHttpClient depending on whether it's a client for cached responses or network responses */
        if (isCache) {
            cacheOkHttpClient
        } else {
            apiOkHttpClient
        }
    },
    configDelegate = { isCache ->
        /* Install Ktor Features depending on whether it's a client for cached responses or network responses */
        install(ContentNegotiation) {
            json(json) // Configure Json for all requests
        }
        if (!isCache) {
            install(Logging) // Enable KtorLogging only for API requests
        }
    }
)

Then create a CoroutineLoader for the request:

val coroutineLoader: CoroutineLoader<List<Repository>> = ktorHttpClientWrapper.createLoader {
    this/* Ktor HttpClient */.request("https://api.github.com/repositories").body()
}

Using Ktor with OpenApiGenerator

If you have a generated ExampleApi:

internal interface ExampleApi {

    suspend fun getRepositories(): List<Repository>

    companion object {
        operator fun invoke(
            basePath: String = "http://localhost",
            httpClientEngine: HttpClientEngine,
            json: Json,
            clientBlock: HttpClient.() -> Unit = {},
            configBlock: HttpClientConfig<*>.() -> Unit
        ): ExampleApi = ExampleApiImpl(basePath, httpClientEngine, json, clientBlock, configBlock)
    }
}

First, wrap your ExampleApi:

// Both OkHttpClients must have the same Cache file.
private val cache = Cache(
    directory = File(app.cacheDir, "cache"),
    maxSize = 10L * 1024 * 1024, // 10 MiB
)

private val baseOkHttpClient = OkHttpClient.Builder().build()
private val cacheOkHttpClient = baseOkHttpClient.newBuilder()
    .setupCache(
        cache = cache,
        onlyCache = true,
        maxStale = OkHttpCacheInterceptor.DEFAULT_MAX_STALE
    )
    .build()
private val apiOkHttpClient = baseOkHttpClient.newBuilder()
    .setupCache(
        cache = cache,
        onlyCache = false,
        maxStale = OkHttpCacheInterceptor.DEFAULT_MAX_STALE
    )
    .addInterceptor(ChuckerInterceptor(app)) // Configure Chucker for API requests only
    .build()

val ktorApiWrapper = KtorHttpClientUtils.createKtorApiWrapper(
    okHttpClientDelegate = { isCache ->
        /* Configure OkHttpClient depending on whether it's a client for cached responses or network responses */
        if (isCache) {
            cacheOkHttpClient
        } else {
            apiOkHttpClient
        }
    },
    engineDelegate = { httpClientEngine, isCache ->
        ExampleApi(
            basePath = baseUrl,
            httpClientEngine = httpClientEngine,
            json = json,
            clientBlock = {},
            configBlock = {},
        )
    }
)

Then create a CoroutineLoader for the request:

val coroutineLoader: CoroutineLoader<List<Repository>> = ktorApiWrapper.createLoader {
    this.getRepositories()
}

KMP or Android projects with custom cache, such as DataBase

Gradle setup for custom cache

KMM shared build.gradle

kotlin {
    sourceSets {
        val commonMain by getting {
            dependencies {
                // core module
                implementation(com.origins-digital.kmp.cache:cache-core:$VERSION)
                // coroutine extensions
                implementation(com.origins-digital.kmp.cache:cache-ktx:$VERSION)
            }
        }
    }
}

or

Android build.gradle

dependencies {
    implementation(com.origins-digital.kmp.cache:cache-core:$VERSION) // core, kmm module
    
    // You can add BOTH if you use suspend requests but have some legacy Rx requests
    implementation(com.origins-digital.kmp.cache:cache-ktx:$VERSION) // coroutines, kmm module
    implementation(com.origins-digital.kmp.cache:cache-rx:$VERSION) // Rx, jvm module
}

Using custom cache

For example, if you have a SampleKtorApiDataSource and SampleDatabaseDataSource:

class SampleKtorApiDataSource {

    suspend fun getRepositoriesCoroutine(): List<Any> {
        return ktor.get("https://api.github.com/repositories").body()
    }

    fun getRepositoriesSingle(): Single<List<Any>> {
        return retrofit.get("https://api.github.com/repositories")
    }
}

class SampleDatabaseDataSource {

    suspend fun getRepositoriesCoroutine(): List<Any> {
        return database.getRepositoriesCoroutine()
    }

    fun getRepositoriesSingle(): Single<List<Any>> {
        return database.getRepositoriesSingle()
    }
}

You can create CoroutineLoader/SingleLoader:

class SampleRepositoryImpl(
    private val apiDataSource: SampleKtorApiDataSource,
    private val databaseDataSource: SampleDatabaseDataSource,
) : SampleRepository {

    override fun getRepositoriesCoroutine(): CoroutineLoader<List<Any>> {
        return suspendLoader { loaderArguments ->
            when (loaderArguments) {
                is LoaderArguments.API -> apiDataSource.getRepositoriesCoroutine()
                is LoaderArguments.CACHE -> databaseDataSource.getRepositoriesCoroutine()
            }
        }
    }

    override fun getRepositoriesSingle(): SingleLoader<List<Any>> {
        return singleLoader { loaderArguments ->
            when (loaderArguments) {
                is LoaderArguments.API -> apiDataSource.getRepositoriesSingle()
                is LoaderArguments.CACHE -> databaseDataSource.getRepositoriesSingle()
            }
        }
    }
}

Now you can retrieve a cached response, a network response or Flow<ResponseWrapper<DATA>>/ Observable<ResponseWrapper<DATA>>:

val sampleRepository: SampleRepository = TODO()

// Coroutines
val coroutineLoader = sampleRepository.getRepositoriesCoroutine()
val cachedData: DATA = coroutineLoader.cache() //suspend fun
val networkData: DATA = coroutineLoader.api() //suspend fun
val flow: Flow<ResponseWrapper<DATA>> = coroutineLoader.toFlow(MergeArguments.CACHE_AND_API)
    .map { responseWrapper ->
        if (responseWrapper.isCache) {
            "CACHE ${responseWrapper.data}"
        } else {
            "API ${responseWrapper.data}"
        }
    }

// RX
val singleLoader = sampleRepository.getRepositoriesSingle()
val cachedData: Single<DATA> = singleLoader.cache()
val networkData: Single<DATA> = singleLoader.api()
val observable: Observable<ResponseWrapper<DATA>> =
    singleLoader.toObservable(MergeArguments.CACHE_AND_API)
        .map { responseWrapper ->
            if (responseWrapper.isCache) {
                "CACHE ${responseWrapper.data}"
            } else {
                "API ${responseWrapper.data}"
            }
        }

Android-only projects with Retrofit and OkHttp

You can retrieve a cached response, a network response or Flow<ResponseWrapper<DATA>>/ Observable<ResponseWrapper<DATA>>:

// Coroutines
val cachedData: DATA = coroutineLoader.cache() //suspend fun
val networkData: DATA = coroutineLoader.api() //suspend fun
val flow: Flow<ResponseWrapper<DATA>> = coroutineLoader.toFlow(MergeArguments.CACHE_AND_API)
    .map { responseWrapper ->
        if (responseWrapper.isCache) {
            "CACHE ${responseWrapper.data}"
        } else {
            "API ${responseWrapper.data}"
        }
    }

// RX
val cachedData: Single<DATA> = singleLoader.cache()
val networkData: Single<DATA> = singleLoader.api()
val observable: Observable<ResponseWrapper<DATA>> =
    singleLoader.toObservable(MergeArguments.CACHE_AND_API)
        .map { responseWrapper ->
            if (responseWrapper.isCache) {
                "CACHE ${responseWrapper.data}"
            } else {
                "API ${responseWrapper.data}"
            }
        }

Gradle setup for Android-only projects

plugins {
    // add this plugin to GENERATE a RetrofitServiceWrapper for your Retrofit Service
    kotlin("kapt")
}

dependencies {
    // required core module
    implementation(com.origins-digital.kmp.cache:cache-core:$VERSION) // core, kmm module
    // required to configure cache in the OkHttpClient
    implementation(com.origins-digital.kmp.cache:cache-okhttp-data:$VERSION) // okhttp utls, jvm module
    
    // You can add BOTH if you use suspend requests but have some legacy Rx requests
    implementation(com.origins-digital.kmp.cache:cache-ktx:$VERSION) // coroutines, kmm module
    implementation(com.origins-digital.kmp.cache:cache-rx:$VERSION) // Rx, jvm module
    
     // add these modules to GENERATE a RetrofitServiceWrapper for your Retrofit Service
    implementation(com.origins-digital.kmp.cache:cache-retrofit-data:$VERSION) // annotation for processor, jvm module
    implementation(com.origins-digital.kmp.cache:cache-retrofit-processor:$VERSION) // kapt processor, jvm module
}

Manually wrapping your RetrofitService

For example, if you have a Retrofit Service:

interface SampleRetrofitService {

    @GET
    fun getResponseSingle(@Url url: String): Single<List<Any>>

    @GET
    fun getResponseObservable(@Url url: String): Observable<List<Any>>

    @GET
    suspend fun getResponseSuspend(@Url url: String): List<Any>
}

You can copy this small class and modify it if you want:

class RetrofitServiceWrapper<SERVICE>(
    private val retrofitDelegate: (isCache: Boolean) -> SERVICE,
) {

    val cacheService: SERVICE by lazy { retrofitDelegate(/*isCache = */true) }
    val apiService: SERVICE by lazy { retrofitDelegate(/*isCache = */false) }

    fun <RESULT : Any> coroutine(delegate: suspend SERVICE.() -> RESULT): CoroutineLoader<RESULT> {
        return suspendLoader { loaderArguments ->
            when (loaderArguments) {
                is LoaderArguments.API -> delegate(apiService)
                is LoaderArguments.CACHE -> delegate(cacheService)
            }
        }
    }

    fun <RESULT : Any> single(delegate: SERVICE.() -> Single<RESULT>): SingleLoader<RESULT> {
        return singleLoader { loaderArguments ->
            when (loaderArguments) {
                is LoaderArguments.API -> delegate(apiService)
                is LoaderArguments.CACHE -> delegate(cacheService)
            }
        }
    }
}

Then wrap your real RetrofitService:

// Both OkHttpClients must have the same Cache file.
private val cache = Cache(
    directory = File(app.cacheDir, "cache"),
    maxSize = 10L * 1024 * 1024, // 10 MiB
)

private val baseOkHttpClient = OkHttpClient.Builder().build()
private val cacheOkHttpClient = baseOkHttpClient.newBuilder()
    .setupCache(
        cache = cache,
        onlyCache = true,
        maxStale = OkHttpCacheInterceptor.DEFAULT_MAX_STALE
    )
    .build()
private val apiOkHttpClient = baseOkHttpClient.newBuilder()
    .setupCache(
        cache = cache,
        onlyCache = false,
        maxStale = OkHttpCacheInterceptor.DEFAULT_MAX_STALE
    )
    .addInterceptor(ChuckerInterceptor(app)) // Configure Chucker for API requests only
    .build()

val retrofitServiceWrapper = RetrofitServiceWrapper<SampleRetrofitService>(
    retrofitDelegate = { isCache ->
        val retrofitBuilder = Retrofit.Builder()
            .baseUrl("https://baseurl.com/")
            .client(
                if (isCache) {
                    cacheOkHttpClient
                } else {
                    apiOkHttpClient
                }
            )
            .addConverterFactory(GsonConverterFactory.create(gson))
            .addCallAdapterFactory(RxJava2CallAdapterFactory.createWithScheduler(Schedulers.io()))
            .build()
            .create(SampleRetrofitService::class.java)
    }
)

Get your CoroutineLoader, SingleLoader or unmodified requests:

val coroutineLoader: CoroutineLoader<List<Any>> =
    retrofitServiceWrapper.coroutine { this/*SampleRetrofitService*/.getResponseSuspend(url = url) }
val singleLoader: SingleLoader<List<Any>> =
    retrofitServiceWrapper.single { this/*SampleRetrofitService*/.getResponseSingle(url = url) }
val observable: Observable<List<Any>> =
    retrofitServiceWrapper.apiService.getResponseObservable(url = url)

Generating a RetrofitServiceWrapper

For example, if you have a Retrofit Service:

interface SampleRetrofitService {

    @GET
    fun getResponseSingle(@Url url: String): Single<List<Any>>

    @GET
    fun getResponseObservable(@Url url: String): Observable<List<Any>>

    @GET
    suspend fun getResponseSuspend(@Url url: String): List<Any>
}

First, annotate it with @CacheService:

@CacheService
interface SampleRetrofitService {
    /* code */
}

Run assemble or kapt{BuildType}Kotlin task for this module to generate a RetrofitServiceWrapper. Ensure the kapt plugin and processor are added to your module. Once the task is completed, the RetrofitServiceWrapper will be generated (in our example it will be SampleRetrofitServiceWrapper)

The RetrofitServiceWrapper:

  • replaces Single<T> with SingleLoader<T>.
  • replaces suspend fun test(): T with fun test(): CoroutineLoader<T>.
  • Doesn't modify functions returning other types.
public class SampleRetrofitServiceWrapper(
    public val cacheService: SampleRetrofitService,
    public val apiService: SampleRetrofitService,
) {

    public fun getResponseSuspend(url: String): CoroutineLoader<List<Any>> {
        return suspendLoader { loaderArguments ->
            when (loaderArguments) {
                is LoaderArguments.API -> apiService.getResponseSuspend(url)
                is LoaderArguments.CACHE -> cacheService.getResponseSuspend(url)
            }
        }
    }

    public fun getResponseSingle(url: String): SingleLoader<List<Any>> {
        return singleLoader { loaderArguments ->
            when (loaderArguments) {
                is LoaderArguments.API -> apiService.getResponseSingle(url)
                is LoaderArguments.CACHE -> cacheService.getResponseSingle(url)
            }
        }
    }

    public fun getResponseObservable(url: String): Observable<List<Any>> {
        return apiService.getResponseObservable(url)
    }
}

Initialize the SampleRetrofitServiceWrapper:

// Both OkHttpClients must have the same Cache file.
private val cache = Cache(
    directory = File(app.cacheDir, "cache"),
    maxSize = 10L * 1024 * 1024, // 10 MiB
)

private val baseOkHttpClient = OkHttpClient.Builder().build()
private val cacheOkHttpClient = baseOkHttpClient.newBuilder()
    .setupCache(
        cache = cache,
        onlyCache = true,
        maxStale = OkHttpCacheInterceptor.DEFAULT_MAX_STALE
    )
    .build()
private val apiOkHttpClient = baseOkHttpClient.newBuilder()
    .setupCache(
        cache = cache,
        onlyCache = false,
        maxStale = OkHttpCacheInterceptor.DEFAULT_MAX_STALE
    )
    .addInterceptor(ChuckerInterceptor(app)) // Configure Chucker for API requests only
    .build()

val createRetrofitClient: (okHttpClient: OkHttpClient) -> SampleRetrofitService =  { okHttpClient ->
    Retrofit.Builder()
        .baseUrl("https://api.curator.io/v1/")
        .client(okHttpClient)
        .addConverterFactory(GsonConverterFactory.create(gson))
        .addCallAdapterFactory(RxJava2CallAdapterFactory.createWithScheduler(Schedulers.io()))
        .build()
        .create(SampleRetrofitService::class.java)
}

return SampleRetrofitServiceWrapper(
    createRetrofitClient(cacheOkHttpClient),
    createRetrofitClient(apiOkHttpClient)
)

Get your CoroutineLoader, SingleLoader or unmodified requests:

val coroutineLoader: CoroutineLoader<List<Any>> =
    retrofitServiceWrapper.getResponseSuspend(url = url)
val singleLoader: SingleLoader<List<Any>> = retrofitServiceWrapper.getResponseSingle(url = url)
val observable: Observable<List<Any>> = retrofitServiceWrapper.getResponseObservable(url = url)