diff --git a/.github/workflows/PR.yml b/.github/workflows/PR.yml index eefa552..8c8ae40 100644 --- a/.github/workflows/PR.yml +++ b/.github/workflows/PR.yml @@ -24,11 +24,11 @@ jobs: if: failure() with: name: testDebugUnitTest - path: ./**/build/reports/tests/testDebugUnitTest + path: ./**/build/reports/tests/testDebugUnitTest/* - name: Upload allTests results uses: actions/upload-artifact@v2.2.3 if: failure() with: name: allTests - path: ./**/build/reports/test/allTests + path: ./**/build/reports/tests/allTests/* diff --git a/fuse-core/.gitignore b/fuse-core/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/fuse-core/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/fuse-core/build.gradle.kts b/fuse-core/build.gradle.kts new file mode 100644 index 0000000..65b9da8 --- /dev/null +++ b/fuse-core/build.gradle.kts @@ -0,0 +1,167 @@ +plugins { + kotlin("multiplatform") + kotlin("plugin.serialization") + + id("com.android.library") +// java + jacoco + + + id("publication") +} + +val artifactGroupId: String by project +group = artifactGroupId + +val gitSha = "git rev-parse --short HEAD".runCommand(project.rootDir)?.trim().orEmpty() + +val isReleaseBuild: Boolean + get() = properties.containsKey("release") + +val artifactPublishVersion: String by project +version = if (isReleaseBuild) artifactPublishVersion else "master-$gitSha-SNAPSHOT" + +kotlin { + jvm() + ios() + iosSimulatorArm64() + android() + + sourceSets { + all { + languageSettings { + optIn("kotlin.RequiresOptIn") + } + } + + val commonMain by getting { + dependencies { + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.time) + api(libs.result) + } + } + + val commonTest by getting { + dependencies { + implementation(libs.bundles.kotlin.test) + } + } + + val jvmMain by getting { + dependencies { + } + } + + val jvmTest by getting { + dependencies { + implementation(libs.kotlin.test.junit) + } + } + + val iosMain by getting { + dependencies { + } + } + + val iosSimulatorArm64Main by getting { + dependsOn(iosMain) + } + + val iosSimulatorArm64Test by getting { + val iosTest by getting + dependsOn(iosTest) + } + + val androidMain by getting { + dependsOn(jvmMain) + } + + val androidTest by getting { + dependencies { + implementation(libs.bundles.android.test) + implementation(libs.kotlin.test.junit) + } + } + } +} + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + + sourceSets { + getByName("main") { + manifest.srcFile("src/androidMain/AndroidManifest.xml") + java.srcDirs("src/androidMain/kotlin") + res.srcDirs("src/androidMain/res") + } + + getByName("androidTest") { + manifest.srcFile("src/androidTest/AndroidManifest.xml") + java.srcDirs("src/androidTest/kotlin") + res.srcDirs("src/androidTest/res") + } + } + + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + + testOptions { + unitTests.isReturnDefaultValues = true + } +} + +jacoco { + toolVersion = libs.versions.jacoco.get() +} + +tasks { + withType { + group = "Reporting" + description = "Generate Jacoco coverage reports." + + val jvmTest by getting + dependsOn(jvmTest) + + val classFiles = File("$buildDir/classes/kotlin/jvm/main").walkBottomUp().toSet() + classDirectories.setFrom(classFiles) + sourceDirectories.setFrom(files(arrayOf("$projectDir/src/commonMain"))) + executionData.setFrom(files("$buildDir/jacoco/jvmTest.exec")) + + reports { + xml.required.set(true) + + html.required.set(true) + html.outputLocation.set(buildDir.resolve("reports")) + + csv.required.set(false) + } + } + + val tests = listOfNotNull(findByName("iosSimulatorArm64Test"), findByName("iosX64Test")) + + val copyIOSTestResources by creating(Copy::class) { + val spec = copySpec { + from("src/commonTest/resources") + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } + into("$buildDir/bin") + + tests.forEach { + into("/${it.name.substringBefore("Test")}/debugTest/resources") { with(spec) } + } + } + + tests.forEach { it.dependsOn(copyIOSTestResources) } +} diff --git a/fuse-core/src/androidMain/AndroidManifest.xml b/fuse-core/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..5b4cc1a --- /dev/null +++ b/fuse-core/src/androidMain/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/fuse-core/src/androidMain/kotlin/com/github/kittinunf/fuse/core/AndroidConfig.kt b/fuse-core/src/androidMain/kotlin/com/github/kittinunf/fuse/core/AndroidConfig.kt new file mode 100644 index 0000000..7b6d26b --- /dev/null +++ b/fuse-core/src/androidMain/kotlin/com/github/kittinunf/fuse/core/AndroidConfig.kt @@ -0,0 +1,29 @@ +package com.github.kittinunf.fuse.core + +import android.content.Context +import com.github.kittinunf.fuse.core.formatter.JsonBinaryConverter +import com.github.kittinunf.fuse.core.persistence.JvmDiskPersistence +import com.github.kittinunf.fuse.core.persistence.MemPersistence +import com.github.kittinunf.fuse.core.persistence.Persistence +import kotlinx.serialization.BinaryFormat +import kotlinx.serialization.KSerializer + +fun AndroidConfig( + name: String, + context: Context, + serializer: KSerializer, + formatter: BinaryFormat = JsonBinaryConverter(), + diskCapacity: Long = 1024 * 1024 * 20, + transformer: (key: String, value: T) -> T = { _, value -> value }, + memCache: Persistence = MemPersistence(), + diskCache: Persistence = JvmDiskPersistence(name, context.cacheDir) +) = JvmConfig( + name, + path = context.cacheDir, + serializer = serializer, + formatter = formatter, + diskCapacity = diskCapacity, + transformer = transformer, + memCache = memCache, + diskCache = diskCache +) diff --git a/fuse-core/src/androidMain/kotlin/com/github/kittinunf/fuse/core/AndroidDiskPersistence.kt b/fuse-core/src/androidMain/kotlin/com/github/kittinunf/fuse/core/AndroidDiskPersistence.kt new file mode 100644 index 0000000..4af6865 --- /dev/null +++ b/fuse-core/src/androidMain/kotlin/com/github/kittinunf/fuse/core/AndroidDiskPersistence.kt @@ -0,0 +1,6 @@ +package com.github.kittinunf.fuse.core + +import android.content.Context +import com.github.kittinunf.fuse.core.persistence.JvmDiskPersistence + +fun AndroidDiskPersistence(name: String, context: Context) = JvmDiskPersistence(name, context.cacheDir) diff --git a/fuse-core/src/androidTest/kotlin/com/github/kittinunf/fuse/core/BaseTest.kt b/fuse-core/src/androidTest/kotlin/com/github/kittinunf/fuse/core/BaseTest.kt new file mode 100644 index 0000000..c130990 --- /dev/null +++ b/fuse-core/src/androidTest/kotlin/com/github/kittinunf/fuse/core/BaseTest.kt @@ -0,0 +1,17 @@ +package com.github.kittinunf.fuse.core + +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Before +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +actual abstract class BaseTest { + + @Before + internal actual fun before() { + setUp(ApplicationProvider.getApplicationContext()) + } + + actual abstract fun setUp(any: Any) +} diff --git a/fuse-core/src/androidTest/kotlin/com/github/kittinunf/fuse/core/CreateByteTestCache.kt b/fuse-core/src/androidTest/kotlin/com/github/kittinunf/fuse/core/CreateByteTestCache.kt new file mode 100644 index 0000000..71972a9 --- /dev/null +++ b/fuse-core/src/androidTest/kotlin/com/github/kittinunf/fuse/core/CreateByteTestCache.kt @@ -0,0 +1,9 @@ +package com.github.kittinunf.fuse.core + +import android.content.Context +import kotlinx.serialization.builtins.ByteArraySerializer + +internal actual fun createByteTestCache(name: String, context: Any): Cache { + val context = context as Context + return JvmConfig(name, path = context.cacheDir, serializer = ByteArraySerializer()).build() +} diff --git a/fuse-core/src/androidTest/kotlin/com/github/kittinunf/fuse/core/CreateJsonTestCache.kt b/fuse-core/src/androidTest/kotlin/com/github/kittinunf/fuse/core/CreateJsonTestCache.kt new file mode 100644 index 0000000..bb3a7f6 --- /dev/null +++ b/fuse-core/src/androidTest/kotlin/com/github/kittinunf/fuse/core/CreateJsonTestCache.kt @@ -0,0 +1,8 @@ +package com.github.kittinunf.fuse.core + +import kotlinx.serialization.builtins.serializer +import com.github.kittinunf.fuse.core.model.Product + +internal actual fun createJsonTestCache(name: String, context: Any): Cache { + return JvmConfig(name, path = createTempDir(suffix = "").parentFile, serializer = Product.serializer()).build() +} diff --git a/fuse-core/src/androidTest/kotlin/com/github/kittinunf/fuse/core/CreateStringTestCache.kt b/fuse-core/src/androidTest/kotlin/com/github/kittinunf/fuse/core/CreateStringTestCache.kt new file mode 100644 index 0000000..0b3f373 --- /dev/null +++ b/fuse-core/src/androidTest/kotlin/com/github/kittinunf/fuse/core/CreateStringTestCache.kt @@ -0,0 +1,7 @@ +package com.github.kittinunf.fuse.core + +import kotlinx.serialization.builtins.serializer + +internal actual fun createStringTestCache(name: String, context: Any): Cache { + return JvmConfig(name, path = createTempDir(suffix = "").parentFile, serializer = String.serializer()).build() +} diff --git a/fuse-core/src/androidTest/kotlin/com/github/kittinunf/fuse/core/CreateTestPersistence.kt b/fuse-core/src/androidTest/kotlin/com/github/kittinunf/fuse/core/CreateTestPersistence.kt new file mode 100644 index 0000000..8417dae --- /dev/null +++ b/fuse-core/src/androidTest/kotlin/com/github/kittinunf/fuse/core/CreateTestPersistence.kt @@ -0,0 +1,9 @@ +package com.github.kittinunf.fuse.core + +import android.content.Context +import com.github.kittinunf.fuse.core.persistence.Persistence + +actual fun createTestDiskPersistence(context: Any): Persistence { + val context = context as Context + return AndroidDiskPersistence("test-cache", context) +} diff --git a/fuse-core/src/androidTest/kotlin/com/github/kittinunf/fuse/core/ReadResource.kt b/fuse-core/src/androidTest/kotlin/com/github/kittinunf/fuse/core/ReadResource.kt new file mode 100644 index 0000000..da8064d --- /dev/null +++ b/fuse-core/src/androidTest/kotlin/com/github/kittinunf/fuse/core/ReadResource.kt @@ -0,0 +1,5 @@ +package com.github.kittinunf.fuse.core + +actual fun readResource(name: String): ByteArray { + return ClassLoader.getSystemResourceAsStream(name).readBytes() +} diff --git a/fuse-core/src/androidTest/resources/another_sample.json b/fuse-core/src/androidTest/resources/another_sample.json new file mode 100644 index 0000000..42229c3 --- /dev/null +++ b/fuse-core/src/androidTest/resources/another_sample.json @@ -0,0 +1,26 @@ +{ + "name": "Another Product", + "properties": { + "id": { + "type": "number", + "description": "Another Product Identifier", + "required": true + }, + "name": { + "type": "string", + "description": "Name of the another product", + "required": true + }, + "price": { + "type": "number", + "minimum": 42, + "required": true + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/fuse-core/src/androidTest/resources/sample.json b/fuse-core/src/androidTest/resources/sample.json new file mode 100644 index 0000000..8484508 --- /dev/null +++ b/fuse-core/src/androidTest/resources/sample.json @@ -0,0 +1,26 @@ +{ + "name": "Product", + "properties": { + "id": { + "type": "number", + "description": "Product Identifier", + "required": true + }, + "name": { + "type": "string", + "description": "Name of the product", + "required": true + }, + "price": { + "type": "number", + "minimum": 10, + "required": true + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/Cache.kt b/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/Cache.kt new file mode 100644 index 0000000..425c84d --- /dev/null +++ b/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/Cache.kt @@ -0,0 +1,132 @@ +package com.github.kittinunf.fuse.core + +import com.github.kittinunf.fuse.core.fetcher.Fetcher +import com.github.kittinunf.fuse.core.model.Entry +import com.github.kittinunf.result.Result +import com.github.kittinunf.result.flatMap +import kotlinx.datetime.Clock +import kotlinx.serialization.BinaryFormat +import kotlinx.serialization.KSerializer + +enum class Source { + ORIGIN, + MEM, + DISK, +} + +interface Cache : Fuse.Cacheable, BinaryFormat + +class CacheImpl internal constructor(private val config: Config, private val serializer: KSerializer) : Cache, + BinaryFormat by config.formatter { + + private val memCache = config.memCache + private val diskCache = config.diskCache + + override fun put(fetcher: Fetcher): Result { + return fetchAndPut(fetcher) + } + + override fun get(fetcher: Fetcher): Result { + return _get(fetcher).first + } + + override fun getWithSource(fetcher: Fetcher): Pair, Source> { + return _get(fetcher) + } + + @Suppress("UNCHECKED_CAST") + private fun _get(fetcher: Fetcher): Pair, Source> { + val key = fetcher.key + val safeKey = key.md5() + + // found in memCache + memCache.get(safeKey)?.let { value -> + // move specific key in disk cache up as it is found in mem + val result = Result.of { + if (diskCache.get(safeKey) == null) { + // we found this in memCache, so we need to retrieve timeStamp that was saved in memCache back to diskCache + val timeWasPersisted = memCache.getTimestamp(safeKey) + diskCache.put(safeKey, Entry(key, encodeToByteArray(serializer, value), timeWasPersisted ?: -1)) + } + value as T + } + return result to Source.MEM + } + + // find in diskCache + val value = diskCache.get(safeKey) + if (value == null) { + // not found we need to fetch then put it back + return fetchAndPut(fetcher) to Source.ORIGIN + } else { + // found in disk, save back into mem + val result = Result.of { + // we found this in disk cache, so we need to retrieve timeStamp that was stored in diskCache back to memCache + val converted = decodeFromByteArray(serializer, value) + + val timeWasPersisted = diskCache.getTimestamp(safeKey) + // put the converted version into the memCache + memCache.put(safeKey, Entry(key, converted, timeWasPersisted ?: -1)) + converted + } + return result to Source.DISK + } + } + + private fun put(key: String, value: T): Result { + val transformed = config.transformer(key, value) + + // save the persist timing + val timeToPersist = Clock.System.now().toEpochMilliseconds() + val safeKey = key.md5() + + memCache.put(safeKey, Entry(key, transformed, timeToPersist)) + return Result.of { + diskCache.put(safeKey, Entry(key, encodeToByteArray(serializer, transformed), timeToPersist)) + transformed + } + } + + override fun remove(key: String, fromSource: Source): Boolean { + require(fromSource != Source.ORIGIN) { "Cannot remove from Source.ORIGIN" } + + val safeKey = key.md5() + return when (fromSource) { + Source.MEM -> memCache.remove(safeKey) + Source.DISK -> diskCache.remove(safeKey) + else -> { + false + } + } + } + + override fun removeAll() { + memCache.removeAll() + diskCache.removeAll() + } + + override fun allKeys(): Set { + val keys = memCache.allKeys() + return keys.takeIf { it.isNotEmpty() } ?: diskCache.allKeys() + } + + override fun hasKey(key: String): Boolean { + val safeKey = key.md5() + val value = memCache.get(safeKey) ?: diskCache.get(safeKey) + return value != null + } + + override fun getTimestamp(key: String): Long? { + val safeKey = key.md5() + return memCache.getTimestamp(safeKey) ?: diskCache.getTimestamp(safeKey) + } + + private fun fetchAndPut(fetcher: Fetcher): Result { + val fetchResult = fetcher.fetch() + return fetchResult.flatMap { put(fetcher.key, it) } + } +} + +fun Config.build(): Cache = CacheImpl(this, serializer) + +internal expect fun String.md5(): String diff --git a/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/Config.kt b/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/Config.kt new file mode 100644 index 0000000..5541e7d --- /dev/null +++ b/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/Config.kt @@ -0,0 +1,15 @@ +package com.github.kittinunf.fuse.core + +import com.github.kittinunf.fuse.core.persistence.Persistence +import kotlinx.serialization.BinaryFormat +import kotlinx.serialization.KSerializer + +interface Config { + val name: String + val serializer: KSerializer + val formatter: BinaryFormat + val diskCapacity: Long + val transformer: ((key: String, value: T) -> T) + val memCache: Persistence + val diskCache: Persistence +} diff --git a/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/Fuse.kt b/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/Fuse.kt new file mode 100644 index 0000000..f54f285 --- /dev/null +++ b/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/Fuse.kt @@ -0,0 +1,126 @@ +package com.github.kittinunf.fuse.core + +import com.github.kittinunf.fuse.core.fetcher.Fetcher +import com.github.kittinunf.fuse.core.fetcher.NeverFetcher +import com.github.kittinunf.fuse.core.fetcher.SimpleFetcher +import com.github.kittinunf.result.Result + +object Fuse { + + interface Cacheable { + /** + * Put the entry supplied by fetcher into the persistence. + * This method will automatically fetch the value from the fetcher and put the entry into the cache (both Memory, and Disk). + * + * @param fetcher The fetcher object that can be used to fetch the new value from the origin + * @return Result The Result that represents the success/failure of the operation + */ + fun put(fetcher: Fetcher): Result + + /** + * Get the entry associated with its particular key which provided by the persistence. + * This method will automatically fetch if and only if the entry was not already saved in the persistence previously + * Otherwise it will return the entry from the persistence + * + * @param fetcher The fetcher object that can be used to fetch the new value from the origin + * @return Result The Result that represents the success/failure of the operation + */ + fun get(fetcher: Fetcher): Result + + /** + * Get the entry associated with its particular key which provided by the persistence. + * This method will automatically fetch if and only if the entry was not already saved in the persistence previously + * Otherwise it will return the entry from the persistence which specified by Source (ORIGIN, MEM, or DISK) + * + * @param fetcher The fetcher object that can be used to fetch the new value from the origin + * @return Pair, Cache.Source> The Pair of the result that represents the success/failure of the operation and The source of the entry + */ + fun getWithSource(fetcher: Fetcher): Pair, Source> + + /** + * Remove the entry associated with its particular key which was saved previously + * In the case of, entry is not found, it will no-op and return false + * + * @param key The key associated with the object to be persisted + * @param fromSource The source of the value to be removed, either MEM or DISK + * @return Boolean Whether the value was removed successfully + */ + fun remove(key: String, fromSource: Source = Source.MEM): Boolean + + /** + * Remove all the entry in the persistence + */ + fun removeAll() + + /** + * Retrieve the keys from all values persisted + * @return Set Set of keys + */ + fun allKeys(): Set + + /** + * Check whether the entry for the given key is there in the persistence or not + * @return Boolean The result of the check, true if the entry is there otherwise false + */ + fun hasKey(key: String): Boolean + + /** + * Retrieve the keys from all values persisted + * @param key The key associated with the object to be persisted + * @return Long represents the timestamp in milliseconds since epoch 1970, or null which means that the key is not present in cache + */ + fun getTimestamp(key: String): Long? + } +} + +// region Value +/** + * Get the entry associated as a value in T by using lambda getValue as a default value generator. If value for associated Key is not there, it saves with value from defaultValue. + * + * @param key The String represent key of the entry + * @return Result The Result that represents the success/failure of the operation + */ +fun Cache.get(key: String, defaultValue: (() -> T?)): Result { + val fetcher = SimpleFetcher(key, defaultValue) + return get(fetcher) +} + +/** + * Get the entry associated as a value in T. Unlike [Cache.get(key: String, defaultValue: (() -> T))] counterpart, if value for associated Key is not there, it returns as [Result.Failure] + * + * @param key The String represent key of the entry + * @return Result The Result that represents the success/failure of the operation + */ +fun Cache.get(key: String): Result = get(NeverFetcher(key)) + +/** + * Get the entry associated as a value in T by using lambda as a default value generator. if value for associated key is not there, it saves with value from defaultValue. + * + * @param key The string represent key of the entry + * @return Pair, Source>> The result that represents the success/failure of the operation + */ +fun Cache.getWithSource(key: String, defaultValue: (() -> T?)): Pair, Source> { + val fetcher = SimpleFetcher(key, defaultValue) + return getWithSource(fetcher) +} + +/** + * Get the entry associated as a value in T by using lambda as a default value generator. if value for associated key is not there, it returns failures as we don't provide defaultValue + * + * @param key The string represent key of the entry + * @return Pair, Source>> The result that represents the success/failure of the operation + */ +fun Cache.getWithSource(key: String): Pair, Source> = getWithSource(NeverFetcher(key)) + +/** + * Put the entry as a content of a file into Cache + * + * @param key file object that represent file data on the disk + * @return Result The Result that represents the success/failure of the operation + */ +fun Cache.put(key: String, putValue: T): Result { + val fetcher = SimpleFetcher(key, { putValue }) + return put(fetcher) +} +// endregion Value + diff --git a/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/fetcher/Fetcher.kt b/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/fetcher/Fetcher.kt new file mode 100644 index 0000000..18bc5a1 --- /dev/null +++ b/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/fetcher/Fetcher.kt @@ -0,0 +1,18 @@ +package com.github.kittinunf.fuse.core.fetcher + +import com.github.kittinunf.result.Result + +interface Fetcher { + + val key: String + + fun fetch(): Result + + fun cancel() {} +} + +internal class SimpleFetcher(override val key: String, private val getValue: () -> T?) : Fetcher { + + override fun fetch(): Result = + if (getValue() == null) Result.failure(RuntimeException("Fetch with Key: $key is failure")) else Result.of(getValue) +} diff --git a/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/fetcher/NeverFetcher.kt b/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/fetcher/NeverFetcher.kt new file mode 100644 index 0000000..bdf3c73 --- /dev/null +++ b/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/fetcher/NeverFetcher.kt @@ -0,0 +1,10 @@ +package com.github.kittinunf.fuse.core.fetcher + +import com.github.kittinunf.result.Result + +internal class NotFoundException(key: String) : RuntimeException("Value with key: $key is not found in cache") + +internal class NeverFetcher(override val key: String) : Fetcher { + + override fun fetch(): Result = Result.failure(NotFoundException(key)) +} diff --git a/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/formatter/JsonBinaryConverter.kt b/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/formatter/JsonBinaryConverter.kt new file mode 100644 index 0000000..c55f0b2 --- /dev/null +++ b/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/formatter/JsonBinaryConverter.kt @@ -0,0 +1,18 @@ +package com.github.kittinunf.fuse.core.formatter + +import kotlinx.serialization.BinaryFormat +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule + +internal class JsonBinaryConverter(private val json: Json = Json) : BinaryFormat { + + override val serializersModule: SerializersModule = json.serializersModule + + override fun decodeFromByteArray(deserializer: DeserializationStrategy, bytes: ByteArray): T = + json.decodeFromString(deserializer, bytes.decodeToString()) + + override fun encodeToByteArray(serializer: SerializationStrategy, value: T): ByteArray = + json.encodeToString(serializer, value).encodeToByteArray() +} diff --git a/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/formatter/StringBinaryConverter.kt b/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/formatter/StringBinaryConverter.kt new file mode 100644 index 0000000..ec8209f --- /dev/null +++ b/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/formatter/StringBinaryConverter.kt @@ -0,0 +1,16 @@ +package com.github.kittinunf.fuse.core.formatter + +import kotlinx.serialization.BinaryFormat +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule + +internal class StringBinaryConverter : BinaryFormat { + + override val serializersModule: SerializersModule = EmptySerializersModule + + override fun decodeFromByteArray(deserializer: DeserializationStrategy, bytes: ByteArray): T = bytes.decodeToString() as T + + override fun encodeToByteArray(serializer: SerializationStrategy, value: T): ByteArray = (value as String).encodeToByteArray() +} diff --git a/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/model/Entry.kt b/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/model/Entry.kt new file mode 100644 index 0000000..8cf0156 --- /dev/null +++ b/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/model/Entry.kt @@ -0,0 +1,6 @@ +package com.github.kittinunf.fuse.core.model + +import kotlinx.serialization.Serializable + +@Serializable +data class Entry(val key: String, val data: T, val timestamp: Long) diff --git a/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/persistence/MemPersistence.kt b/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/persistence/MemPersistence.kt new file mode 100644 index 0000000..73daddb --- /dev/null +++ b/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/persistence/MemPersistence.kt @@ -0,0 +1,32 @@ +package com.github.kittinunf.fuse.core.persistence + +import com.github.kittinunf.fuse.core.model.Entry + +internal class MemPersistence : Persistence { + + private val cache: MutableMap = LinkedHashMap(0, 0.75f) + + override fun put(safeKey: String, entry: Entry) { + cache.put(safeKey, entry) + } + + override fun remove(safeKey: String): Boolean = cache.remove(safeKey) != null + + override fun removeAll() { + cache.clear() + } + + override fun allKeys(): Set { + val snapshot = LinkedHashMap(cache) + return snapshot.keys + .mapNotNull { getEntry(it)?.key } + .toSet() + } + + override fun get(safeKey: String): T? = getEntry(safeKey)?.data + + override fun getTimestamp(safeKey: String): Long? = getEntry(safeKey)?.timestamp + + @Suppress("UNCHECKED_CAST") + private fun getEntry(safeKey: String): Entry? = cache.get(safeKey) as? Entry +} diff --git a/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/persistence/Persistence.kt b/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/persistence/Persistence.kt new file mode 100644 index 0000000..98d2ec5 --- /dev/null +++ b/fuse-core/src/commonMain/kotlin/com/github/kittinunf/fuse/core/persistence/Persistence.kt @@ -0,0 +1,46 @@ +package com.github.kittinunf.fuse.core.persistence + +import com.github.kittinunf.fuse.core.model.Entry + +interface Persistence { + /** + * Save the entry supplied based on a certain mechanism which provides persistence + * + * @param safeKey The safeKey associated with the value to be persisted, this is sanitized key and it is conformed to regex [a-z0-9_-]{1,64} + * @param entry The Entry associated with the value to be persisted, please visit [Entry] for more information on the object's definition + */ + fun put(safeKey: String, entry: Entry) + + /** + * Remove the entry associated with its particular safeKey + * + * @param safeKey The safeKey associated with the object to be deleted from persistence + * @return Boolean Whether the safeKey was removed successfully + */ + fun remove(safeKey: String): Boolean + + /** + * Remove all the entry in the persistence + */ + fun removeAll() + + /** + * Retrieve the keys from all values persisted, this is a real as opposed to safeKey + * @return Set Set of un-sanitized keys which are readable from the call-site + */ + fun allKeys(): Set + + /** + * Get the value associated with its particular safeKey + * + * @param safeKey The safeKey associated with the value to be retrieved from persistence + */ + fun get(safeKey: String): T? + + /** + * Get timestamp in milliseconds associated with its particular safeKey + * + * @param safeKey The safeKey associated with the value to be retrieved from persistence + */ + fun getTimestamp(safeKey: String): Long? +} diff --git a/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/BaseTest.kt b/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/BaseTest.kt new file mode 100644 index 0000000..e94a330 --- /dev/null +++ b/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/BaseTest.kt @@ -0,0 +1,8 @@ +package com.github.kittinunf.fuse.core + +expect abstract class BaseTest() { + + internal fun before() + + abstract fun setUp(any: Any) +} diff --git a/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/FuseByteCacheTest.kt b/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/FuseByteCacheTest.kt new file mode 100644 index 0000000..ca26d94 --- /dev/null +++ b/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/FuseByteCacheTest.kt @@ -0,0 +1,172 @@ +package com.github.kittinunf.fuse.core + +import com.github.kittinunf.fuse.core.fetcher.NotFoundException +import kotlinx.datetime.Clock +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +internal expect fun createByteTestCache(name: String, context: Any): Cache + +class FuseByteCacheTest : BaseTest() { + + lateinit var cache: Cache + + override fun setUp(any: Any) { + cache = createByteTestCache("test", any) + cache.removeAll() + } + + @Test + fun `should fetch data correctly with defaultValue`() { + val (result, source) = cache.getWithSource("hello", defaultValue = { "world".encodeToByteArray() }) + val (value, error) = result + + assertNotNull(value) + assertEquals("world", value.decodeToString()) + assertNull(error) + assertEquals(Source.ORIGIN, source) + } + + @Test + fun `should return hasKey as true when there is data with key in the cache`() { + val (value, error) = cache.get("hello", defaultValue = { "world".encodeToByteArray() }) + val hasKey = cache.hasKey("hello") + + assertNotNull(value) + assertEquals(value.decodeToString(), "world") + assertNull(error) + assertTrue(hasKey) + + val notFound = cache.hasKey("xxxx") + assertFalse(notFound) + } + + @Test + fun `should return failure when fetch function is null out`() { + fun fetchFail(): ByteArray? = null + + val (result, source) = cache.getWithSource("fail", ::fetchFail) + val (value, error) = result + + assertNull(value) + assertNotNull(error) + assertEquals(Source.ORIGIN, source) + } + + @Test + fun `should return source as memory after fetching the second time`() { + // get this once, to trigger save in memory + cache.getWithSource("hello", defaultValue = { "world".encodeToByteArray() }) + + val (result, source) = cache.getWithSource("hello", defaultValue = { "world".encodeToByteArray() }) + val (value, error) = result + + assertNotNull(value) + assertEquals("world", value.decodeToString()) + assertNull(error) + assertEquals(Source.MEM, source) + } + + @Test + fun `should fallback to use disk value when we remove it from memory`() { + cache.getWithSource("hello", defaultValue = { "world".encodeToByteArray() }) + // remove from memory cache + cache.remove("hello", Source.MEM) + + val (result, source) = cache.getWithSource("hello") + val (value, error) = result + + assertNotNull(value) + assertEquals("world", value.decodeToString()) + assertNull(error) + assertEquals(Source.DISK, source) + } + + @Test + fun `should put value into the cache successfully`() { + val (value, error) = cache.put("put", "Hello world".encodeToByteArray()) + + assertNotNull(value) + assertEquals("Hello world", value.decodeToString()) + assertNull(error) + + val (value2, error2) = cache.get("put") + + assertNotNull(value2) + assertNull(error2) + assertEquals("Hello world", value2.decodeToString()) + } + + @Test + fun `should get correct timestamp`() { + val timeStamp = Clock.System.now().toEpochMilliseconds() + cache.get("timestamp", { timeStamp.toString().encodeToByteArray() }) + + val retrieved = cache.getTimestamp("timestamp") + + assertNotNull(retrieved) + assertNotEquals(-1, retrieved) + assertTrue { (timeStamp - retrieved) < 1_000 } + } + + @Test + fun `should able to remove item correctly`() { + val (result, source) = cache.getWithSource("YOYO", { "yoyo".encodeToByteArray() }) + val (value, error) = result + + assertNotNull(value) + assertEquals("yoyo", value.decodeToString()) + assertNull(error) + assertEquals(Source.ORIGIN, source) + + cache.remove("YOYO", Source.MEM) + cache.remove("YOYO", Source.DISK) + + val (anotherValue, anotherError) = cache.get("YOYO") + + assertNull(anotherValue) + assertNotNull(anotherError) + assertIs(anotherError) + } + + @Test + fun `should be able to remove from mem correctly`() { + cache.put("remove", "test".encodeToByteArray()) + + val result = cache.remove("remove") + assertTrue(result) + + val anotherResult = cache.remove("remove") + assertFalse(anotherResult) + } + + @Test + fun `should be able to remove from disk correctly`() { + cache.put("remove", "test".encodeToByteArray()) + + val result = cache.remove("remove", Source.DISK) + assertTrue(result) + + val anotherResult = cache.remove("remove", Source.MEM) + assertTrue(anotherResult) + + val hasKey = cache.hasKey("remove") + assertFalse(hasKey) + } + + @Test + fun `should remove all keys correctly`() { + (1..5).forEach { + cache.put("remove $it", "yoyo".encodeToByteArray()) + } + assertEquals(setOf("remove 1", "remove 2", "remove 3", "remove 4", "remove 5"), cache.allKeys()) + cache.removeAll() + assertEquals(emptySet(), cache.allKeys()) + } +} diff --git a/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/FuseJsonCacheTest.kt b/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/FuseJsonCacheTest.kt new file mode 100644 index 0000000..5f72d1a --- /dev/null +++ b/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/FuseJsonCacheTest.kt @@ -0,0 +1,118 @@ +package com.github.kittinunf.fuse.core + +import com.github.kittinunf.fuse.core.model.Id +import com.github.kittinunf.fuse.core.model.Price +import com.github.kittinunf.fuse.core.model.Product +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +internal expect fun createStringTestCache(name: String, context: Any): Cache + +internal expect fun createJsonTestCache(name: String, context: Any): Cache + +class FuseJsonCacheTest : BaseTest() { + + lateinit var cache: Cache + lateinit var productCache: Cache + + override fun setUp(any: Any) { + cache = createStringTestCache("json-test", any) + productCache = createJsonTestCache("product-test", any) + + cache.removeAll() + productCache.removeAll() + } + + @Serializable + private data class Name(val name: String, val surname: String) + + @Test + fun `should fetch from json string correctly`() { + val json = """ + { "name" : "hello", "surname" : "world" } + """.trimIndent() + + val (value, error) = cache.get("json", defaultValue = { json }) + assertNotNull(value) + assertNull(error) + + val (v, e) = cache.get("json") + assertNotNull(v) + assertNull(e) + val serialized = Json.decodeFromString(v) + assertEquals("hello", serialized.name) + assertEquals("world", serialized.surname) + } + + @Test + fun `should read data as json string correctly`() { + val data = readResource("./sample.json") + + val (value, error) = cache.get("json", defaultValue = { data.decodeToString() }) + assertNotNull(value) + assertNull(error) + + val (result, source) = cache.getWithSource("json") + assertEquals(Source.MEM, source) + val (v, e) = result + assertNotNull(v) + assertNull(e) + val serialized = Json.decodeFromString(v) + assertEquals("Product", serialized.name) + assertEquals(Id("number", description = "Product Identifier", required = true), serialized.properties.id) + assertEquals(Price("number", minimum = 10, required = true), serialized.properties.price) + } + + @Test + fun `should read and deserialize into customize object correctly`() { + val data = readResource("./another_sample.json") + + val (value, error) = productCache.get("another", defaultValue = { Json.decodeFromString(data.decodeToString()) }) + + assertNotNull(value) + assertNull(error) + + val (result, source) = productCache.getWithSource("another") + assertEquals(Source.MEM, source) + val (v, e) = result + assertNotNull(v) + assertNull(e) + + assertEquals("Another Product", v.name) + assertEquals(Id("number", description = "Another Product Identifier", required = true), v.properties.id) + assertEquals(Price("number", minimum = 42, required = true), v.properties.price) + } + +// @Test +// fun putWithValueJsonCompatible() { +// val temp = assetDir.resolve("sample.json").copyTo(assetDir.resolve("temp.json"), true) +// +// val cache = CacheBuilder.config(tempDir, ProductDataConvertible()).build() +// +// val newText = temp.readText().replace("Product", "New Product") +// temp.writeText(newText) +// +// val (value, error) = cache.put(temp) +// +// assertThat(value, notNullValue()) +// assertThat(error, nullValue()) +// assertThat(value!!.name, equalTo("New Product")) +// } +// +// @Test +// fun putWithValueJsonNotCompatible() { +// val json = assetDir.resolve("broken_sample.json") +// +// val cache = CacheBuilder.config(tempDir, ProductDataConvertible()).build() +// +// val (value, error) = cache.put(DiskFetcher(json, cache)) +// +// assertThat(value, nullValue()) +// assertThat(error, notNullValue()) +// } +} diff --git a/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/MD5Test.kt b/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/MD5Test.kt new file mode 100644 index 0000000..b3a38a3 --- /dev/null +++ b/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/MD5Test.kt @@ -0,0 +1,21 @@ +package com.github.kittinunf.fuse.core + +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class MD5Test { + + @BeforeTest + fun before() { + } + + @Test + fun `should return correct md5 value`() { + val string = "Hello world!" + assertEquals("86fb269d190d2c85f6e0468ceca42a20", string.md5()) + + val anotherString = "MD5Test" + assertEquals("add684e1863b5d900583236825a431c2", anotherString.md5()) + } +} diff --git a/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/PersistenceTest.kt b/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/PersistenceTest.kt new file mode 100644 index 0000000..2bdbc5d --- /dev/null +++ b/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/PersistenceTest.kt @@ -0,0 +1,145 @@ +package com.github.kittinunf.fuse.core + +import com.github.kittinunf.fuse.core.model.Entry +import com.github.kittinunf.fuse.core.persistence.MemPersistence +import com.github.kittinunf.fuse.core.persistence.Persistence +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +expect fun createTestDiskPersistence(context: Any): Persistence + +class PersistenceTest : BaseTest() { + + private lateinit var diskCache: Persistence + private lateinit var memCache: MemPersistence + + override fun setUp(any: Any) { + diskCache = createTestDiskPersistence(any) + memCache = MemPersistence() + } + + @Test + fun `should put data into persistence without error`() { + diskCache.put("112233", Entry("112233", "hello".encodeToByteArray(), 0)) + memCache.put("112233", Entry("112233", "hello", 0)) + + assertNotNull(diskCache) + assertNotNull(memCache) + } + + @Test + fun `should be able to put data and get it back without error`() { + diskCache.put("av1342", Entry("av1342", "hello world!".encodeToByteArray(), 0)) + memCache.put("av1342", Entry("av1342", "hello world!", 0)) + + val diskResult = diskCache.get("av1342") + + assertNotNull(diskResult) + assertEquals("hello world!", diskResult.decodeToString()) + + val memResult = memCache.get("av1342") + + assertNotNull(memResult) + assertEquals("hello world!", memResult) + } + + @Test + fun `should be able to delete item without error`() { + diskCache.put("to-be-deleted", Entry("to-be-deleted", "DELETED".encodeToByteArray(), 0)) + memCache.put("to-be-deleted", Entry("to-be-deleted", "DELETED", 0)) + + val diskResult = diskCache.get("to-be-deleted") + assertNotNull(diskResult) + assertEquals("DELETED", diskResult.decodeToString()) + + assertTrue(diskCache.remove("to-be-deleted")) + assertFalse(diskCache.remove("unknown")) + assertFalse(diskCache.remove("to-be-deleted")) + + val memResult = memCache.get("to-be-deleted") + assertNotNull(memResult) + assertEquals("DELETED", memResult) + + assertTrue(memCache.remove("to-be-deleted")) + assertFalse(memCache.remove("unknown")) + assertFalse(memCache.remove("to-be-deleted")) + } + + @Test + fun `should be able to retrieve the timestamp`() { + diskCache.put("1", Entry("1", "foo bar".encodeToByteArray(), 1650012031)) + diskCache.put("2", Entry("2", "foo bar".encodeToByteArray(), 1650012032)) + + memCache.put("1", Entry("1", "foo bar", 1650012031)) + memCache.put("2", Entry("2", "foo bar", 1650012032)) + + val diskTimestamp1 = diskCache.getTimestamp("1") + val diskTimestamp2 = diskCache.getTimestamp("2") + val diskTimestamp3 = diskCache.getTimestamp("unknown") + + assertEquals(1650012031, diskTimestamp1) + assertEquals(1650012032, diskTimestamp2) + assertNull(diskTimestamp3) + + val memTimestamp1 = memCache.getTimestamp("1") + val memTimestamp2 = memCache.getTimestamp("2") + val memTimestamp3 = memCache.getTimestamp("unknown") + + assertEquals(1650012031, memTimestamp1) + assertEquals(1650012032, memTimestamp2) + assertNull(memTimestamp3) + } + + @Test + fun `should be able to list items in cache folder`() { + diskCache.removeAll() + memCache.removeAll() + + diskCache.put("1", Entry("1", "foo".encodeToByteArray(), 1)) + diskCache.put("2", Entry("2", "bar".encodeToByteArray(), 2)) + diskCache.put("3", Entry("3", "foo bar".encodeToByteArray(), 3)) + + memCache.put("1", Entry("1", "foo", 1)) + memCache.put("2", Entry("2", "bar", 2)) + memCache.put("3", Entry("3", "foo bar", 3)) + + val diskResult = diskCache.allKeys() + + assertTrue(diskResult.isNotEmpty()) + assertContains(diskResult, "1") + assertContains(diskResult, "2") + assertContains(diskResult, "3") + + val memResult = diskCache.allKeys() + + assertTrue(memResult.isNotEmpty()) + assertContains(memResult, "1") + assertContains(memResult, "2") + assertContains(memResult, "3") + } + + @Test + fun `should remove all item in the cache folder`() { + diskCache.put("1111", Entry("1234", "foo".encodeToByteArray(), 1)) + memCache.put("1111", Entry("1234", "foo", 1)) + + var diskResult = diskCache.allKeys() + assertContains(diskResult, "1234") // contains original key + + diskCache.removeAll() + diskResult = diskCache.allKeys() + assertTrue(diskResult.isEmpty()) + + var memResult = memCache.allKeys() + assertContains(memResult, "1234") // contains original key + + memCache.removeAll() + memResult = memCache.allKeys() + assertTrue(memResult.isEmpty()) + } +} diff --git a/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/ReadResource.kt b/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/ReadResource.kt new file mode 100644 index 0000000..0e22fb3 --- /dev/null +++ b/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/ReadResource.kt @@ -0,0 +1,3 @@ +package com.github.kittinunf.fuse.core + +expect fun readResource(name: String): ByteArray diff --git a/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/model/Product.kt b/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/model/Product.kt new file mode 100644 index 0000000..1d38d41 --- /dev/null +++ b/fuse-core/src/commonTest/kotlin/com/github/kittinunf/fuse/core/model/Product.kt @@ -0,0 +1,49 @@ +package com.github.kittinunf.fuse.core.model + +import kotlinx.serialization.Serializable + +@Serializable +data class Product( + val name: String = "", // Product + val properties: Properties = Properties() +) + +@Serializable +data class Properties( + val id: Id = Id(), + val name: Name = Name(), + val price: Price = Price(), + val tags: Tags = Tags() +) + +@Serializable +data class Id( + val type: String = "", // number + val description: String = "", // Product identifier + val required: Boolean = false // true +) + +@Serializable +data class Name( + val type: String = "", // string + val description: String = "", // Name of the product + val required: Boolean = false // true +) + +@Serializable +data class Price( + val type: String = "", // number + val minimum: Int = 0, // 0 + val required: Boolean = false // true +) + +@Serializable +data class Tags( + val items: Items = Items(), + val type: String = "" // array +) + +@Serializable +data class Items( + val type: String = "" // string +) diff --git a/fuse-core/src/commonTest/resources/another_sample.json b/fuse-core/src/commonTest/resources/another_sample.json new file mode 100644 index 0000000..42229c3 --- /dev/null +++ b/fuse-core/src/commonTest/resources/another_sample.json @@ -0,0 +1,26 @@ +{ + "name": "Another Product", + "properties": { + "id": { + "type": "number", + "description": "Another Product Identifier", + "required": true + }, + "name": { + "type": "string", + "description": "Name of the another product", + "required": true + }, + "price": { + "type": "number", + "minimum": 42, + "required": true + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/fuse-core/src/commonTest/resources/lorem_ipsum.txt b/fuse-core/src/commonTest/resources/lorem_ipsum.txt new file mode 100644 index 0000000..e11d451 --- /dev/null +++ b/fuse-core/src/commonTest/resources/lorem_ipsum.txt @@ -0,0 +1 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce euismod orci at libero sollicitudin, quis mollis nunc rutrum. Proin orci arcu, faucibus non lorem eu, euismod lobortis ligula. Curabitur vehicula lorem nec aliquam mollis. Donec sit amet ligula quis nisi ullamcorper mattis nec eget justo. Sed nec nulla eu nunc hendrerit mattis. Morbi commodo dapibus nibh, eget mattis odio ornare non. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Morbi sed tellus id magna viverra viverra. Ut dignissim quis ligula ac efficitur. Maecenas eleifend faucibus laoreet. Etiam at justo in nulla eleifend cursus. Sed ut molestie ante, nec consectetur turpis. Curabitur imperdiet pharetra mauris at iaculis. Proin a mauris ex. Duis leo nisl, viverra vel mauris eu, convallis ultricies nisl. Nullam vehicula ullamcorper erat vitae dictum. \ No newline at end of file diff --git a/fuse-core/src/commonTest/resources/sample.json b/fuse-core/src/commonTest/resources/sample.json new file mode 100644 index 0000000..8484508 --- /dev/null +++ b/fuse-core/src/commonTest/resources/sample.json @@ -0,0 +1,26 @@ +{ + "name": "Product", + "properties": { + "id": { + "type": "number", + "description": "Product Identifier", + "required": true + }, + "name": { + "type": "string", + "description": "Name of the product", + "required": true + }, + "price": { + "type": "number", + "minimum": 10, + "required": true + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/fuse-core/src/commonTest/resources/sample_song.mp3 b/fuse-core/src/commonTest/resources/sample_song.mp3 new file mode 100644 index 0000000..f040407 Binary files /dev/null and b/fuse-core/src/commonTest/resources/sample_song.mp3 differ diff --git a/fuse-core/src/iosMain/kotlin/com/github/kittinunf/fuse/core/IosConfig.kt b/fuse-core/src/iosMain/kotlin/com/github/kittinunf/fuse/core/IosConfig.kt new file mode 100644 index 0000000..9d65d06 --- /dev/null +++ b/fuse-core/src/iosMain/kotlin/com/github/kittinunf/fuse/core/IosConfig.kt @@ -0,0 +1,20 @@ +package com.github.kittinunf.fuse.core + +import com.github.kittinunf.fuse.core.formatter.JsonBinaryConverter +import com.github.kittinunf.fuse.core.persistence.IosDiskPersistence +import com.github.kittinunf.fuse.core.persistence.MemPersistence +import com.github.kittinunf.fuse.core.persistence.Persistence +import kotlinx.serialization.BinaryFormat +import kotlinx.serialization.KSerializer +import platform.Foundation.NSURL + +class IosConfig( + override val name: String, + val path: NSURL? = null, + override val serializer: KSerializer, + override val formatter: BinaryFormat = JsonBinaryConverter(), + override val diskCapacity: Long = 1024 * 1024 * 20, + override val transformer: (key: String, value: T) -> T = { _, value -> value }, + override val memCache: Persistence = MemPersistence(), + override val diskCache: Persistence = IosDiskPersistence(name, path) +) : Config diff --git a/fuse-core/src/iosMain/kotlin/com/github/kittinunf/fuse/core/IosFuse.kt b/fuse-core/src/iosMain/kotlin/com/github/kittinunf/fuse/core/IosFuse.kt new file mode 100644 index 0000000..b701711 --- /dev/null +++ b/fuse-core/src/iosMain/kotlin/com/github/kittinunf/fuse/core/IosFuse.kt @@ -0,0 +1,25 @@ +package com.github.kittinunf.fuse.core + +import com.github.kittinunf.fuse.core.fetcher.IosDiskFetcher +import com.github.kittinunf.result.Result +import kotlinx.serialization.serializer +import platform.Foundation.NSURL + +/** + * Get the entry associated as a content of a File in T with its particular key as path in [NSURL] format. If File is not there or too large, it returns as [Result.Failure] + * Otherwise, it returns [Result.Success] of data of a given file in T + * + * @param file The file object that represent file data on the disk + * @return Result The Result that represents the success/failure of the operation + */ +inline fun Cache.get(url: NSURL): Result = + get(IosDiskFetcher(url, serializer = serializersModule.serializer(), formatter = this)) + +/** + * Put the entry as a content of a in T with its key as path in [NSURL] format into Cache + * + * @param file The file object that represent file data on the disk + * @return Result The Result that represents the success/failure of the operation + */ +inline fun Cache.put(url: NSURL): Result = + put(IosDiskFetcher(url, serializer = serializersModule.serializer(), formatter = this)) diff --git a/fuse-core/src/iosMain/kotlin/com/github/kittinunf/fuse/core/Md5.kt b/fuse-core/src/iosMain/kotlin/com/github/kittinunf/fuse/core/Md5.kt new file mode 100644 index 0000000..fa88c48 --- /dev/null +++ b/fuse-core/src/iosMain/kotlin/com/github/kittinunf/fuse/core/Md5.kt @@ -0,0 +1,17 @@ +package com.github.kittinunf.fuse.core + +import kotlinx.cinterop.refTo +import platform.CoreCrypto.CC_MD5 +import platform.CoreCrypto.CC_MD5_DIGEST_LENGTH +import platform.Foundation.NSString +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.dataUsingEncoding +import platform.Foundation.stringWithFormat + +@OptIn(ExperimentalUnsignedTypes::class) +internal actual fun String.md5(): String { + val data = (this as NSString).dataUsingEncoding(NSUTF8StringEncoding) + val hash = UByteArray(CC_MD5_DIGEST_LENGTH) { 0u } + CC_MD5(data!!.bytes, length().toUInt(), hash.refTo(0)) + return hash.joinToString("") { NSString.stringWithFormat("%02hhx", it) } +} diff --git a/fuse-core/src/iosMain/kotlin/com/github/kittinunf/fuse/core/fetcher/IosDiskFetcher.kt b/fuse-core/src/iosMain/kotlin/com/github/kittinunf/fuse/core/fetcher/IosDiskFetcher.kt new file mode 100644 index 0000000..fbc7f4e --- /dev/null +++ b/fuse-core/src/iosMain/kotlin/com/github/kittinunf/fuse/core/fetcher/IosDiskFetcher.kt @@ -0,0 +1,41 @@ +package com.github.kittinunf.fuse.core.fetcher + +import com.github.kittinunf.fuse.core.formatter.JsonBinaryConverter +import com.github.kittinunf.fuse.core.persistence.toByteArray +import com.github.kittinunf.result.Result +import kotlinx.cinterop.ObjCObjectVar +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.value +import kotlinx.serialization.BinaryFormat +import kotlinx.serialization.KSerializer +import platform.Foundation.NSData +import platform.Foundation.NSError +import platform.Foundation.NSURL +import platform.Foundation.dataWithContentsOfURL + +class IosDiskFetcher( + private val url: NSURL, + private val serializer: KSerializer, + private val formatter: BinaryFormat = JsonBinaryConverter() +) : Fetcher, BinaryFormat by formatter { + + override val key: String = url.absoluteString!! + + override fun fetch(): Result { + if (!url.fileURL) return Result.failure(IllegalStateException("Given $url is not a File URL.")) + + memScoped { + val errorPtr = alloc>() + if (!url.checkResourceIsReachableAndReturnError(errorPtr.ptr)) return Result.failure(IllegalStateException("Given $url is unreachable. ${errorPtr.value}")) + } + + return Result.of { + val data = NSData.dataWithContentsOfURL(url) + val bytes = data?.toByteArray() ?: throw RuntimeException("Cannot read from file") + + decodeFromByteArray(serializer, bytes) + } + } +} diff --git a/fuse-core/src/iosMain/kotlin/com/github/kittinunf/fuse/core/persistence/IosDiskPersistence.kt b/fuse-core/src/iosMain/kotlin/com/github/kittinunf/fuse/core/persistence/IosDiskPersistence.kt new file mode 100644 index 0000000..fa23f70 --- /dev/null +++ b/fuse-core/src/iosMain/kotlin/com/github/kittinunf/fuse/core/persistence/IosDiskPersistence.kt @@ -0,0 +1,101 @@ +package com.github.kittinunf.fuse.core.persistence + +import com.github.kittinunf.fuse.core.formatter.JsonBinaryConverter +import com.github.kittinunf.fuse.core.model.Entry +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.convert +import kotlinx.cinterop.usePinned +import kotlinx.serialization.BinaryFormat +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import platform.Foundation.NSCachesDirectory +import platform.Foundation.NSData +import platform.Foundation.NSDirectoryEnumerationSkipsHiddenFiles +import platform.Foundation.NSFileManager +import platform.Foundation.NSURL +import platform.Foundation.NSUserDomainMask +import platform.Foundation.URLByAppendingPathComponent +import platform.Foundation.create +import platform.Foundation.dataWithContentsOfURL +import platform.Foundation.writeToURL +import platform.posix.memcpy + +class IosDiskPersistence(name: String, private var directory: NSURL? = null) : Persistence { + + private val fileManager = NSFileManager() + + private val formatter: BinaryFormat = JsonBinaryConverter() + + private val cacheDirectory + get() = directory!! + + init { + if (directory == null) { + val d = NSFileManager.defaultManager.URLsForDirectory(NSCachesDirectory, NSUserDomainMask).firstOrNull() as? NSURL + if (d == null) throw IllegalStateException("$d cannot be created") + directory = d.URLByAppendingPathComponent("${this::class.qualifiedName}.$name") + } + + fileManager.createDirectoryAtURL(url = cacheDirectory, withIntermediateDirectories = true, attributes = null, error = null) + } + + override fun put(safeKey: String, entry: Entry) { + val destination = getUrlForKey(safeKey) + val bytes = formatter.encodeToByteArray(entry) + val data = bytes.toData() + data.writeToURL(destination, atomically = true) + } + + override fun remove(safeKey: String): Boolean { + val destination = getUrlForKey(safeKey) + + if (!fileManager.fileExistsAtPath(destination.path!!)) return false + + val result = fileManager.removeItemAtURL(destination, null) + if (!result) throw RuntimeException("Cannot delete file at path: ${destination.relativePath}") + return result + } + + override fun removeAll() { + val urls = fileManager.contentsOfDirectoryAtURL( + url = cacheDirectory, + includingPropertiesForKeys = null, + options = NSDirectoryEnumerationSkipsHiddenFiles, + error = null + ) + if (urls.isNullOrEmpty()) return + + urls.forEach { fileManager.removeItemAtURL(it as NSURL, null) } + } + + override fun allKeys(): Set { + val urls = fileManager.contentsOfDirectoryAtURL( + url = cacheDirectory, + includingPropertiesForKeys = null, + options = NSDirectoryEnumerationSkipsHiddenFiles, + error = null + ) + if (urls.isNullOrEmpty()) return emptySet() + return urls.map { getEntry((it as NSURL))!!.key }.toSet() + } + + override fun get(safeKey: String): ByteArray? = getEntry(getUrlForKey(safeKey))?.data + + override fun getTimestamp(safeKey: String): Long? = getEntry(getUrlForKey(safeKey))?.timestamp + + private fun getUrlForKey(safeKey: String): NSURL = cacheDirectory.URLByAppendingPathComponent(safeKey) + ?: throw IllegalStateException("Cannot create NSURL destination for key: $safeKey") + + private fun getEntry(url: NSURL): Entry? { + if (!fileManager.fileExistsAtPath(url.path!!)) return null + val data = NSData.dataWithContentsOfURL(url) ?: throw RuntimeException("Cannot retrieve data at path: ${url.relativePath}") + val bytes = data.toByteArray() + return formatter.decodeFromByteArray(bytes) + } +} + +internal fun ByteArray.toData(): NSData = usePinned { NSData.create(bytes = it.addressOf(0), size.convert()) } + +internal fun NSData.toByteArray(): ByteArray = ByteArray(length.convert()).apply { + usePinned { memcpy(it.addressOf(0), bytes, length.convert()) } +} diff --git a/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/BaseTest.kt b/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/BaseTest.kt new file mode 100644 index 0000000..d5b6a41 --- /dev/null +++ b/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/BaseTest.kt @@ -0,0 +1,13 @@ +package com.github.kittinunf.fuse.core + +import kotlin.test.BeforeTest + +actual abstract class BaseTest { + + @BeforeTest + internal actual fun before() { + setUp(Unit) + } + + actual abstract fun setUp(any: Any) +} diff --git a/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/CreateByteTestCache.kt b/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/CreateByteTestCache.kt new file mode 100644 index 0000000..a77a056 --- /dev/null +++ b/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/CreateByteTestCache.kt @@ -0,0 +1,7 @@ +package com.github.kittinunf.fuse.core + +import kotlinx.serialization.builtins.ByteArraySerializer + +internal actual fun createByteTestCache(name: String, context: Any): Cache { + return IosConfig(name, path = null, serializer = ByteArraySerializer()).build() +} diff --git a/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/CreateJsonTestCache.kt b/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/CreateJsonTestCache.kt new file mode 100644 index 0000000..b3b6f75 --- /dev/null +++ b/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/CreateJsonTestCache.kt @@ -0,0 +1,7 @@ +package com.github.kittinunf.fuse.core + +import com.github.kittinunf.fuse.core.model.Product + +internal actual fun createJsonTestCache(name: String, context: Any): Cache { + return IosConfig(name, path = null, serializer = Product.serializer()).build() +} diff --git a/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/CreateStringTestCache.kt b/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/CreateStringTestCache.kt new file mode 100644 index 0000000..322cf10 --- /dev/null +++ b/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/CreateStringTestCache.kt @@ -0,0 +1,7 @@ +package com.github.kittinunf.fuse.core + +import kotlinx.serialization.builtins.serializer + +internal actual fun createStringTestCache(name: String, context: Any): Cache { + return IosConfig(name, path = null, serializer = String.serializer()).build() +} diff --git a/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/CreateTestPersistence.kt b/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/CreateTestPersistence.kt new file mode 100644 index 0000000..7ac1a89 --- /dev/null +++ b/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/CreateTestPersistence.kt @@ -0,0 +1,6 @@ +package com.github.kittinunf.fuse.core + +import com.github.kittinunf.fuse.core.persistence.IosDiskPersistence +import com.github.kittinunf.fuse.core.persistence.Persistence + +actual fun createTestDiskPersistence(context: Any): Persistence = IosDiskPersistence("test-cache") diff --git a/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/IosFuseCacheTest.kt b/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/IosFuseCacheTest.kt new file mode 100644 index 0000000..ba08bcf --- /dev/null +++ b/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/IosFuseCacheTest.kt @@ -0,0 +1,76 @@ +package com.github.kittinunf.fuse.core + +import com.github.kittinunf.fuse.core.formatter.StringBinaryConverter +import com.github.kittinunf.result.Result +import kotlinx.serialization.BinaryFormat +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule +import platform.Foundation.NSBundle +import platform.Foundation.NSURL +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull + +class IosFuseCacheTest { + + private val cache: Cache = + IosConfig("test-cache", path = null, serializer = ByteArraySerializer(), formatter = object : BinaryFormat { + override val serializersModule: SerializersModule = EmptySerializersModule + + override fun decodeFromByteArray(deserializer: DeserializationStrategy, bytes: ByteArray): T { + return bytes as T + } + + override fun encodeToByteArray(serializer: SerializationStrategy, value: T): ByteArray { + return value as ByteArray + } + + }).build() + + private val stringCache: Cache = + IosConfig("test-string-cache", path = null, serializer = String.serializer(), formatter = StringBinaryConverter()).build() + + @Test + fun `should fetch data from file correctly`() { + val fileUrl = getUrlFromResource("sample_song", "mp3") + + val result = cache.get(fileUrl) + assertIs>(result) + + val (value, _) = result + assertNotNull(value) + } + + @Test + fun `should put data from file as binary correctly`() { + val file = getUrlFromResource("sample_song", "mp3") + val result = cache.put(file) + assertIs>(result) + + val (value, _) = cache.get(file) + assertNotNull(value) + } + + @Test + fun `should fetch data from file as string correctly`() { + val file = getUrlFromResource("lorem_ipsum", "txt") + val result = stringCache.put(file) + assertIs>(result) + + val (value, _) = stringCache.get(file) + assertEquals( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce euismod orci at libero sollicitudin, quis mollis nunc rutrum. Proin orci arcu, faucibus non lorem eu, euismod lobortis ligula. Curabitur vehicula lorem nec aliquam mollis. Donec sit amet ligula quis nisi ullamcorper mattis nec eget justo. Sed nec nulla eu nunc hendrerit mattis. Morbi commodo dapibus nibh, eget mattis odio ornare non. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Morbi sed tellus id magna viverra viverra. Ut dignissim quis ligula ac efficitur. Maecenas eleifend faucibus laoreet. Etiam at justo in nulla eleifend cursus. Sed ut molestie ante, nec consectetur turpis. Curabitur imperdiet pharetra mauris at iaculis. Proin a mauris ex. Duis leo nisl, viverra vel mauris eu, convallis ultricies nisl. Nullam vehicula ullamcorper erat vitae dictum.", + value + ) + } + + private fun getUrlFromResource(name: String, type: String): NSURL { + val filePath = NSBundle.mainBundle.pathForResource(name, ofType = type, inDirectory = "resources") + return NSURL(fileURLWithPath = filePath!!) + } +} diff --git a/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/ReadResource.kt b/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/ReadResource.kt new file mode 100644 index 0000000..697d157 --- /dev/null +++ b/fuse-core/src/iosTest/kotlin/com/github/kittinunf/fuse/core/ReadResource.kt @@ -0,0 +1,13 @@ +package com.github.kittinunf.fuse.core + +import com.github.kittinunf.fuse.core.persistence.toByteArray +import platform.Foundation.NSBundle +import platform.Foundation.NSData +import platform.Foundation.dataWithContentsOfFile + +actual fun readResource(name: String): ByteArray { + val paths = name.split("[.|/]".toRegex()) + val path = NSBundle.mainBundle.pathForResource(paths[2], ofType = paths[3], inDirectory = "resources") + val data = NSData.dataWithContentsOfFile(path!!) + return data?.toByteArray() ?: ByteArray(0) +} diff --git a/fuse-core/src/jvmMain/kotlin/com/github/kittinunf/fuse/core/JvmConfig.kt b/fuse-core/src/jvmMain/kotlin/com/github/kittinunf/fuse/core/JvmConfig.kt new file mode 100644 index 0000000..4bfa636 --- /dev/null +++ b/fuse-core/src/jvmMain/kotlin/com/github/kittinunf/fuse/core/JvmConfig.kt @@ -0,0 +1,20 @@ +package com.github.kittinunf.fuse.core + +import com.github.kittinunf.fuse.core.formatter.JsonBinaryConverter +import com.github.kittinunf.fuse.core.persistence.JvmDiskPersistence +import com.github.kittinunf.fuse.core.persistence.MemPersistence +import com.github.kittinunf.fuse.core.persistence.Persistence +import kotlinx.serialization.BinaryFormat +import kotlinx.serialization.KSerializer +import java.io.File + +class JvmConfig( + override val name: String, + val path: File? = null, + override val serializer: KSerializer, + override val formatter: BinaryFormat = JsonBinaryConverter(), + override val diskCapacity: Long = 1024 * 1024 * 20, + override val transformer: (key: String, value: T) -> T = { _, value -> value }, + override val memCache: Persistence = MemPersistence(), + override val diskCache: Persistence = JvmDiskPersistence(name, path) +) : Config diff --git a/fuse-core/src/jvmMain/kotlin/com/github/kittinunf/fuse/core/JvmFuse.kt b/fuse-core/src/jvmMain/kotlin/com/github/kittinunf/fuse/core/JvmFuse.kt new file mode 100644 index 0000000..45d1868 --- /dev/null +++ b/fuse-core/src/jvmMain/kotlin/com/github/kittinunf/fuse/core/JvmFuse.kt @@ -0,0 +1,25 @@ +package com.github.kittinunf.fuse.core + +import com.github.kittinunf.fuse.core.fetcher.JvmDiskFetcher +import com.github.kittinunf.result.Result +import kotlinx.serialization.serializer +import java.io.File + +/** + * Get the entry associated as a Data of file content in T with its particular key as File path in [File] format. If File is not there or too large, it returns as [Result.Failure] + * Otherwise, it returns [Result.Success] of data of a given file in T + * + * @param file The file object that represent file data on the disk + * @return Result The Result that represents the success/failure of the operation + */ +inline fun Cache.get(file: File): Result = + get(JvmDiskFetcher(file, serializer = serializersModule.serializer(), format = this)) + +/** + * Put the entry as a content of a file into Cache + * + * @param file The file object that represent file data on the disk + * @return Result The Result that represents the success/failure of the operation + */ +inline fun Cache.put(file: File): Result = + put(JvmDiskFetcher(file, serializer = serializersModule.serializer(), format = this)) diff --git a/fuse-core/src/jvmMain/kotlin/com/github/kittinunf/fuse/core/Md5.kt b/fuse-core/src/jvmMain/kotlin/com/github/kittinunf/fuse/core/Md5.kt new file mode 100644 index 0000000..e8504e1 --- /dev/null +++ b/fuse-core/src/jvmMain/kotlin/com/github/kittinunf/fuse/core/Md5.kt @@ -0,0 +1,9 @@ +package com.github.kittinunf.fuse.core + +import java.security.MessageDigest + +internal actual fun String.md5(): String { + val md = MessageDigest.getInstance("MD5") + val digested = md.digest(toByteArray()) + return digested.joinToString("") { String.format("%02x", it) } +} diff --git a/fuse-core/src/jvmMain/kotlin/com/github/kittinunf/fuse/core/fetcher/JvmDiskFetcher.kt b/fuse-core/src/jvmMain/kotlin/com/github/kittinunf/fuse/core/fetcher/JvmDiskFetcher.kt new file mode 100644 index 0000000..83494e8 --- /dev/null +++ b/fuse-core/src/jvmMain/kotlin/com/github/kittinunf/fuse/core/fetcher/JvmDiskFetcher.kt @@ -0,0 +1,35 @@ +package com.github.kittinunf.fuse.core.fetcher + +import com.github.kittinunf.fuse.core.formatter.JsonBinaryConverter +import com.github.kittinunf.result.Result +import kotlinx.serialization.BinaryFormat +import kotlinx.serialization.KSerializer +import java.io.File + +class JvmDiskFetcher( + private val file: File, + private val serializer: KSerializer, + private val format: BinaryFormat = JsonBinaryConverter() +) : Fetcher, BinaryFormat by format { + + private var cancelled: Boolean = false + + override val key: String = file.absolutePath + + override fun fetch(): Result { + if (!file.isFile) return Result.failure(IllegalStateException("Given $file is not a File URL.")) + if (!file.exists()) return Result.failure(RuntimeException("Given $file is unreachable.")) + + return Result.of { + val bytes = file.inputStream().use { it.readBytes() } + if (cancelled) throw RuntimeException("Fetcher with $key got cancelled") + if (bytes.isEmpty()) throw RuntimeException("Cannot read from file") + + decodeFromByteArray(serializer, bytes) + } + } + + override fun cancel() { + cancelled = true + } +} diff --git a/fuse-core/src/jvmMain/kotlin/com/github/kittinunf/fuse/core/persistence/JvmDiskPersistence.kt b/fuse-core/src/jvmMain/kotlin/com/github/kittinunf/fuse/core/persistence/JvmDiskPersistence.kt new file mode 100644 index 0000000..6d20b1e --- /dev/null +++ b/fuse-core/src/jvmMain/kotlin/com/github/kittinunf/fuse/core/persistence/JvmDiskPersistence.kt @@ -0,0 +1,63 @@ +package com.github.kittinunf.fuse.core.persistence + +import com.github.kittinunf.fuse.core.formatter.JsonBinaryConverter +import com.github.kittinunf.fuse.core.model.Entry +import kotlinx.serialization.BinaryFormat +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import java.io.File + +class JvmDiskPersistence(name: String, private var path: File? = null) : Persistence { + + private val binaryFormat: BinaryFormat = JsonBinaryConverter() + + private val cacheDirectory + get() = path!! + + init { + if (path != null) { + require(path!!.isDirectory) { "Provided path must be directory" } + } else { + path = createTempDir(suffix = "").parentFile + } + // create folder inside with specific name + path = path?.resolve("${this::class.qualifiedName}.$name/")?.also { + if (!it.exists()) it.mkdir() + } + } + + override fun put(safeKey: String, entry: Entry) { + val file = createNewFileForKey(safeKey) + val serialized = binaryFormat.encodeToByteArray(entry) + file.writeBytes(serialized) + } + + override fun remove(safeKey: String): Boolean { + val destination = getFileForKey(safeKey) + if (destination.exists().not()) return false + + return destination.delete() + } + + override fun removeAll() { + cacheDirectory.listFiles()?.onEach(File::delete) + } + + override fun allKeys(): Set = cacheDirectory.walk() + .filter { it.isFile } + .map { getEntryForKey(it.name)!!.key }.toSet() + + override fun get(safeKey: String): ByteArray? = getEntryForKey(safeKey)?.data + + override fun getTimestamp(safeKey: String): Long? = getEntryForKey(safeKey)?.timestamp + + private fun getFileForKey(safeKey: String): File = cacheDirectory.resolve(safeKey) + + private fun createNewFileForKey(safeKey: String): File = getFileForKey(safeKey).also { it.createNewFile() } + + private fun getEntryForKey(safeKey: String): Entry? { + val file = getFileForKey(safeKey) + if (file.exists().not()) return null + return binaryFormat.decodeFromByteArray(file.readBytes()) + } +} diff --git a/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/BaseTest.kt b/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/BaseTest.kt new file mode 100644 index 0000000..ba2c7ff --- /dev/null +++ b/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/BaseTest.kt @@ -0,0 +1,13 @@ +package com.github.kittinunf.fuse.core + +import org.junit.Before + +actual abstract class BaseTest { + + @Before + actual fun before() { + setUp(Unit) + } + + actual abstract fun setUp(any: Any) +} diff --git a/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/CreateByteTestCache.kt b/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/CreateByteTestCache.kt new file mode 100644 index 0000000..521084f --- /dev/null +++ b/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/CreateByteTestCache.kt @@ -0,0 +1,7 @@ +package com.github.kittinunf.fuse.core + +import kotlinx.serialization.builtins.ByteArraySerializer + +internal actual fun createByteTestCache(name: String, context: Any): Cache { + return JvmConfig(name, path = createTempDir(suffix = "").parentFile, serializer = ByteArraySerializer()).build() +} diff --git a/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/CreateJsonTestCache.kt b/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/CreateJsonTestCache.kt new file mode 100644 index 0000000..7ed213d --- /dev/null +++ b/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/CreateJsonTestCache.kt @@ -0,0 +1,7 @@ +package com.github.kittinunf.fuse.core + +import com.github.kittinunf.fuse.core.model.Product + +internal actual fun createJsonTestCache(name: String, context: Any): Cache { + return JvmConfig(name, path = createTempDir(suffix = "").parentFile, serializer = Product.serializer()).build() +} diff --git a/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/CreateStringTestCache.kt b/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/CreateStringTestCache.kt new file mode 100644 index 0000000..0b3f373 --- /dev/null +++ b/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/CreateStringTestCache.kt @@ -0,0 +1,7 @@ +package com.github.kittinunf.fuse.core + +import kotlinx.serialization.builtins.serializer + +internal actual fun createStringTestCache(name: String, context: Any): Cache { + return JvmConfig(name, path = createTempDir(suffix = "").parentFile, serializer = String.serializer()).build() +} diff --git a/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/CreateTestPersistence.kt b/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/CreateTestPersistence.kt new file mode 100644 index 0000000..1bc2fea --- /dev/null +++ b/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/CreateTestPersistence.kt @@ -0,0 +1,6 @@ +package com.github.kittinunf.fuse.core + +import com.github.kittinunf.fuse.core.persistence.JvmDiskPersistence +import com.github.kittinunf.fuse.core.persistence.Persistence + +actual fun createTestDiskPersistence(context: Any): Persistence = JvmDiskPersistence("test-cache") diff --git a/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/JvmFuseCacheTest.kt b/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/JvmFuseCacheTest.kt new file mode 100644 index 0000000..9ff1a61 --- /dev/null +++ b/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/JvmFuseCacheTest.kt @@ -0,0 +1,73 @@ +package com.github.kittinunf.fuse.core + +import com.github.kittinunf.fuse.core.formatter.StringBinaryConverter +import com.github.kittinunf.result.Result +import junit.framework.Assert.assertNotNull +import kotlinx.serialization.BinaryFormat +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule +import org.junit.Test +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class JvmFuseCacheTest { + + private val cache: Cache = + JvmConfig("test-cache", path = null, serializer = ByteArraySerializer(), formatter = object : BinaryFormat { + override val serializersModule: SerializersModule = EmptySerializersModule + + override fun decodeFromByteArray(deserializer: DeserializationStrategy, bytes: ByteArray): T { + return bytes as T + } + + override fun encodeToByteArray(serializer: SerializationStrategy, value: T): ByteArray { + return value as ByteArray + } + + }).build() + + private val stringCache: Cache = + JvmConfig("test-string-cache", path = null, serializer = String.serializer(), formatter = StringBinaryConverter()).build() + + @Test + fun `should fetch data from file as binary correctly`() { + val file = getFileFromResource("./sample_song.mp3") + val result = cache.get(file) + assertIs>(result) + + val (value, _) = result + assertNotNull(value) + } + + @Test + fun `should put data from file as binary correctly`() { + val file = getFileFromResource("./sample_song.mp3") + val result = cache.put(file) + assertIs>(result) + + val (value, _) = cache.get(file) + assertNotNull(value) + } + + @Test + fun `should fetch data from file as string correctly`() { + val file = getFileFromResource("lorem_ipsum.txt") + val result = stringCache.put(file) + assertIs>(result) + + val (value, _) = stringCache.get(file) + assertEquals( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce euismod orci at libero sollicitudin, quis mollis nunc rutrum. Proin orci arcu, faucibus non lorem eu, euismod lobortis ligula. Curabitur vehicula lorem nec aliquam mollis. Donec sit amet ligula quis nisi ullamcorper mattis nec eget justo. Sed nec nulla eu nunc hendrerit mattis. Morbi commodo dapibus nibh, eget mattis odio ornare non. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Morbi sed tellus id magna viverra viverra. Ut dignissim quis ligula ac efficitur. Maecenas eleifend faucibus laoreet. Etiam at justo in nulla eleifend cursus. Sed ut molestie ante, nec consectetur turpis. Curabitur imperdiet pharetra mauris at iaculis. Proin a mauris ex. Duis leo nisl, viverra vel mauris eu, convallis ultricies nisl. Nullam vehicula ullamcorper erat vitae dictum.", + value + ) + } + + private fun getFileFromResource(name: String): File { + return File(ClassLoader.getSystemResource(name).file) + } +} diff --git a/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/ReadResource.kt b/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/ReadResource.kt new file mode 100644 index 0000000..da8064d --- /dev/null +++ b/fuse-core/src/jvmTest/kotlin/com/github/kittinunf/fuse/core/ReadResource.kt @@ -0,0 +1,5 @@ +package com.github.kittinunf.fuse.core + +actual fun readResource(name: String): ByteArray { + return ClassLoader.getSystemResourceAsStream(name).readBytes() +} diff --git a/gradle.properties b/gradle.properties index 06d4908..4e7c794 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,6 +14,8 @@ org.gradle.parallel=true android.enableJetifier=true android.useAndroidX=true +kotlin.mpp.enableCompatibilityMetadataVariant=true + artifactName=Fuse artifactDesc=The simple generic LRU memory/disk cache for Android written in Kotlin. artifactUserOrg=kittinunf diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7337078..c34fe04 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,17 +2,19 @@ kotlin = "1.6.20" androidGradle = "7.1.2" kotlinxSerialization = "1.3.2" +kotlinxTime = "0.3.2" diskCache = "2.0.2" result = "5.2.1" appCompat = "1.3.1" constraintLayout = "2.0.4" +androidTest = "1.4.0" jacoco = "0.8.7" junit = "4.13.1" -robolectric = "4.4" -minSdk = "24" -targetSdk = "30" +minSdk = "21" +targetSdk = "31" +compileSdk = "31" [libraries] kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } @@ -21,15 +23,20 @@ kotlin-test-common = { module = "org.jetbrains.kotlin:kotlin-test-common", versi kotlin-test-annotations = { module = "org.jetbrains.kotlin:kotlin-test-annotations-common", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerialization" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +kotlinx-time = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxTime" } diskCache = { module = "com.jakewharton:disklrucache", version.ref = "diskCache" } -result = { module = "com.github.kittinunf.result:result-jvm", version.ref = "result" } +result = { module = "com.github.kittinunf.result:result", version.ref = "result" } appCompat = { module = "androidx.appcompat:appcompat", version.ref = "appCompat" } constraintLayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintLayout" } +android-test-core = { module = "androidx.test:core", version.ref = "androidTest" } +android-test-runner = { module = "androidx.test.ext:junit", version = "1.1.0" } +robolectric = { module = "org.robolectric:robolectric", version = "4.7.3" } test-junit = { module = "junit:junit", version.ref = "junit" } -test-robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } [bundles] kotlin-test = ["kotlin-test-common", "kotlin-test-annotations"] +android-test = ["android-test-core", "android-test-runner", "robolectric"] diff --git a/settings.gradle.kts b/settings.gradle.kts index 5a91e88..659ebd8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,9 +5,10 @@ rootProject.name = "Fuse" includeBuild("plugins") include( - ":fuse", - ":fuse-android", - ":sample" +// ":fuse", + ":fuse-core", +// ":fuse-android", +// ":sample" ) pluginManagement { @@ -26,6 +27,7 @@ pluginManagement { .removeSurrounding("\"") plugins { + kotlin("multiplatform") version kotlinVersion kotlin("jvm") version kotlinVersion kotlin("android") version kotlinVersion kotlin("plugin.serialization") version kotlinVersion