From 2812ece255caad3934fc4d626858d6bc1bfa626b Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Fri, 14 Apr 2023 13:24:48 +0200 Subject: [PATCH] Transfer codebase --- .gitignore | 46 ++++ OpenFeature/.gitignore | 1 + OpenFeature/build.gradle.kts | 53 ++++ OpenFeature/proguard-rules.pro | 21 ++ OpenFeature/src/main/AndroidManifest.xml | 3 + .../dev/openfeature/sdk/BaseEvaluation.kt | 11 + .../main/java/dev/openfeature/sdk/Client.kt | 8 + .../dev/openfeature/sdk/EvaluationContext.kt | 10 + .../dev/openfeature/sdk/FeatureProvider.kt | 16 ++ .../main/java/dev/openfeature/sdk/Features.kt | 24 ++ .../openfeature/sdk/FlagEvaluationDetails.kt | 25 ++ .../openfeature/sdk/FlagEvaluationOptions.kt | 6 + .../java/dev/openfeature/sdk/FlagValueType.kt | 9 + .../src/main/java/dev/openfeature/sdk/Hook.kt | 9 + .../java/dev/openfeature/sdk/HookContext.kt | 10 + .../java/dev/openfeature/sdk/HookSupport.kt | 206 ++++++++++++++++ .../main/java/dev/openfeature/sdk/Metadata.kt | 5 + .../dev/openfeature/sdk/MutableContext.kt | 52 ++++ .../dev/openfeature/sdk/MutableStructure.kt | 52 ++++ .../java/dev/openfeature/sdk/NoOpProvider.kt | 53 ++++ .../dev/openfeature/sdk/OpenFeatureAPI.kt | 53 ++++ .../dev/openfeature/sdk/OpenFeatureClient.kt | 233 ++++++++++++++++++ .../dev/openfeature/sdk/ProviderEvaluation.kt | 11 + .../main/java/dev/openfeature/sdk/Reason.kt | 22 ++ .../java/dev/openfeature/sdk/Structure.kt | 12 + .../main/java/dev/openfeature/sdk/Value.kt | 73 ++++++ .../openfeature/sdk/exceptions/ErrorCode.kt | 18 ++ .../sdk/exceptions/OpenFeatureError.kt | 43 ++++ .../sdk/DeveloperExperienceTests.kt | 61 +++++ .../dev/openfeature/sdk/EvalContextTests.kt | 156 ++++++++++++ .../openfeature/sdk/FlagEvaluationsTests.kt | 132 ++++++++++ .../java/dev/openfeature/sdk/HookSpecTests.kt | 70 ++++++ .../dev/openfeature/sdk/HookSupportTests.kt | 56 +++++ .../openfeature/sdk/OpenFeatureClientTests.kt | 18 ++ .../dev/openfeature/sdk/ProviderSpecTests.kt | 63 +++++ .../dev/openfeature/sdk/StructureTests.kt | 54 ++++ .../java/dev/openfeature/sdk/ValueTests.kt | 139 +++++++++++ .../sdk/helpers/AlwaysBrokenProvider.kt | 54 ++++ .../sdk/helpers/DoSomethingProvider.kt | 52 ++++ .../sdk/helpers/GenericSpyHookMock.kt | 44 ++++ README.md | 4 +- build.gradle.kts | 6 + gradle.properties | 25 ++ settings.gradle.kts | 18 ++ 44 files changed, 2036 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 OpenFeature/.gitignore create mode 100644 OpenFeature/build.gradle.kts create mode 100644 OpenFeature/proguard-rules.pro create mode 100644 OpenFeature/src/main/AndroidManifest.xml create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/BaseEvaluation.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/Client.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/EvaluationContext.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/FeatureProvider.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/Features.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/FlagEvaluationDetails.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/FlagEvaluationOptions.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/FlagValueType.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/Hook.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/HookContext.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/HookSupport.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/Metadata.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/MutableContext.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/MutableStructure.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/NoOpProvider.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/OpenFeatureClient.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/ProviderEvaluation.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/Reason.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/Structure.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/Value.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/exceptions/ErrorCode.kt create mode 100644 OpenFeature/src/main/java/dev/openfeature/sdk/exceptions/OpenFeatureError.kt create mode 100644 OpenFeature/src/test/java/dev/openfeature/sdk/DeveloperExperienceTests.kt create mode 100644 OpenFeature/src/test/java/dev/openfeature/sdk/EvalContextTests.kt create mode 100644 OpenFeature/src/test/java/dev/openfeature/sdk/FlagEvaluationsTests.kt create mode 100644 OpenFeature/src/test/java/dev/openfeature/sdk/HookSpecTests.kt create mode 100644 OpenFeature/src/test/java/dev/openfeature/sdk/HookSupportTests.kt create mode 100644 OpenFeature/src/test/java/dev/openfeature/sdk/OpenFeatureClientTests.kt create mode 100644 OpenFeature/src/test/java/dev/openfeature/sdk/ProviderSpecTests.kt create mode 100644 OpenFeature/src/test/java/dev/openfeature/sdk/StructureTests.kt create mode 100644 OpenFeature/src/test/java/dev/openfeature/sdk/ValueTests.kt create mode 100644 OpenFeature/src/test/java/dev/openfeature/sdk/helpers/AlwaysBrokenProvider.kt create mode 100644 OpenFeature/src/test/java/dev/openfeature/sdk/helpers/DoSomethingProvider.kt create mode 100644 OpenFeature/src/test/java/dev/openfeature/sdk/helpers/GenericSpyHookMock.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..739e4e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + + +# Gradle files +.gradle/ +build/ +gradlew +gradlew.bat + +# Local configuration file (sdk path, etc) +local.properties + +# IntelliJ +*.iml +.idea/ +misc.xml +deploymentTargetDropDown.xml +render.experimental.xml + +# Android Profiling +*.hprof + +# Other +*.DS_Store \ No newline at end of file diff --git a/OpenFeature/.gitignore b/OpenFeature/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/OpenFeature/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/OpenFeature/build.gradle.kts b/OpenFeature/build.gradle.kts new file mode 100644 index 0000000..64b422c --- /dev/null +++ b/OpenFeature/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("maven-publish") + kotlin("plugin.serialization") version "1.8.10" +} + +android { + namespace = "dev.openfeature.sdk" + compileSdk = 33 + + defaultConfig { + minSdk = 26 + version = "0.0.1" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } +} + +dependencies { + implementation("com.google.android.material:material:1.8.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1") + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") +} + +publishing { + publications { + register("release") { + groupId = "dev.openfeature" + artifactId = "kotlin-sdk" + version = "0.0.1-SNAPSHOT" + + afterEvaluate { + from(components["release"]) + } + } + } +} diff --git a/OpenFeature/proguard-rules.pro b/OpenFeature/proguard-rules.pro new file mode 100644 index 0000000..ff59496 --- /dev/null +++ b/OpenFeature/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/OpenFeature/src/main/AndroidManifest.xml b/OpenFeature/src/main/AndroidManifest.xml new file mode 100644 index 0000000..69fc412 --- /dev/null +++ b/OpenFeature/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/BaseEvaluation.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/BaseEvaluation.kt new file mode 100644 index 0000000..4aa2574 --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/BaseEvaluation.kt @@ -0,0 +1,11 @@ +package dev.openfeature.sdk + +import dev.openfeature.sdk.exceptions.ErrorCode + +interface BaseEvaluation { + val value: T + val variant: String? + val reason: String? + val errorCode: ErrorCode? + val errorMessage: String? +} diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/Client.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/Client.kt new file mode 100644 index 0000000..befb176 --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/Client.kt @@ -0,0 +1,8 @@ +package dev.openfeature.sdk + +interface Client: Features { + val metadata: Metadata + val hooks: List> + + fun addHooks(hooks: List>) +} \ No newline at end of file diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/EvaluationContext.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/EvaluationContext.kt new file mode 100644 index 0000000..fcd0da8 --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/EvaluationContext.kt @@ -0,0 +1,10 @@ +package dev.openfeature.sdk + +interface EvaluationContext: Structure { + fun getTargetingKey(): String + fun setTargetingKey(targetingKey: String) + + // Make sure these are implemented for correct object comparisons + override fun hashCode(): Int + override fun equals(other: Any?): Boolean +} diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/FeatureProvider.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/FeatureProvider.kt new file mode 100644 index 0000000..a7efcee --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/FeatureProvider.kt @@ -0,0 +1,16 @@ +package dev.openfeature.sdk + +interface FeatureProvider { + val hooks: List> + val metadata: Metadata + + // Called by OpenFeatureAPI whenever the new Provider is registered + suspend fun initialize(initialContext: EvaluationContext?) + // Called by OpenFeatureAPI whenever a new EvaluationContext is set by the application + suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) + fun getBooleanEvaluation(key: String, defaultValue: Boolean): ProviderEvaluation + fun getStringEvaluation(key: String, defaultValue: String): ProviderEvaluation + fun getIntegerEvaluation(key: String, defaultValue: Int): ProviderEvaluation + fun getDoubleEvaluation(key: String, defaultValue: Double): ProviderEvaluation + fun getObjectEvaluation(key: String, defaultValue: Value): ProviderEvaluation +} \ No newline at end of file diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/Features.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/Features.kt new file mode 100644 index 0000000..62b3784 --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/Features.kt @@ -0,0 +1,24 @@ +package dev.openfeature.sdk + +interface Features { + fun getBooleanValue(key: String, defaultValue: Boolean): Boolean + fun getBooleanValue(key: String, defaultValue: Boolean, options: FlagEvaluationOptions): Boolean + fun getBooleanDetails(key: String, defaultValue: Boolean): FlagEvaluationDetails + fun getBooleanDetails(key: String, defaultValue: Boolean, options: FlagEvaluationOptions): FlagEvaluationDetails + fun getStringValue(key: String, defaultValue: String): String + fun getStringValue(key: String, defaultValue: String, options: FlagEvaluationOptions): String + fun getStringDetails(key: String, defaultValue: String): FlagEvaluationDetails + fun getStringDetails(key: String, defaultValue: String, options: FlagEvaluationOptions): FlagEvaluationDetails + fun getIntegerValue(key: String, defaultValue: Int): Int + fun getIntegerValue(key: String, defaultValue: Int, options: FlagEvaluationOptions): Int + fun getIntegerDetails(key: String, defaultValue: Int): FlagEvaluationDetails + fun getIntegerDetails(key: String, defaultValue: Int, options: FlagEvaluationOptions): FlagEvaluationDetails + fun getDoubleValue(key: String, defaultValue: Double): Double + fun getDoubleValue(key: String, defaultValue: Double, options: FlagEvaluationOptions): Double + fun getDoubleDetails(key: String, defaultValue: Double): FlagEvaluationDetails + fun getDoubleDetails(key: String, defaultValue: Double, options: FlagEvaluationOptions): FlagEvaluationDetails + fun getObjectValue(key: String, defaultValue: Value): Value + fun getObjectValue(key: String, defaultValue: Value, options: FlagEvaluationOptions): Value + fun getObjectDetails(key: String, defaultValue: Value): FlagEvaluationDetails + fun getObjectDetails(key: String, defaultValue: Value, options: FlagEvaluationOptions): FlagEvaluationDetails +} \ No newline at end of file diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/FlagEvaluationDetails.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/FlagEvaluationDetails.kt new file mode 100644 index 0000000..623bb12 --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/FlagEvaluationDetails.kt @@ -0,0 +1,25 @@ +package dev.openfeature.sdk + +import dev.openfeature.sdk.exceptions.ErrorCode + +data class FlagEvaluationDetails( + val flagKey: String, + override var value: T, + override var variant: String? = null, + override var reason: String? = null, + override var errorCode: ErrorCode? = null, + override var errorMessage: String? = null +) : BaseEvaluation { + companion object +} + +fun FlagEvaluationDetails.Companion.from(providerEval: ProviderEvaluation, flagKey: String): FlagEvaluationDetails { + return FlagEvaluationDetails( + flagKey, + providerEval.value, + providerEval.variant, + providerEval.reason, + providerEval.errorCode, + providerEval.errorMessage + ) +} \ No newline at end of file diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/FlagEvaluationOptions.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/FlagEvaluationOptions.kt new file mode 100644 index 0000000..94659df --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/FlagEvaluationOptions.kt @@ -0,0 +1,6 @@ +package dev.openfeature.sdk + +data class FlagEvaluationOptions( + var hooks: List> = listOf(), + var hookHints: Map = mapOf() +) diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/FlagValueType.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/FlagValueType.kt new file mode 100644 index 0000000..b463140 --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/FlagValueType.kt @@ -0,0 +1,9 @@ +package dev.openfeature.sdk +enum class FlagValueType { + BOOLEAN, + STRING, + INTEGER, + DOUBLE, + OBJECT; +} + diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/Hook.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/Hook.kt new file mode 100644 index 0000000..4b705e5 --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/Hook.kt @@ -0,0 +1,9 @@ +package dev.openfeature.sdk + +interface Hook { + fun before(ctx: HookContext, hints: Map) = Unit + fun after(ctx: HookContext, details: FlagEvaluationDetails, hints: Map) = Unit + fun error(ctx: HookContext, error: Exception, hints: Map) = Unit + fun finallyAfter(ctx: HookContext, hints: Map) = Unit + fun supportsFlagValueType(flagValueType: FlagValueType): Boolean = true +} \ No newline at end of file diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/HookContext.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/HookContext.kt new file mode 100644 index 0000000..7836784 --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/HookContext.kt @@ -0,0 +1,10 @@ +package dev.openfeature.sdk + +data class HookContext( + var flagKey: String, + val type: FlagValueType, + var defaultValue: T, + var ctx: EvaluationContext, + var clientMetadata: Metadata?, + var providerMetadata: Metadata +) diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/HookSupport.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/HookSupport.kt new file mode 100644 index 0000000..a10d015 --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/HookSupport.kt @@ -0,0 +1,206 @@ +package dev.openfeature.sdk + +@Suppress("UNCHECKED_CAST") // TODO can we do better here? +class HookSupport { + fun beforeHooks(flagValueType: FlagValueType, hookCtx: HookContext, hooks: List>, hints: Map) { + hooks + .reversed() + .filter { hook -> hook.supportsFlagValueType(flagValueType) } + .forEach { hook -> + when (flagValueType) { + FlagValueType.BOOLEAN -> { + safeLet(hook as? Hook, hookCtx as? HookContext) { booleanHook, booleanCtx -> + booleanHook.before(booleanCtx, hints) + } + } + FlagValueType.STRING -> { + safeLet(hook as? Hook, hookCtx as? HookContext) { stringHook, stringCtx -> + stringHook.before(stringCtx, hints) + } + } + FlagValueType.INTEGER -> { + safeLet(hook as? Hook, hookCtx as? HookContext) { integerHook, integerCtx -> + integerHook.before(integerCtx, hints) + } + } + FlagValueType.DOUBLE -> { + safeLet(hook as? Hook, hookCtx as? HookContext) { doubleHook, doubleCtx -> + doubleHook.before(doubleCtx, hints) + } + } + FlagValueType.OBJECT -> { + safeLet(hook as? Hook, hookCtx as? HookContext) { objectHook, objectCtx -> + objectHook.before(objectCtx, hints) + } + } + } + } + } + + fun afterHooks(flagValueType: FlagValueType, hookCtx: HookContext, details: FlagEvaluationDetails, hooks: List>, hints: Map) { + hooks + .filter { hook -> hook.supportsFlagValueType(flagValueType) } + .forEach { hook -> + run { + when (flagValueType) { + FlagValueType.BOOLEAN -> { + safeLet( + hook as? Hook, + hookCtx as? HookContext, + details as? FlagEvaluationDetails + ) { booleanHook, booleanCtx, booleanDetails -> + booleanHook.after(booleanCtx, booleanDetails, hints) + } + } + FlagValueType.STRING -> { + safeLet( + hook as? Hook, + hookCtx as? HookContext, + details as? FlagEvaluationDetails + ) { stringHook, stringCtx, stringDetails -> + stringHook.after(stringCtx, stringDetails, hints) + } + } + FlagValueType.INTEGER -> { + safeLet( + hook as? Hook, + hookCtx as? HookContext, + details as? FlagEvaluationDetails + ) { integerHook, integerCtx, integerDetails -> + integerHook.after(integerCtx, integerDetails, hints) + } + } + FlagValueType.DOUBLE -> { + safeLet( + hook as? Hook, + hookCtx as? HookContext, + details as? FlagEvaluationDetails + ) { doubleHook, doubleCtx, doubleDetails -> + doubleHook.after(doubleCtx, doubleDetails, hints) + } + } + FlagValueType.OBJECT -> { + safeLet( + hook as? Hook, + hookCtx as? HookContext, + details as? FlagEvaluationDetails + ) { objectHook, objectCtx, objectDetails -> + objectHook.after(objectCtx, objectDetails, hints) + } + } + } + } + } + } + + fun afterAllHooks(flagValueType: FlagValueType, hookCtx: HookContext, hooks: List>, hints: Map) { + hooks + .filter { hook -> hook.supportsFlagValueType(flagValueType) } + .forEach { hook -> + run { + when (flagValueType) { + FlagValueType.BOOLEAN -> { + safeLet( + hook as? Hook, + hookCtx as? HookContext + ) { booleanHook, booleanCtx -> + booleanHook.finallyAfter(booleanCtx, hints) + } + } + FlagValueType.STRING -> { + safeLet( + hook as? Hook, + hookCtx as? HookContext + ) { stringHook, stringCtx -> + stringHook.finallyAfter(stringCtx, hints) + } + } + FlagValueType.INTEGER -> { + safeLet( + hook as? Hook, + hookCtx as? HookContext + ) { integerHook, integerCtx -> + integerHook.finallyAfter(integerCtx, hints) + } + } + FlagValueType.DOUBLE -> { + safeLet( + hook as? Hook, + hookCtx as? HookContext + ) { doubleHook, doubleCtx -> + doubleHook.finallyAfter(doubleCtx, hints) + } + } + FlagValueType.OBJECT -> { + safeLet( + hook as? Hook, + hookCtx as? HookContext + ) { objectHook, objectCtx -> + objectHook.finallyAfter(objectCtx, hints) + } + } + } + } + } + } + + fun errorHooks(flagValueType: FlagValueType, hookCtx: HookContext, error: Exception, hooks: List>, hints: Map) { + hooks + .filter { hook -> hook.supportsFlagValueType(flagValueType) } + .forEach { hook -> + run { + when (flagValueType) { + FlagValueType.BOOLEAN -> { + safeLet( + hook as? Hook, + hookCtx as? HookContext + ) { booleanHook, booleanCtx -> + booleanHook.error(booleanCtx, error, hints) + } + } + FlagValueType.STRING -> { + safeLet( + hook as? Hook, + hookCtx as? HookContext + ) { stringHook, stringCtx -> + stringHook.error(stringCtx, error, hints) + } + } + FlagValueType.INTEGER -> { + safeLet( + hook as? Hook, + hookCtx as? HookContext + ) { integerHook, integerCtx -> + integerHook.error(integerCtx, error, hints) + } + } + FlagValueType.DOUBLE -> { + safeLet( + hook as? Hook, + hookCtx as? HookContext + ) { doubleHook, doubleCtx -> + doubleHook.error(doubleCtx, error, hints) + } + } + FlagValueType.OBJECT -> { + safeLet( + hook as? Hook, + hookCtx as? HookContext + ) { objectHook, objectCtx -> + objectHook.error(objectCtx, error, hints) + } + } + } + } + } + } + + + private inline fun safeLet(p1: T1?, p2: T2?, block: (T1, T2)->R?): R? { + return if (p1 != null && p2 != null) block(p1, p2) else null + } + + private inline fun safeLet(p1: T1?, p2: T2?, p3: T3?, block: (T1, T2, T3)->R?): R? { + return if (p1 != null && p2 != null && p3 != null) block(p1, p2, p3) else null + } +} \ No newline at end of file diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/Metadata.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/Metadata.kt new file mode 100644 index 0000000..8fcc170 --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/Metadata.kt @@ -0,0 +1,5 @@ +package dev.openfeature.sdk + +interface Metadata { + val name: String? +} \ No newline at end of file diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/MutableContext.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/MutableContext.kt new file mode 100644 index 0000000..c7e54f7 --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/MutableContext.kt @@ -0,0 +1,52 @@ +package dev.openfeature.sdk + +class MutableContext + (private var targetingKey: String = "", attributes: MutableMap = mutableMapOf()) : EvaluationContext { + private var structure: MutableStructure = MutableStructure(attributes) + override fun getTargetingKey(): String { + return targetingKey + } + + override fun setTargetingKey(targetingKey: String) { + this.targetingKey = targetingKey + } + + override fun keySet(): Set { + return structure.keySet() + } + + override fun getValue(key: String): Value? { + return structure.getValue(key) + } + + override fun asMap(): MutableMap { + return structure.asMap() + } + + override fun asObjectMap(): Map { + return structure.asObjectMap() + } + + fun add(key: String, value: Value): MutableContext { + structure.add(key, value) + return this + } + + override fun hashCode(): Int { + var result = targetingKey.hashCode() + result = 31 * result + structure.hashCode() + return result + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MutableContext + + if (targetingKey != other.targetingKey) return false + if (structure != other.structure) return false + + return true + } +} \ No newline at end of file diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/MutableStructure.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/MutableStructure.kt new file mode 100644 index 0000000..7fff8db --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/MutableStructure.kt @@ -0,0 +1,52 @@ +package dev.openfeature.sdk + +class MutableStructure(private var attributes: MutableMap = mutableMapOf()) : Structure { + override fun keySet(): Set { + return attributes.keys + } + + override fun getValue(key: String): Value? { + return attributes[key] + } + + override fun asMap(): MutableMap { + return attributes + } + + override fun asObjectMap(): Map { + return attributes.mapValues { convertValue(it.value) } + } + + fun add(key: String, value: Value): MutableStructure { + attributes[key] = value + return this + } + + private fun convertValue(value: Value): Any? { + return when(value) { + is Value.List -> value.list.map { t -> convertValue(t) } + is Value.Structure -> value.structure.mapValues { t -> convertValue(t.value) } + is Value.Null -> return null + is Value.String -> value.asString() + is Value.Boolean -> value.asBoolean() + is Value.Integer -> value.asInteger() + is Value.Instant -> value.asInstant() + is Value.Double -> value.asDouble() + } + } + + override fun hashCode(): Int { + return attributes.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MutableStructure + + if (attributes != other.attributes) return false + + return true + } +} diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/NoOpProvider.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/NoOpProvider.kt new file mode 100644 index 0000000..23dbfb3 --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/NoOpProvider.kt @@ -0,0 +1,53 @@ +package dev.openfeature.sdk + +class NoOpProvider : FeatureProvider { + override var metadata: Metadata = NoOpMetadata("No-op provider") + override suspend fun initialize(initialContext: EvaluationContext?) { + // no-op + } + + override suspend fun onContextSet( + oldContext: EvaluationContext?, + newContext: EvaluationContext + ) { + // no-op + } + + override var hooks: List> = listOf() + override fun getBooleanEvaluation( + key: String, + defaultValue: Boolean + ): ProviderEvaluation { + return ProviderEvaluation(defaultValue, "Passed in default", Reason.DEFAULT.toString()) + } + + override fun getStringEvaluation( + key: String, + defaultValue: String + ): ProviderEvaluation { + return ProviderEvaluation(defaultValue, "Passed in default", Reason.DEFAULT.toString()) + } + + override fun getIntegerEvaluation( + key: String, + defaultValue: Int + ): ProviderEvaluation { + return ProviderEvaluation(defaultValue, "Passed in default", Reason.DEFAULT.toString()) + } + + override fun getDoubleEvaluation( + key: String, + defaultValue: Double + ): ProviderEvaluation { + return ProviderEvaluation(defaultValue, "Passed in default", Reason.DEFAULT.toString()) + } + + override fun getObjectEvaluation( + key: String, + defaultValue: Value + ): ProviderEvaluation { + return ProviderEvaluation(defaultValue, "Passed in default", Reason.DEFAULT.toString()) + } + + data class NoOpMetadata(override var name: String?) : Metadata +} \ No newline at end of file diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.kt new file mode 100644 index 0000000..3cbce8e --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.kt @@ -0,0 +1,53 @@ +package dev.openfeature.sdk + +import kotlinx.coroutines.coroutineScope + +object OpenFeatureAPI { + private var provider: FeatureProvider? = null + private var context: EvaluationContext? = null + var hooks: List> = listOf() + private set + + suspend fun setProvider(provider: FeatureProvider, initialContext: EvaluationContext? = null) = coroutineScope { + provider.initialize(initialContext ?: context) + this@OpenFeatureAPI.provider = provider + if (initialContext != null) context = initialContext + } + + fun getProvider(): FeatureProvider? { + return provider + } + + fun clearProvider() { + provider = null + } + + suspend fun setEvaluationContext(evaluationContext: EvaluationContext) { + getProvider()?.onContextSet(context, evaluationContext) + // A provider evaluation reading the global ctx at this point would fail due to stale cache. + // To prevent this, the provider should internally manage the ctx to use on each evaluation, and + // make sure it's aligned with the values in the cache at all times. If no guarantees are offered by + // the provider, the application can expect STALE resolves while setting a new global ctx + context = evaluationContext + } + + fun getEvaluationContext(): EvaluationContext? { + return context + } + + fun getProviderMetadata(): Metadata? { + return provider?.metadata + } + + fun getClient(name: String? = null, version: String? = null): Client { + return OpenFeatureClient(this, name, version) + } + + fun addHooks(hooks: List>) { + this.hooks += hooks + } + + fun clearHooks() { + this.hooks = listOf() + } +} \ No newline at end of file diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/OpenFeatureClient.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/OpenFeatureClient.kt new file mode 100644 index 0000000..9a2e5ef --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/OpenFeatureClient.kt @@ -0,0 +1,233 @@ +package dev.openfeature.sdk + +import dev.openfeature.sdk.FlagValueType.* +import dev.openfeature.sdk.exceptions.ErrorCode +import dev.openfeature.sdk.exceptions.OpenFeatureError +import dev.openfeature.sdk.exceptions.OpenFeatureError.GeneralError + +private val typeMatchingException = GeneralError("Unable to match default value type with flag value type") + +class OpenFeatureClient( + private val openFeatureAPI: OpenFeatureAPI, + name: String? = null, + version: String? = null, + override val hooks: MutableList> = mutableListOf() +) : Client { + override val metadata: Metadata = ClientMetadata(name) + private var hookSupport = HookSupport() + override fun addHooks(hooks: List>) { + this.hooks += hooks + } + + override fun getBooleanValue(key: String, defaultValue: Boolean): Boolean { + return getBooleanDetails(key, defaultValue).value + } + + override fun getBooleanValue( + key: String, + defaultValue: Boolean, + options: FlagEvaluationOptions + ): Boolean { + return getBooleanDetails(key, defaultValue, options).value + } + + override fun getBooleanDetails( + key: String, + defaultValue: Boolean + ): FlagEvaluationDetails { + return getBooleanDetails(key, defaultValue, FlagEvaluationOptions()) + } + + override fun getBooleanDetails( + key: String, + defaultValue: Boolean, + options: FlagEvaluationOptions + ): FlagEvaluationDetails { + return evaluateFlag(BOOLEAN, key, defaultValue, options) + } + + override fun getStringValue(key: String, defaultValue: String): String { + return getStringDetails(key, defaultValue).value + } + + override fun getStringValue( + key: String, + defaultValue: String, + options: FlagEvaluationOptions + ): String { + return getStringDetails(key, defaultValue, options).value + } + + override fun getStringDetails( + key: String, + defaultValue: String, + ): FlagEvaluationDetails { + return getStringDetails(key, defaultValue, FlagEvaluationOptions()) + } + + override fun getStringDetails( + key: String, + defaultValue: String, + options: FlagEvaluationOptions + ): FlagEvaluationDetails { + return evaluateFlag(STRING, key, defaultValue, options) + } + + override fun getIntegerValue(key: String, defaultValue: Int): Int { + return getIntegerDetails(key, defaultValue).value + } + + override fun getIntegerValue( + key: String, + defaultValue: Int, + options: FlagEvaluationOptions + ): Int { + return getIntegerDetails(key, defaultValue, options).value + } + + override fun getIntegerDetails( + key: String, + defaultValue: Int + ): FlagEvaluationDetails { + return getIntegerDetails(key, defaultValue, FlagEvaluationOptions()) + } + + override fun getIntegerDetails( + key: String, + defaultValue: Int, + options: FlagEvaluationOptions + ): FlagEvaluationDetails { + return evaluateFlag(INTEGER, key, defaultValue, options) + } + + override fun getDoubleValue(key: String, defaultValue: Double): Double { + return getDoubleDetails(key, defaultValue).value + } + + override fun getDoubleValue( + key: String, + defaultValue: Double, + options: FlagEvaluationOptions + ): Double { + return getDoubleDetails(key, defaultValue, options).value + } + + override fun getDoubleDetails( + key: String, + defaultValue: Double + ): FlagEvaluationDetails { + return evaluateFlag(DOUBLE, key, defaultValue, FlagEvaluationOptions()) + } + + override fun getDoubleDetails( + key: String, + defaultValue: Double, + options: FlagEvaluationOptions + ): FlagEvaluationDetails { + return evaluateFlag(DOUBLE, key, defaultValue, options) + } + + override fun getObjectValue(key: String, defaultValue: Value): Value { + return getObjectDetails(key, defaultValue).value + } + + override fun getObjectValue( + key: String, + defaultValue: Value, + options: FlagEvaluationOptions + ): Value { + return getObjectDetails(key, defaultValue, options).value + } + + override fun getObjectDetails( + key: String, + defaultValue: Value, + ): FlagEvaluationDetails { + return getObjectDetails(key, defaultValue, FlagEvaluationOptions()) + } + + override fun getObjectDetails( + key: String, + defaultValue: Value, + options: FlagEvaluationOptions + ): FlagEvaluationDetails { + return evaluateFlag(OBJECT, key, defaultValue, options) + } + + private fun evaluateFlag( + flagValueType: FlagValueType, + key: String, + defaultValue: T, + optionsIn: FlagEvaluationOptions? + ): FlagEvaluationDetails { + val options = optionsIn ?: FlagEvaluationOptions(listOf(), mapOf()) + val hints = options.hookHints + var details = FlagEvaluationDetails(key, defaultValue) + val provider = openFeatureAPI.getProvider() ?: NoOpProvider() + val mergedHooks: List> = provider.hooks + options.hooks + hooks + openFeatureAPI.hooks + val context = openFeatureAPI.getEvaluationContext() ?: MutableContext() + val hookCtx: HookContext = HookContext(key, flagValueType, defaultValue, context, this.metadata, provider.metadata) + try { + hookSupport.beforeHooks(flagValueType, hookCtx, mergedHooks, hints) + val providerEval = createProviderEvaluation( + flagValueType, + key, + defaultValue, + provider + ) + details = FlagEvaluationDetails.from(providerEval, key) + hookSupport.afterHooks(flagValueType, hookCtx, details, mergedHooks, hints) + } catch (error: Exception) { + if (error is OpenFeatureError) { + details.errorCode = error.errorCode() + } else { + details.errorCode = ErrorCode.GENERAL + } + + details.errorMessage = error.message + details.reason = Reason.ERROR.toString() + + hookSupport.errorHooks(flagValueType, hookCtx, error, mergedHooks, hints) + } + hookSupport.afterAllHooks(flagValueType, hookCtx, mergedHooks, hints) + return details + } + + @Suppress("UNCHECKED_CAST") // TODO can we do better here? + private fun createProviderEvaluation( + flagValueType: FlagValueType, + key: String, + defaultValue: V, + provider: FeatureProvider + ): ProviderEvaluation { + return when(flagValueType) { + BOOLEAN -> { + val defaultBoolean = defaultValue as? Boolean ?: throw typeMatchingException + val eval: ProviderEvaluation = provider.getBooleanEvaluation(key, defaultBoolean) + eval as? ProviderEvaluation ?: throw typeMatchingException + } + STRING -> { + val defaultString = defaultValue as? String ?: throw typeMatchingException + val eval: ProviderEvaluation = provider.getStringEvaluation(key, defaultString) + eval as? ProviderEvaluation ?: throw typeMatchingException + } + INTEGER -> { + val defaultInteger = defaultValue as? Int ?: throw typeMatchingException + val eval: ProviderEvaluation = provider.getIntegerEvaluation(key, defaultInteger) + eval as? ProviderEvaluation ?: throw typeMatchingException + } + DOUBLE -> { + val defaultDouble = defaultValue as? Double ?: throw typeMatchingException + val eval: ProviderEvaluation = provider.getDoubleEvaluation(key, defaultDouble) + eval as? ProviderEvaluation ?: throw typeMatchingException + } + OBJECT -> { + val defaultObject = defaultValue as? Value ?: throw typeMatchingException + val eval: ProviderEvaluation = provider.getObjectEvaluation(key, defaultObject) + eval as? ProviderEvaluation ?: throw typeMatchingException + } + } + } + + data class ClientMetadata(override var name: String?) : Metadata +} \ No newline at end of file diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/ProviderEvaluation.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/ProviderEvaluation.kt new file mode 100644 index 0000000..082c160 --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/ProviderEvaluation.kt @@ -0,0 +1,11 @@ +package dev.openfeature.sdk + +import dev.openfeature.sdk.exceptions.ErrorCode + +data class ProviderEvaluation( + var value: T, + var variant: String? = null, + var reason: String? = null, + var errorCode: ErrorCode? = null, + var errorMessage: String? = null + ) diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/Reason.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/Reason.kt new file mode 100644 index 0000000..dfca1a9 --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/Reason.kt @@ -0,0 +1,22 @@ +package dev.openfeature.sdk + +enum class Reason { + // The resolved value is static (no dynamic evaluation). + STATIC, + // The resolved value fell back to a pre-configured value (no dynamic evaluation occurred or dynamic evaluation yielded no result). + DEFAULT, + // The resolved value was the result of a dynamic evaluation, such as a rule or specific user-targeting. + TARGETING_MATCH, + // The resolved value was the result of pseudorandom assignment. + SPLIT, + // The resolved value was retrieved from cache. + CACHED, + /// The resolved value was the result of the flag being disabled in the management system. + DISABLED, + /// The reason for the resolved value could not be determined. + UNKNOWN, + /// The resolved value is non-authoritative or possible out of date + STALE, + /// The resolved value was the result of an error. + ERROR +} \ No newline at end of file diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/Structure.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/Structure.kt new file mode 100644 index 0000000..9681c58 --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/Structure.kt @@ -0,0 +1,12 @@ +package dev.openfeature.sdk + +interface Structure { + fun keySet(): Set + fun getValue(key: String): Value? + fun asMap(): MutableMap + fun asObjectMap(): Map + + // Make sure these are implemented for correct object comparisons + override fun hashCode(): Int + override fun equals(other: Any?): Boolean +} diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/Value.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/Value.kt new file mode 100644 index 0000000..2228bc0 --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/Value.kt @@ -0,0 +1,73 @@ +package dev.openfeature.sdk + +import dev.openfeature.sdk.exceptions.OpenFeatureError +import kotlinx.serialization.EncodeDefault.* +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonObject +import java.time.Instant +import java.util.* + +@Serializable(with = ValueSerializer::class) +sealed interface Value { + + fun asString(): kotlin.String? = if (this is String) string else null + fun asBoolean(): kotlin.Boolean? = if (this is Boolean) boolean else null + fun asInteger(): Int? = if (this is Integer) integer else null + fun asDouble(): kotlin.Double? = if (this is Double) double else null + fun asInstant(): java.time.Instant? = if (this is Instant) instant else null + fun asList(): kotlin.collections.List? = if (this is List) list else null + fun asStructure(): Map? = if (this is Structure) structure else null + fun isNull(): kotlin.Boolean = this is Null + + @Serializable + data class String(val string: kotlin.String) : Value + @Serializable + data class Boolean(val boolean: kotlin.Boolean) : Value + @Serializable + data class Integer(val integer: Int) : Value + @Serializable + data class Double(val double: kotlin.Double) : Value + @Serializable + data class Instant(@Serializable(InstantSerializer::class) val instant: java.time.Instant) : Value + @Serializable + data class Structure(val structure: Map) : Value + @Serializable + data class List(val list: kotlin.collections.List) : Value + @Serializable + object Null: Value { + override fun equals(other: Any?): kotlin.Boolean { + return other is Null + } + override fun hashCode(): Int { + return javaClass.hashCode() + } + } +} + + +object ValueSerializer: JsonContentPolymorphicSerializer(Value::class) { + override fun selectDeserializer(element: JsonElement) = when(element.jsonObject.keys) { + emptySet() -> Value.Null.serializer() + setOf("string") -> Value.String.serializer() + setOf("boolean") -> Value.Boolean.serializer() + setOf("integer") -> Value.Integer.serializer() + setOf("double") -> Value.Double.serializer() + setOf("instant") -> Value.Instant.serializer() + setOf("list") -> Value.List.serializer() + setOf("structure") -> Value.Structure.serializer() + else -> throw OpenFeatureError.ParseError("couldn't find deserialization key for Value") + } +} + +object InstantSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString()) + override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString()) +} diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/exceptions/ErrorCode.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/exceptions/ErrorCode.kt new file mode 100644 index 0000000..ed7ab8a --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/exceptions/ErrorCode.kt @@ -0,0 +1,18 @@ +package dev.openfeature.sdk.exceptions + +enum class ErrorCode { + // The value was resolved before the provider was ready. + PROVIDER_NOT_READY, + // The flag could not be found. + FLAG_NOT_FOUND, + // An error was encountered parsing data, such as a flag configuration. + PARSE_ERROR, + // The type of the flag value does not match the expected type. + TYPE_MISMATCH, + // The provider requires a targeting key and one was not provided in the evaluation context. + TARGETING_KEY_MISSING, + // The evaluation context does not meet provider requirements. + INVALID_CONTEXT, + // The error was for a reason not enumerated above. + GENERAL +} diff --git a/OpenFeature/src/main/java/dev/openfeature/sdk/exceptions/OpenFeatureError.kt b/OpenFeature/src/main/java/dev/openfeature/sdk/exceptions/OpenFeatureError.kt new file mode 100644 index 0000000..be17b95 --- /dev/null +++ b/OpenFeature/src/main/java/dev/openfeature/sdk/exceptions/OpenFeatureError.kt @@ -0,0 +1,43 @@ +package dev.openfeature.sdk.exceptions + +sealed class OpenFeatureError : Exception() { + abstract fun errorCode(): ErrorCode + + class GeneralError(override val message: String): OpenFeatureError() { + override fun errorCode(): ErrorCode { + return ErrorCode.GENERAL + } + } + + class FlagNotFoundError(flagKey: String): OpenFeatureError() { + override val message: String = "Could not find flag named: $flagKey" + override fun errorCode(): ErrorCode { + return ErrorCode.FLAG_NOT_FOUND + } + } + + class InvalidContextError( + override val message: String = "Invalid context"): OpenFeatureError() { + override fun errorCode(): ErrorCode { + return ErrorCode.INVALID_CONTEXT + } + } + + class ParseError(override val message: String): OpenFeatureError() { + override fun errorCode(): ErrorCode { + return ErrorCode.PARSE_ERROR + } + } + + class TargetingKeyMissingError(override val message: String = "Targeting key missing in evaluation context"): OpenFeatureError() { + override fun errorCode(): ErrorCode { + return ErrorCode.TARGETING_KEY_MISSING + } + } + + class ProviderNotReadyError(override val message: String = "The value was resolved before the provider was ready"): OpenFeatureError() { + override fun errorCode(): ErrorCode { + return ErrorCode.PROVIDER_NOT_READY + } + } +} \ No newline at end of file diff --git a/OpenFeature/src/test/java/dev/openfeature/sdk/DeveloperExperienceTests.kt b/OpenFeature/src/test/java/dev/openfeature/sdk/DeveloperExperienceTests.kt new file mode 100644 index 0000000..ca5303b --- /dev/null +++ b/OpenFeature/src/test/java/dev/openfeature/sdk/DeveloperExperienceTests.kt @@ -0,0 +1,61 @@ +package dev.openfeature.sdk + +import dev.openfeature.sdk.exceptions.ErrorCode +import dev.openfeature.sdk.helpers.AlwaysBrokenProvider +import dev.openfeature.sdk.helpers.GenericSpyHookMock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Test + +@ExperimentalCoroutinesApi +class DeveloperExperienceTests { + @Test + fun testNoProviderSet() = runTest { + OpenFeatureAPI.clearProvider() + val stringValue = OpenFeatureAPI.getClient().getStringValue("test", "no-op") + Assert.assertEquals(stringValue, "no-op") + } + + @Test + fun testSimpleBooleanFlag() = runTest { + OpenFeatureAPI.setProvider(NoOpProvider()) + val booleanValue = OpenFeatureAPI.getClient().getBooleanValue("test", false) + Assert.assertFalse(booleanValue) + } + + @Test + fun testClientHooks() = runTest { + OpenFeatureAPI.setProvider(NoOpProvider()) + val client = OpenFeatureAPI.getClient() + + val hook = GenericSpyHookMock() + client.addHooks(listOf(hook)) + + client.getBooleanValue("test", false) + Assert.assertEquals(hook.finallyCalledAfter, 1) + } + + @Test + fun testEvalHooks() = runTest { + OpenFeatureAPI.setProvider(NoOpProvider()) + val client = OpenFeatureAPI.getClient() + + val hook = GenericSpyHookMock() + val options = FlagEvaluationOptions(listOf(hook)) + + client.getBooleanValue("test", false, options) + Assert.assertEquals(hook.finallyCalledAfter, 1) + } + + @Test + fun testBrokenProvider() = runTest { + OpenFeatureAPI.setProvider(AlwaysBrokenProvider()) + val client = OpenFeatureAPI.getClient() + + val details = client.getBooleanDetails("test", false) + Assert.assertEquals(ErrorCode.FLAG_NOT_FOUND, details.errorCode) + Assert.assertEquals("Could not find flag named: test", details.errorMessage) + Assert.assertEquals(Reason.ERROR.toString(), details.reason) + } +} \ No newline at end of file diff --git a/OpenFeature/src/test/java/dev/openfeature/sdk/EvalContextTests.kt b/OpenFeature/src/test/java/dev/openfeature/sdk/EvalContextTests.kt new file mode 100644 index 0000000..c17e750 --- /dev/null +++ b/OpenFeature/src/test/java/dev/openfeature/sdk/EvalContextTests.kt @@ -0,0 +1,156 @@ +package dev.openfeature.sdk + +import org.junit.Assert +import org.junit.Test +import java.time.Instant + +class EvalContextTests { + + @Test + fun testContextStoresTargetingKey() { + val ctx = MutableContext() + ctx.setTargetingKey("test") + Assert.assertEquals("test", ctx.getTargetingKey()) + } + + @Test + fun testContextStoresPrimitiveValues() { + val ctx = MutableContext() + val now = Instant.now() + + ctx.add("string", Value.String("value")) + Assert.assertEquals("value", ctx.getValue("string")?.asString()) + ctx.add("bool", Value.Boolean(true)) + Assert.assertEquals(true, ctx.getValue("bool")?.asBoolean()) + ctx.add("int", Value.Integer(3)) + Assert.assertEquals(3, ctx.getValue("int")?.asInteger()) + ctx.add("double", Value.Double(3.14)) + Assert.assertEquals(3.14, ctx.getValue("double")?.asDouble()) + ctx.add("instant", Value.Instant(now)) + Assert.assertEquals(now, ctx.getValue("instant")?.asInstant()) + } + @Test + fun testContextStoresLists() { + val ctx = MutableContext() + + ctx.add("list", Value.List(listOf( + Value.Integer(3), + Value.String("4")))) + Assert.assertEquals(3, ctx.getValue("list")?.asList()?.get(0)?.asInteger()) + Assert.assertEquals("4", ctx.getValue("list")?.asList()?.get(1)?.asString()) + } + + @Test + fun testContextStoresStructures() { + val ctx = MutableContext() + + ctx.add("struct", Value.Structure(mapOf( + "string" to Value.String("test"), + "int" to Value.Integer(3)))) + Assert.assertEquals("test", ctx.getValue("struct")?.asStructure()?.get("string")?.asString()) + Assert.assertEquals(3, ctx.getValue("struct")?.asStructure()?.get("int")?.asInteger()) + } + + @Test + fun testContextCanConvertToMap() { + val ctx = MutableContext() + val now = Instant.now() + ctx.add("str1", Value.String("test1")) + ctx.add("str2", Value.String("test2")) + ctx.add("bool1", Value.Boolean(true)) + ctx.add("bool2", Value.Boolean(false)) + ctx.add("int1", Value.Integer(4)) + ctx.add("int2", Value.Integer(2)) + ctx.add("dt", Value.Instant(now)) + ctx.add("obj", Value.Structure(mapOf("val1" to Value.Integer(1), "val2" to Value.String("2")))) + + val map = ctx.asMap() + val structure = map["obj"]?.asStructure() + Assert.assertEquals("test1", map["str1"]?.asString()) + Assert.assertEquals("test2", map["str2"]?.asString()) + Assert.assertEquals(true, map["bool1"]?.asBoolean()) + Assert.assertEquals(false, map["bool2"]?.asBoolean()) + Assert.assertEquals(4, map["int1"]?.asInteger()) + Assert.assertEquals(2, map["int2"]?.asInteger()) + Assert.assertEquals(now, map["dt"]?.asInstant()) + Assert.assertEquals(1, structure?.get("val1")?.asInteger()) + Assert.assertEquals("2", structure?.get("val2")?.asString()) + } + + @Test + fun testContextHasUniqueKeyAcrossTypes() { + val ctx = MutableContext() + + ctx.add("key", Value.String("val1")) + ctx.add("key", Value.String("val2")) + Assert.assertEquals("val2", ctx.getValue("key")?.asString()) + + ctx.add("key", Value.Integer(3)) + Assert.assertNull(ctx.getValue("key")?.asString()) + Assert.assertEquals(3, ctx.getValue("key")?.asInteger()) + } + + @Test + fun testContextCanChainAttributeAddition() { + val ctx = MutableContext() + + val result = + ctx.add("key1", Value.String("val1")) + ctx.add("key2", Value.String("val2")) + Assert.assertEquals("val1", result.getValue("key1")?.asString()) + Assert.assertEquals("val2", result.getValue("key2")?.asString()) + } + + @Test + fun testContextCanAddNull() { + val ctx = MutableContext() + + ctx.add("null", Value.Null) + Assert.assertEquals(true, ctx.getValue("null")?.isNull()) + Assert.assertNull(ctx.getValue("null")?.asString()) + } + + @Test + fun testContextConvertsToObjectMap() { + val key = "key1" + val now = Instant.now() + val ctx = MutableContext(key) + ctx.add("string", Value.String("value")) + ctx.add("bool", Value.Boolean(false)) + ctx.add("integer", Value.Integer(1)) + ctx.add("double", Value.Double(1.2)) + ctx.add("date", Value.Instant(now)) + ctx.add("null", Value.Null) + ctx.add("list", Value.List(listOf(Value.String("item1"), Value.Boolean(true)))) + ctx.add("structure", + Value.Structure( + mapOf( + "field1" to Value.Integer(3), + "field2" to Value.Double(3.14) + ) + ) + ) + + val expected = mapOf( + "string" to "value", + "bool" to false, + "integer" to 1, + "double" to 1.2, + "date" to now, + "null" to null, + "list" to listOf("item1", true), + "structure" to mapOf("field1" to 3, "field2" to 3.14), + ) + Assert.assertEquals(expected, ctx.asObjectMap()) + } + + @Test + fun compareContexts() { + val map: MutableMap = mutableMapOf("key" to Value.String("test")) + val map2: MutableMap = mutableMapOf("key" to Value.String("test")) + val ctx1 = MutableContext("user1", map) + val ctx2 = MutableContext("user1", map2) + + Assert.assertEquals(ctx1, ctx2) + } +} diff --git a/OpenFeature/src/test/java/dev/openfeature/sdk/FlagEvaluationsTests.kt b/OpenFeature/src/test/java/dev/openfeature/sdk/FlagEvaluationsTests.kt new file mode 100644 index 0000000..024c06f --- /dev/null +++ b/OpenFeature/src/test/java/dev/openfeature/sdk/FlagEvaluationsTests.kt @@ -0,0 +1,132 @@ +package dev.openfeature.sdk + +import dev.openfeature.sdk.exceptions.ErrorCode +import dev.openfeature.sdk.helpers.AlwaysBrokenProvider +import dev.openfeature.sdk.helpers.DoSomethingProvider +import dev.openfeature.sdk.helpers.GenericSpyHookMock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Test + +@ExperimentalCoroutinesApi +class FlagEvaluationsTests { + @Test + fun testApiSetsProvider() = runTest { + val provider = NoOpProvider() + + OpenFeatureAPI.setProvider(provider) + Assert.assertEquals(provider, OpenFeatureAPI.getProvider()) + } + + @Test + fun testHooksPersist() { + val hook1 = GenericSpyHookMock() + val hook2 = GenericSpyHookMock() + + OpenFeatureAPI.addHooks(listOf(hook1)) + Assert.assertEquals(1, OpenFeatureAPI.hooks.count()) + + OpenFeatureAPI.addHooks(listOf(hook2)) + Assert.assertEquals(2, OpenFeatureAPI.hooks.count()) + } + + @Test + fun testClientHooksPersist() { + val hook1 = GenericSpyHookMock() + val hook2 = GenericSpyHookMock() + + val client = OpenFeatureAPI.getClient() + client.addHooks(listOf(hook1)) + Assert.assertEquals(1, client.hooks.count()) + + client.addHooks(listOf(hook2)) + Assert.assertEquals(2, client.hooks.count()) + } + + @Test + fun testSimpleFlagEvaluation() = runTest { + OpenFeatureAPI.setProvider(DoSomethingProvider()) + val client = OpenFeatureAPI.getClient() + val key = "key" + + Assert.assertEquals(true, client.getBooleanValue(key, false)) + Assert.assertEquals(true, client.getBooleanValue(key, false, FlagEvaluationOptions())) + + Assert.assertEquals("test", client.getStringValue(key, "tset")) + Assert.assertEquals("test", client.getStringValue(key, "tset", FlagEvaluationOptions())) + + Assert.assertEquals(400, client.getIntegerValue(key, 4)) + Assert.assertEquals(400, client.getIntegerValue(key, 4, FlagEvaluationOptions())) + + Assert.assertEquals(40.0, client.getDoubleValue(key, 0.4),0.0) + Assert.assertEquals(40.0, client.getDoubleValue(key, 0.4, FlagEvaluationOptions()),0.0) + + Assert.assertEquals(Value.Null, client.getObjectValue(key, Value.Structure(mapOf()))) + Assert.assertEquals(Value.Null, client.getObjectValue(key, Value.Structure(mapOf()), FlagEvaluationOptions())) + } + + @Test + fun testDetailedFlagEvaluation() = runTest { + OpenFeatureAPI.setProvider(DoSomethingProvider()) + val client = OpenFeatureAPI.getClient() + val key = "key" + + val booleanDetails = FlagEvaluationDetails(key, true) + Assert.assertEquals(booleanDetails, client.getBooleanDetails(key, false)) + Assert.assertEquals(booleanDetails, client.getBooleanDetails(key, false, FlagEvaluationOptions())) + + val stringDetails = FlagEvaluationDetails(key, "tset") + Assert.assertEquals(stringDetails, client.getStringDetails(key, "test")) + Assert.assertEquals(stringDetails, client.getStringDetails(key, "test", FlagEvaluationOptions())) + + val integerDetails = FlagEvaluationDetails(key, 400) + Assert.assertEquals(integerDetails, client.getIntegerDetails(key, 4)) + Assert.assertEquals(integerDetails, client.getIntegerDetails(key, 4, FlagEvaluationOptions())) + + val doubleDetails = FlagEvaluationDetails(key, 40.0) + Assert.assertEquals(doubleDetails, client.getDoubleDetails(key, 0.4)) + Assert.assertEquals(doubleDetails, client.getDoubleDetails(key, 0.4, FlagEvaluationOptions())) + + val objectDetails = FlagEvaluationDetails(key, Value.Null) + Assert.assertEquals(objectDetails, client.getObjectDetails(key, Value.Structure(mapOf()))) + Assert.assertEquals(objectDetails, client.getObjectDetails(key, Value.Structure(mapOf()), FlagEvaluationOptions())) + } + + @Test + fun testHooksAreFired() = runTest { + OpenFeatureAPI.setProvider(NoOpProvider()) + val client = OpenFeatureAPI.getClient() + + val clientHook = GenericSpyHookMock() + val invocationHook = GenericSpyHookMock() + + client.addHooks(listOf(clientHook)) + client.getBooleanValue("key", false, FlagEvaluationOptions(listOf(invocationHook))) + + Assert.assertEquals(1, clientHook.beforeCalled) + Assert.assertEquals(1, invocationHook.beforeCalled) + } + + @Test + fun testBrokenProvider() = runTest { + OpenFeatureAPI.setProvider(AlwaysBrokenProvider()) + val client = OpenFeatureAPI.getClient() + + client.getBooleanValue("testKey", false) + val details = client.getBooleanDetails("testKey", false) + + Assert.assertEquals(ErrorCode.FLAG_NOT_FOUND, details.errorCode) + Assert.assertEquals(Reason.ERROR.toString(), details.reason) + Assert.assertEquals("Could not find flag named: testKey", details.errorMessage) + } + + @Test + fun testClientMetadata() { + val client1 = OpenFeatureAPI.getClient() + Assert.assertNull(client1.metadata.name) + + val client2 = OpenFeatureAPI.getClient("test") + Assert.assertEquals("test", client2.metadata.name) + } +} diff --git a/OpenFeature/src/test/java/dev/openfeature/sdk/HookSpecTests.kt b/OpenFeature/src/test/java/dev/openfeature/sdk/HookSpecTests.kt new file mode 100644 index 0000000..b759a86 --- /dev/null +++ b/OpenFeature/src/test/java/dev/openfeature/sdk/HookSpecTests.kt @@ -0,0 +1,70 @@ +package dev.openfeature.sdk + +import dev.openfeature.sdk.helpers.AlwaysBrokenProvider +import dev.openfeature.sdk.helpers.GenericSpyHookMock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Test + +@ExperimentalCoroutinesApi +class HookSpecTests { + + @Test + fun testNoErrorHookCalled() = runTest { + OpenFeatureAPI.setProvider(NoOpProvider()) + val client = OpenFeatureAPI.getClient() + val hook = GenericSpyHookMock() + + client.getBooleanValue("key", false, FlagEvaluationOptions(listOf(hook))) + + Assert.assertEquals(1, hook.beforeCalled) + Assert.assertEquals(1, hook.afterCalled) + Assert.assertEquals(0, hook.errorCalled) + Assert.assertEquals(1, hook.finallyCalledAfter) + } + + @Test + fun testErrorHookButNoAfterCalled() = runTest { + OpenFeatureAPI.setProvider(AlwaysBrokenProvider()) + val client = OpenFeatureAPI.getClient() + val hook = GenericSpyHookMock() + + client.getBooleanValue("key", false, FlagEvaluationOptions(listOf(hook))) + Assert.assertEquals(1, hook.beforeCalled) + Assert.assertEquals(0, hook.afterCalled) + Assert.assertEquals(1, hook.errorCalled) + Assert.assertEquals(1, hook.finallyCalledAfter) + } + + @Test + fun testHookEvaluationOrder() = runTest { + val provider = NoOpProvider() + val evalOrder: MutableList = mutableListOf() + val addEval: (String) -> Unit = { eval: String -> evalOrder += eval} + + provider.hooks = listOf(GenericSpyHookMock("provider", addEval)) + OpenFeatureAPI.setProvider(provider) + OpenFeatureAPI.addHooks(listOf(GenericSpyHookMock("api", addEval))) + val client = OpenFeatureAPI.getClient() + client.addHooks(listOf(GenericSpyHookMock("client", addEval))) + val flagOptions = FlagEvaluationOptions(listOf(GenericSpyHookMock("invocation", addEval))) + + client.getBooleanValue("key", false, flagOptions) + + Assert.assertEquals(listOf( + "api before", + "client before", + "invocation before", + "provider before", + "provider after", + "invocation after", + "client after", + "api after", + "provider finallyAfter", + "invocation finallyAfter", + "client finallyAfter", + "api finallyAfter" + ), evalOrder) + } +} \ No newline at end of file diff --git a/OpenFeature/src/test/java/dev/openfeature/sdk/HookSupportTests.kt b/OpenFeature/src/test/java/dev/openfeature/sdk/HookSupportTests.kt new file mode 100644 index 0000000..8791fc7 --- /dev/null +++ b/OpenFeature/src/test/java/dev/openfeature/sdk/HookSupportTests.kt @@ -0,0 +1,56 @@ +package dev.openfeature.sdk + +import dev.openfeature.sdk.exceptions.OpenFeatureError.InvalidContextError +import dev.openfeature.sdk.helpers.GenericSpyHookMock +import org.junit.Assert +import org.junit.Test + +class HookSupportTests { + @Test + fun testShouldAlwaysCallGenericHook() { + val metadata = OpenFeatureAPI.getClient().metadata + val hook = GenericSpyHookMock() + val hookContext = HookContext( + "flagKey", + FlagValueType.BOOLEAN, + false, + MutableContext(), + metadata, + NoOpProvider().metadata + ) + + val hookSupport = HookSupport() + + hookSupport.beforeHooks( + FlagValueType.BOOLEAN, + hookContext, + listOf(hook), + mapOf() + ) + hookSupport.afterHooks( + FlagValueType.BOOLEAN, + hookContext, + FlagEvaluationDetails("", false), + listOf(hook), + mapOf() + ) + hookSupport.afterAllHooks( + FlagValueType.BOOLEAN, + hookContext, + listOf(hook), + mapOf() + ) + hookSupport.errorHooks( + FlagValueType.BOOLEAN, + hookContext, + InvalidContextError(), + listOf(hook), + mapOf() + ) + + Assert.assertEquals(1, hook.beforeCalled) + Assert.assertEquals(1, hook.afterCalled) + Assert.assertEquals(1, hook.finallyCalledAfter) + Assert.assertEquals(1, hook.errorCalled) + } +} \ No newline at end of file diff --git a/OpenFeature/src/test/java/dev/openfeature/sdk/OpenFeatureClientTests.kt b/OpenFeature/src/test/java/dev/openfeature/sdk/OpenFeatureClientTests.kt new file mode 100644 index 0000000..dce8c2f --- /dev/null +++ b/OpenFeature/src/test/java/dev/openfeature/sdk/OpenFeatureClientTests.kt @@ -0,0 +1,18 @@ +package dev.openfeature.sdk + +import dev.openfeature.sdk.helpers.GenericSpyHookMock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@ExperimentalCoroutinesApi +class OpenFeatureClientTests { + @Test + fun testShouldNowThrowIfHookHasDifferentTypeArgument() = runTest { + OpenFeatureAPI.setProvider(NoOpProvider()) + OpenFeatureAPI.addHooks(listOf(GenericSpyHookMock())) + val stringValue = OpenFeatureAPI.getClient().getStringValue("test", "defaultTest") + assertEquals(stringValue, "defaultTest") + } +} diff --git a/OpenFeature/src/test/java/dev/openfeature/sdk/ProviderSpecTests.kt b/OpenFeature/src/test/java/dev/openfeature/sdk/ProviderSpecTests.kt new file mode 100644 index 0000000..5e26e67 --- /dev/null +++ b/OpenFeature/src/test/java/dev/openfeature/sdk/ProviderSpecTests.kt @@ -0,0 +1,63 @@ +package dev.openfeature.sdk + +import org.junit.Assert +import org.junit.Test + +class ProviderSpecTests { + + @Test + fun testFlagValueSet() { + val provider = NoOpProvider() + + val boolResult = provider.getBooleanEvaluation("key", false) + Assert.assertNotNull(boolResult.value) + + val stringResult = provider.getStringEvaluation("key", "test") + Assert.assertNotNull(stringResult.value) + + val intResult = provider.getIntegerEvaluation("key", 4) + Assert.assertNotNull(intResult.value) + + val doubleResult = provider.getDoubleEvaluation("key", 0.4) + Assert.assertNotNull(doubleResult.value) + + val objectResult = provider.getObjectEvaluation("key", Value.Null) + Assert.assertNotNull(objectResult.value) + } + + @Test + fun testHasReason() { + val provider = NoOpProvider() + val boolResult = provider.getBooleanEvaluation("key", false) + + Assert.assertEquals(Reason.DEFAULT.toString(), boolResult.reason) + } + + @Test + fun testNoErrorCodeByDefault() { + val provider = NoOpProvider() + val boolResult = provider.getBooleanEvaluation("key", false) + + Assert.assertNull(boolResult.errorCode) + } + + @Test + fun testVariantIsSet() { + val provider = NoOpProvider() + + val boolResult = provider.getBooleanEvaluation("key", false) + Assert.assertNotNull(boolResult.variant) + + val stringResult = provider.getStringEvaluation("key", "test") + Assert.assertNotNull(stringResult.variant) + + val intResult = provider.getIntegerEvaluation("key", 4) + Assert.assertNotNull(intResult.variant) + + val doubleResult = provider.getDoubleEvaluation("key", 0.4) + Assert.assertNotNull(doubleResult.variant) + + val objectResult = provider.getObjectEvaluation("key", Value.Null) + Assert.assertNotNull(objectResult.variant) + } +} diff --git a/OpenFeature/src/test/java/dev/openfeature/sdk/StructureTests.kt b/OpenFeature/src/test/java/dev/openfeature/sdk/StructureTests.kt new file mode 100644 index 0000000..fdab5e6 --- /dev/null +++ b/OpenFeature/src/test/java/dev/openfeature/sdk/StructureTests.kt @@ -0,0 +1,54 @@ +package dev.openfeature.sdk + +import org.junit.Assert +import org.junit.Test +import java.time.Instant + +class StructureTests { + + @Test + fun testNoArgIsEmpty() { + val structure = MutableContext() + Assert.assertTrue(structure.asMap().keys.isEmpty()) + } + + @Test + fun testArgShouldContainNewMap() { + val map: MutableMap = mutableMapOf("key" to Value.String("test")) + val structure = MutableStructure(map) + + Assert.assertEquals("test", structure.getValue("key")?.asString()) + Assert.assertEquals(map, structure.asMap()) + } + + @Test + fun testAddAndGetReturnValues() { + val now = Instant.now() + val structure = MutableStructure() + structure.add("bool", Value.Boolean(true)) + structure.add("string", Value.String("val")) + structure.add("int", Value.Integer(13)) + structure.add("double", Value.Double(0.5)) + structure.add("date", Value.Instant(now)) + structure.add("list", Value.List(listOf())) + structure.add("structure", Value.Structure(mapOf())) + + Assert.assertEquals(true, structure.getValue("bool")?.asBoolean()) + Assert.assertEquals("val", structure.getValue("string")?.asString()) + Assert.assertEquals(13, structure.getValue("int")?.asInteger()) + Assert.assertEquals(0.5, structure.getValue("double")?.asDouble()) + Assert.assertEquals(now, structure.getValue("date")?.asInstant()) + Assert.assertEquals(listOf(), structure.getValue("list")?.asList()) + Assert.assertEquals(mapOf(), structure.getValue("structure")?.asStructure()) + } + + @Test + fun testCompareStructure() { + val map: MutableMap = mutableMapOf("key" to Value.String("test")) + val map2: MutableMap = mutableMapOf("key" to Value.String("test")) + val structure1 = MutableStructure(map) + val structure2 = MutableStructure(map2) + + Assert.assertEquals(structure1, structure2) + } +} \ No newline at end of file diff --git a/OpenFeature/src/test/java/dev/openfeature/sdk/ValueTests.kt b/OpenFeature/src/test/java/dev/openfeature/sdk/ValueTests.kt new file mode 100644 index 0000000..ba0307a --- /dev/null +++ b/OpenFeature/src/test/java/dev/openfeature/sdk/ValueTests.kt @@ -0,0 +1,139 @@ +package dev.openfeature.sdk + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import org.junit.Assert +import org.junit.Test +import java.time.Instant + +class ValueTests { + + @Test + fun testNull() { + val value = Value.Null + Assert.assertTrue(value.isNull()) + } + + @Test + fun testIntShouldConvertToInt() { + val value = Value.Integer(3) + Assert.assertEquals(3, value.asInteger()) + } + + @Test + fun testDoubleShouldConvertToDouble() { + val value = Value.Double(3.14) + Assert.assertEquals(3.14, value.asDouble()!!, 0.0) + } + + @Test + fun testBoolShouldConvertToBool() { + val value = Value.Boolean(true) + Assert.assertEquals(true, value.asBoolean()) + } + + @Test + fun testStringShouldConvertToString() { + val value = Value.String("test") + Assert.assertEquals("test", value.asString()) + } + + @Test + fun testListShouldConvertToList() { + val value = Value.List(listOf(Value.Integer(3), Value.Integer(4))) + Assert.assertEquals(listOf(Value.Integer(3), Value.Integer(4)), value.asList()) + } + + @Test + fun testStructShouldConvertToStruct() { + val value = Value.Structure(mapOf("field1" to Value.Integer(3), "field2" to Value.String("test"))) + Assert.assertEquals(value.asStructure(), mapOf("field1" to Value.Integer(3), "field2" to Value.String("test"))) + } + + @Test + fun testEmptyListAllowed() { + val value = Value.List(listOf()) + Assert.assertEquals(listOf(), value.asList()) + } + + @Test + fun testEncodeDecode() { + val date = Instant.parse("2023-03-01T14:01:46Z") + val value = Value.Structure( + mapOf( + "null" to Value.Null, + "text" to Value.String("test"), + "bool" to Value.Boolean(true), + "int" to Value.Integer(3), + "double" to Value.Double(4.5), + "date" to Value.Instant(date), + "list" to Value.List(listOf(Value.Boolean(false), Value.Integer(4))), + "structure" to Value.Structure(mapOf("int" to Value.Integer(5))) + ) + ) + + val encodedValue = Json.encodeToJsonElement(value) + val decodedValue = Json.decodeFromJsonElement(encodedValue) + + Assert.assertEquals(value, decodedValue) + } + + @Test + fun testJsonDecode() { + val stringInstant = "2023-03-01T14:01:46Z" + val json = "{" + + " \"structure\": {" + + " \"null\": {}," + + " \"text\": {" + + " \"string\": \"test\"" + + " }," + + " \"bool\": {" + + " \"boolean\": true" + + " }," + + " \"int\": {" + + " \"integer\": 3" + + " }," + + " \"double\": {" + + " \"double\": 4.5" + + " }," + + " \"date\": {" + + " \"instant\": \"$stringInstant\"" + + " }," + + " \"list\": {" + + " \"list\": [" + + " {" + + " \"boolean\": false" + + " }," + + " {" + + " \"integer\": 4" + + " }" + + " ]" + + " }," + + " \"structure\": {" + + " \"structure\": {" + + " \"int\": {" + + " \"integer\": 5" + + " }" + + " }" + + " }" + + " }" + + "}" + + val expectedValue = Value.Structure( + mapOf( + "null" to Value.Null, + "text" to Value.String("test"), + "bool" to Value.Boolean(true), + "int" to Value.Integer(3), + "double" to Value.Double(4.5), + "date" to Value.Instant(Instant.parse(stringInstant)), + "list" to Value.List(listOf(Value.Boolean(false), Value.Integer(4))), + "structure" to Value.Structure(mapOf("int" to Value.Integer(5))) + ) + ) + + val decodedValue = Json.decodeFromString(Value.serializer(), json) + Assert.assertEquals(expectedValue, decodedValue) + } +} diff --git a/OpenFeature/src/test/java/dev/openfeature/sdk/helpers/AlwaysBrokenProvider.kt b/OpenFeature/src/test/java/dev/openfeature/sdk/helpers/AlwaysBrokenProvider.kt new file mode 100644 index 0000000..98a8d3e --- /dev/null +++ b/OpenFeature/src/test/java/dev/openfeature/sdk/helpers/AlwaysBrokenProvider.kt @@ -0,0 +1,54 @@ +package dev.openfeature.sdk.helpers + +import dev.openfeature.sdk.* +import dev.openfeature.sdk.exceptions.OpenFeatureError.FlagNotFoundError + +class AlwaysBrokenProvider(override var hooks: List> = listOf(), override var metadata: Metadata = AlwaysBrokenMetadata()) : FeatureProvider { + override suspend fun initialize(initialContext: EvaluationContext?) { + // no-op + } + + override suspend fun onContextSet( + oldContext: EvaluationContext?, + newContext: EvaluationContext + ) { + // no-op + } + + override fun getBooleanEvaluation( + key: String, + defaultValue: Boolean + ): ProviderEvaluation { + throw FlagNotFoundError(key) + } + + override fun getStringEvaluation( + key: String, + defaultValue: String + ): ProviderEvaluation { + throw FlagNotFoundError(key) + } + + override fun getIntegerEvaluation( + key: String, + defaultValue: Int + ): ProviderEvaluation { + throw FlagNotFoundError(key) + } + + override fun getDoubleEvaluation( + key: String, + defaultValue: Double + ): ProviderEvaluation { + throw FlagNotFoundError(key) + } + + override fun getObjectEvaluation( + key: String, + defaultValue: Value + ): ProviderEvaluation { + throw FlagNotFoundError(key) + } + + class AlwaysBrokenMetadata(override var name: String? = "test") : Metadata +} \ No newline at end of file diff --git a/OpenFeature/src/test/java/dev/openfeature/sdk/helpers/DoSomethingProvider.kt b/OpenFeature/src/test/java/dev/openfeature/sdk/helpers/DoSomethingProvider.kt new file mode 100644 index 0000000..5184eef --- /dev/null +++ b/OpenFeature/src/test/java/dev/openfeature/sdk/helpers/DoSomethingProvider.kt @@ -0,0 +1,52 @@ +package dev.openfeature.sdk.helpers + +import dev.openfeature.sdk.* + +class DoSomethingProvider(override val hooks: List> = listOf(), override val metadata: Metadata = DoSomethingMetadata()) : FeatureProvider { + override suspend fun initialize(initialContext: EvaluationContext?) { + // no-op + } + + override suspend fun onContextSet( + oldContext: EvaluationContext?, + newContext: EvaluationContext + ) { + // no-op + } + + override fun getBooleanEvaluation( + key: String, + defaultValue: Boolean + ): ProviderEvaluation { + return ProviderEvaluation(!defaultValue) + } + + override fun getStringEvaluation( + key: String, + defaultValue: String + ): ProviderEvaluation { + return ProviderEvaluation(defaultValue.reversed()) + } + + override fun getIntegerEvaluation( + key: String, + defaultValue: Int + ): ProviderEvaluation { + return ProviderEvaluation(defaultValue * 100) + } + + override fun getDoubleEvaluation( + key: String, + defaultValue: Double + ): ProviderEvaluation { + return ProviderEvaluation(defaultValue * 100) + } + + override fun getObjectEvaluation( + key: String, + defaultValue: Value + ): ProviderEvaluation { + return ProviderEvaluation(Value.Null) + } + class DoSomethingMetadata(override var name: String? = "something") : Metadata +} diff --git a/OpenFeature/src/test/java/dev/openfeature/sdk/helpers/GenericSpyHookMock.kt b/OpenFeature/src/test/java/dev/openfeature/sdk/helpers/GenericSpyHookMock.kt new file mode 100644 index 0000000..e6b179b --- /dev/null +++ b/OpenFeature/src/test/java/dev/openfeature/sdk/helpers/GenericSpyHookMock.kt @@ -0,0 +1,44 @@ +package dev.openfeature.sdk.helpers + +import dev.openfeature.sdk.FlagEvaluationDetails +import dev.openfeature.sdk.Hook +import dev.openfeature.sdk.HookContext + +class GenericSpyHookMock(private var prefix: String = "", var addEval: (String) -> Unit = {}) : Hook { + var beforeCalled = 0 + var afterCalled = 0 + var finallyCalledAfter = 0 + var errorCalled = 0 + + override fun before( + ctx: HookContext, + hints: Map + ) { + beforeCalled += 1 + addEval("$prefix before") + } + + override fun after( + ctx: HookContext, + details: FlagEvaluationDetails, + hints: Map + ) { + afterCalled += 1 + addEval("$prefix after") + } + + + override fun error( + ctx: HookContext, + error: Exception, + hints: Map + ) { + errorCalled += 1 + addEval("$prefix error") + } + + override fun finallyAfter(ctx: HookContext, hints: Map) { + finallyCalledAfter += 1 + addEval("$prefix finallyAfter") + } +} \ No newline at end of file diff --git a/README.md b/README.md index 317ac2b..a48737e 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -# openfeature-kotlin-sdk \ No newline at end of file +# OpenFeature Kotlin SDK + +Kotlin implementation of the OpenFeature SDK \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..e862768 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,6 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + id("com.android.application").version("7.4.1").apply(false) + id("com.android.library").version("7.4.1").apply(false) + id("org.jetbrains.kotlin.android").version("1.8.0").apply(false) +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..0db0ea9 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,25 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +# Software Components will not be created automatically for Maven publishing from Android Gradle Plugin 8.0 +android.disableAutomaticComponentCreation=true \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..4673d67 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +import org.gradle.api.initialization.resolve.RepositoriesMode + +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "OpenFeature" +include(":OpenFeature") \ No newline at end of file