Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Added

- Added public `OkioCacheStorage(...)` (`cache-okio`) and experimental `KotlinxIoCacheStorage(...)` (`cache-kotlinx-io`, `@ExperimentalKotlinxIoCache`) factories returning Ktor's `CacheStorage`, so the persistent storage can be used with `install(HttpCache) { publicStorage(...) }` without the `PersistentCache` plugin. `CacheFileSystem` and `FileCacheStorage` stay `@InternalPersistentCacheApi`.
- `cache-okio` and `cache-kotlinx-io` now expose `ktor-client-core` as an `api` dependency, since the factories return Ktor's `CacheStorage`.
- Android host tests (`testAndroidHostTest`) now run the common tests and conformance suites for `:cache-core`, `:cache-okio` and `:cache-kotlinx-io` as part of `check`. They run on the JVM against Android stubs, not on a device or emulator.
- Added an unpublished Kotlin Multiplatform `sample` module that verifies persistent cache reads with both Okio and kotlinx-io.
- Added native Android Compose and iOS SwiftUI sample screens with visible pass/fail results.
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,42 @@ These rules are verified by `AuthPluginInteropTest` (in `cache-okio`'s test suit
the real `install(PersistentCache)` DSL). No extra configuration is required beyond matching server
cache headers and the `PersistentCacheConfig` flags above.

### Using the storage directly with Ktor `HttpCache`

If you already configure Ktor's own `HttpCache`, you can plug this library's storage into it
without the `PersistentCache` plugin. Each backend module exposes one factory returning Ktor's
`CacheStorage`:

```kotlin
import io.github.santimattius.persistent.cache.OkioCacheStorage
import io.ktor.client.plugins.cache.HttpCache

val client = HttpClient(CIO) {
install(HttpCache) {
publicStorage(OkioCacheStorage(maxSize = 20L * 1024 * 1024)) // or privateStorage(...)
}
}
```

| Factory | Module | Stability |
|---|---|---|
| `OkioCacheStorage(...)` | `cache-okio` | Stable |
| `KotlinxIoCacheStorage(...)` | `cache-kotlinx-io` | Experimental, needs `@OptIn(ExperimentalKotlinxIoCache::class)` (`WARNING` level) |

Both take the same named parameters, with the same defaults as `PersistentCacheConfig`:
`directory = "http_cache"`, `maxSize = 10 MiB`, `ttl = 1 hour`,
`directoryProvider = getCacheDirectoryProvider()`, the backend file system
(`okio.FileSystem.SYSTEM` / `SystemFileSystem`) and `clock`. They need no
`@InternalPersistentCacheApi` opt-in, and they behave exactly like `install(PersistentCache)`
(atomic writes, TTL, LRU eviction; see [docs/STORAGE.md](docs/STORAGE.md)).

Prefer the factory when you want full control over `HttpCache` (several storages, custom
`isShared`, your own plugin ordering) or want to avoid the plugin DSL. Prefer
`install(PersistentCache)` for the shortest setup: it also wires `shared`/`public` and routes the
storage to `publicStorage` or `privateStorage` for you. With the factory, Ktor's routing rules
(`Cache-Control: private` goes to `privateStorage`) are yours to configure. Use one storage
instance per cache directory.

---

## Architecture: `cache-core` and storage backends
Expand Down
9 changes: 9 additions & 0 deletions cache-core/api/cache-core.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ final class io.github.santimattius.persistent.cache/PersistentCacheConfig { // i
final fun <set-ttl>(kotlin/Long) // io.github.santimattius.persistent.cache/PersistentCacheConfig.ttl.<set-ttl>|<set-ttl>(kotlin.Long){}[0]
}

final object io.github.santimattius.persistent.cache/CacheStorageDefaults { // io.github.santimattius.persistent.cache/CacheStorageDefaults|null[0]
final const val DIRECTORY // io.github.santimattius.persistent.cache/CacheStorageDefaults.DIRECTORY|{}DIRECTORY[0]
final fun <get-DIRECTORY>(): kotlin/String // io.github.santimattius.persistent.cache/CacheStorageDefaults.DIRECTORY.<get-DIRECTORY>|<get-DIRECTORY>(){}[0]
final const val MAX_SIZE // io.github.santimattius.persistent.cache/CacheStorageDefaults.MAX_SIZE|{}MAX_SIZE[0]
final fun <get-MAX_SIZE>(): kotlin/Long // io.github.santimattius.persistent.cache/CacheStorageDefaults.MAX_SIZE.<get-MAX_SIZE>|<get-MAX_SIZE>(){}[0]
final const val TTL // io.github.santimattius.persistent.cache/CacheStorageDefaults.TTL|{}TTL[0]
final fun <get-TTL>(): kotlin/Long // io.github.santimattius.persistent.cache/CacheStorageDefaults.TTL.<get-TTL>|<get-TTL>(){}[0]
}

final val io.github.santimattius.persistent.cache/PersistentCache // io.github.santimattius.persistent.cache/PersistentCache|{}PersistentCache[0]
final fun <get-PersistentCache>(): io.ktor.client.plugins.api/ClientPlugin<io.github.santimattius.persistent.cache/PersistentCacheConfig> // io.github.santimattius.persistent.cache/PersistentCache.<get-PersistentCache>|<get-PersistentCache>(){}[0]

Expand Down
7 changes: 7 additions & 0 deletions cache-core/api/jvm/cache-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ public abstract interface class io/github/santimattius/persistent/cache/CacheFil
public abstract fun writeAtomically (Ljava/lang/Object;[B)V
}

public final class io/github/santimattius/persistent/cache/CacheStorageDefaults {
public static final field DIRECTORY Ljava/lang/String;
public static final field INSTANCE Lio/github/santimattius/persistent/cache/CacheStorageDefaults;
public static final field MAX_SIZE J
public static final field TTL J
}

public final class io/github/santimattius/persistent/cache/CacheStorageException : java/lang/Exception {
public fun <init> (Ljava/lang/String;Ljava/lang/Throwable;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package io.github.santimattius.persistent.cache

/**
* Default values shared by [PersistentCacheConfig] and the public `CacheStorage` factories of the
* backend modules (`OkioCacheStorage`, `KotlinxIoCacheStorage`), so every entry point agrees.
*
* Public only because Kotlin has no cross-module `internal`; application code should rely on the
* documented defaults of the factories and of [PersistentCacheConfig] instead.
*/
@InternalPersistentCacheApi
public object CacheStorageDefaults {

/** Default subdirectory name under the cache root. */
public const val DIRECTORY: String = "http_cache"

/** Default maximum on-disk size in bytes (10 MiB). */
public const val MAX_SIZE: Long = 10L * 1024 * 1024

/** Default entry time-to-live in milliseconds (1 hour). */
public const val TTL: Long = 60L * 60 * 1000
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,24 @@ import io.ktor.util.date.getTimeMillis
* pre-split `CacheConfig` / `CacheStorageConfig` / `OkioFileCacheConfig` trio with no field
* renaming across layers.
*/
@OptIn(InternalPersistentCacheApi::class)
public class PersistentCacheConfig {

/** Subdirectory name under the cache root. Default: `"http_cache"`. */
public var directory: String = "http_cache"
public var directory: String = CacheStorageDefaults.DIRECTORY

/**
* Maximum on-disk size in bytes. A value `<= 0` means unlimited. Default: 10 MB. When exceeded,
* the least recently used entries (reads count as use) are evicted.
*/
public var maxSize: Long = 10L * 1024 * 1024
public var maxSize: Long = CacheStorageDefaults.MAX_SIZE

/**
* Entry time-to-live in milliseconds, measured from when the entry was stored. A value `<= 0`
* means entries never expire. Default: 1 hour. This bounds retention only: HTTP freshness
* (`max-age`, `Expires`) is enforced by Ktor's `HttpCache` from the stored `expires`.
*/
public var ttl: Long = 60 * 60 * 1000
public var ttl: Long = CacheStorageDefaults.TTL

/** Whether the client where this is installed is shared among multiple users. Default: `true`. */
public var shared: Boolean = true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
@file:OptIn(InternalPersistentCacheApi::class)

package io.github.santimattius.persistent.cache

import kotlin.test.Test
import kotlin.test.assertEquals

class CacheStorageDefaultsTest {

@Test
fun `given the shared defaults then they match the documented values`() {
assertEquals("http_cache", CacheStorageDefaults.DIRECTORY)
assertEquals(10L * 1024 * 1024, CacheStorageDefaults.MAX_SIZE)
assertEquals(60L * 60 * 1000, CacheStorageDefaults.TTL)
}

@Test
fun `given a fresh PersistentCacheConfig then it uses the shared defaults`() {
val config = PersistentCacheConfig()

assertEquals(CacheStorageDefaults.DIRECTORY, config.directory)
assertEquals(CacheStorageDefaults.MAX_SIZE, config.maxSize)
assertEquals(CacheStorageDefaults.TTL, config.ttl)
}
}
5 changes: 5 additions & 0 deletions cache-kotlinx-io/api/jvm/cache-kotlinx-io.api
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,8 @@ public final class io/github/santimattius/persistent/cache/KotlinxIoCacheFileSys
public fun writeAtomically (Lkotlinx/io/files/Path;[B)V
}

public final class io/github/santimattius/persistent/cache/KotlinxIoCacheStorageKt {
public static final fun KotlinxIoCacheStorage (Ljava/lang/String;JJLio/github/santimattius/persistent/cache/CacheDirectoryProvider;Lkotlinx/io/files/FileSystem;Lkotlin/jvm/functions/Function0;)Lio/ktor/client/plugins/cache/storage/CacheStorage;
public static synthetic fun KotlinxIoCacheStorage$default (Ljava/lang/String;JJLio/github/santimattius/persistent/cache/CacheDirectoryProvider;Lkotlinx/io/files/FileSystem;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lio/ktor/client/plugins/cache/storage/CacheStorage;
}

4 changes: 3 additions & 1 deletion cache-kotlinx-io/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@ kotlin {
// must be `api`, not `implementation`, for consumers of :cache-kotlinx-io to resolve
// it — same rationale as :cache-okio's `api(libs.okio)`.
api(libs.kotlinx.io.core)
// The public CacheStorage factory returns Ktor's CacheStorage type.
api(libs.ktor.client.core)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.mock)
implementation(projects.cacheTestSuite)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package io.github.santimattius.persistent.cache

import io.ktor.client.plugins.cache.storage.CacheStorage
import io.ktor.util.date.getTimeMillis
import kotlinx.io.files.FileSystem
import kotlinx.io.files.SystemFileSystem

/**
* Creates a persistent, kotlinx-io-backed [CacheStorage] for Ktor's `HttpCache`, without
* installing the [PersistentCache] plugin and without depending on Okio:
*
* ```kotlin
* val client = HttpClient(CIO) {
* install(HttpCache) {
* publicStorage(KotlinxIoCacheStorage(maxSize = 20L * 1024 * 1024))
* }
* }
* ```
*
* Experimental because kotlinx-io's `kotlinx.io.files` API is Alpha upstream; see
* [ExperimentalKotlinxIoCache]. Use `OkioCacheStorage` from `cache-okio` for the stable backend.
*
* The storage is the same one `install(PersistentCache)` builds: atomic writes, collision-free
* file names with a format version, TTL and least-recently-used eviction. See `docs/STORAGE.md`.
* Use one storage instance per cache directory; two instances writing to the same directory are
* unsupported.
*
* @param directory Subdirectory name under the cache root. Default: `"http_cache"`.
* @param maxSize Maximum on-disk size in bytes. A value `<= 0` means unlimited. Default: 10 MiB.
* @param ttl Entry time-to-live in milliseconds, measured from when the entry was stored. A value
* `<= 0` means entries never expire. Default: 1 hour. HTTP freshness is still enforced by Ktor.
* @param directoryProvider Supplies the cache root directory. Defaults to the platform-specific
* [getCacheDirectoryProvider].
* @param fileSystem The kotlinx-io [FileSystem] used for all I/O. Defaults to [SystemFileSystem].
* @param clock Supplies the current time in milliseconds. Override for deterministic tests.
* @return A [CacheStorage] to pass to `publicStorage(...)` or `privateStorage(...)`.
*/
@ExperimentalKotlinxIoCache
@OptIn(InternalPersistentCacheApi::class)
public fun KotlinxIoCacheStorage(
directory: String = CacheStorageDefaults.DIRECTORY,
maxSize: Long = CacheStorageDefaults.MAX_SIZE,
ttl: Long = CacheStorageDefaults.TTL,
directoryProvider: CacheDirectoryProvider = getCacheDirectoryProvider(),
fileSystem: FileSystem = SystemFileSystem,
clock: () -> Long = { getTimeMillis() }
): CacheStorage = FileCacheStorage(
fileSystem = KotlinxIoCacheFileSystem(fileSystem),
directoryRoot = directoryProvider.cacheDirectory,
directoryName = directory,
maxSize = maxSize,
ttl = ttl,
clock = clock
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
@file:OptIn(ExperimentalKotlinxIoCache::class)

package io.github.santimattius.persistent.cache

import io.github.santimattius.persistent.cache.conformance.support.TestClock
import io.github.santimattius.persistent.cache.conformance.support.TestDataFactory
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.cache.HttpCache
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.Url
import io.ktor.http.headersOf
import kotlinx.coroutines.test.runTest
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue

/** Verifies the public [KotlinxIoCacheStorage] factory, used directly with Ktor's `HttpCache`. */
class KotlinxIoCacheStorageTest {

private lateinit var root: String

@BeforeTest
fun setUp() {
root = KotlinxIoConformanceTestSupport.freshTempDirectory("ktor-kotlinxio-storage")
}

@AfterTest
fun tearDown() {
KotlinxIoConformanceTestSupport.deleteRecursively(root)
}

private fun provider() = object : CacheDirectoryProvider {
override val cacheDirectory: String = root
}

private fun countingEngine(onRequest: () -> Unit) = MockEngine {
onRequest()
respond(
content = "payload",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.CacheControl, "max-age=3600")
)
}

private fun client(engine: MockEngine) = HttpClient(engine) {
install(HttpCache) {
publicStorage(KotlinxIoCacheStorage(directoryProvider = provider()))
}
}

@Test
fun `given a cached response when a second client reads the same directory then the engine is not called`() =
runTest {
var requests = 0
val engine = countingEngine { requests++ }
val url = "https://api.example.com/kotlinxio-factory"

val first = client(engine)
try {
assertEquals("payload", first.get(url).bodyAsText())
} finally {
first.close()
}
assertEquals(1, requests)

val second = client(engine)
try {
assertEquals("payload", second.get(url).bodyAsText())
} finally {
second.close()
}
assertEquals(1, requests, "Second client must be served from disk with zero engine requests")
}

@Test
fun `given default parameters then entries land in the http_cache directory`() = runTest {
val storage = KotlinxIoCacheStorage(directoryProvider = provider())
val url = Url("https://api.example.com/defaults")

storage.store(url, TestDataFactory.createCachedResponse(url = url.toString()))

val files = SystemFileSystem.list(Path(root, "http_cache"))
assertTrue(files.any { it.name.endsWith(".cache") }, "Expected a .cache file under http_cache")
}

@Test
fun `given a custom directory then entries land in that directory`() = runTest {
val storage = KotlinxIoCacheStorage(directory = "custom_dir", directoryProvider = provider())
val url = Url("https://api.example.com/custom")

storage.store(url, TestDataFactory.createCachedResponse(url = url.toString()))

assertTrue(SystemFileSystem.exists(Path(root, "custom_dir")))
assertTrue(!SystemFileSystem.exists(Path(root, "http_cache")))
}

@Test
fun `given a ttl when the clock passes it then the entry is expired`() = runTest {
val clock = TestClock()
val storage = KotlinxIoCacheStorage(ttl = 500, directoryProvider = provider(), clock = clock::now)
val url = Url("https://api.example.com/ttl")
storage.store(url, TestDataFactory.createCachedResponse(url = url.toString()))
assertNotNull(storage.find(url, emptyMap()))

clock.advance(501)

assertNull(storage.find(url, emptyMap()))
}

@Test
fun `given a maxSize when more data is stored then older entries are evicted`() = runTest {
val clock = TestClock()
val storage = KotlinxIoCacheStorage(maxSize = 600, directoryProvider = provider(), clock = clock::now)
val urls = (0 until 10).map { Url("https://api.example.com/size/$it") }
urls.forEach {
clock.advance(10)
storage.store(it, TestDataFactory.createCachedResponse(url = it.toString(), body = "x".repeat(200)))
}

val total = SystemFileSystem.list(Path(root, "http_cache"))
.filter { it.name.endsWith(".cache") }
.sumOf { SystemFileSystem.metadataOrNull(it)?.size ?: 0L }
assertTrue(total <= 600, "On-disk size $total must not exceed maxSize")
assertNull(storage.find(urls.first(), emptyMap()), "Oldest entry must have been evicted")
assertNotNull(storage.find(urls.last(), emptyMap()), "Newest entry must remain")
}
}
5 changes: 5 additions & 0 deletions cache-okio/api/jvm/cache-okio.api
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,8 @@ public final class io/github/santimattius/persistent/cache/OkioCacheFileSystem :
public fun writeAtomically (Lokio/Path;[B)V
}

public final class io/github/santimattius/persistent/cache/OkioCacheStorageKt {
public static final fun OkioCacheStorage (Ljava/lang/String;JJLio/github/santimattius/persistent/cache/CacheDirectoryProvider;Lokio/FileSystem;Lkotlin/jvm/functions/Function0;)Lio/ktor/client/plugins/cache/storage/CacheStorage;
public static synthetic fun OkioCacheStorage$default (Ljava/lang/String;JJLio/github/santimattius/persistent/cache/CacheDirectoryProvider;Lokio/FileSystem;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lio/ktor/client/plugins/cache/storage/CacheStorage;
}

2 changes: 2 additions & 0 deletions cache-okio/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ kotlin {
// OkioCacheFileSystem exposes okio.Path in its public API, so this must be `api`,
// not `implementation`, for consumers of :cache-okio to resolve it.
api(libs.okio)
// The public CacheStorage factory returns Ktor's CacheStorage type.
api(libs.ktor.client.core)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
Expand Down
Loading
Loading