From f2dd4e52b0d25c369eb66a4ceca7b3140508998e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Fri, 6 Jun 2025 18:40:17 +0200 Subject: [PATCH 01/21] Move repeating Gradle config to the root project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- build.gradle.kts | 11 +++++++++++ providers/env-var/build.gradle.kts | 2 -- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 722dc5b..a6d1c03 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,9 @@ plugins { + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.android.library) apply false alias(libs.plugins.nexus.publish) + alias(libs.plugins.ktlint) apply false + alias(libs.plugins.binary.compatibility.validator) apply false } allprojects { @@ -9,6 +13,13 @@ allprojects { group = project.extra["groupId"].toString() version = project.extra["version"].toString() +subprojects { + plugins.withId("org.jetbrains.kotlin.multiplatform") { + apply(plugin = "org.jlleitschuh.gradle.ktlint") + apply(plugin = "org.jetbrains.kotlinx.binary-compatibility-validator") + } +} + nexusPublishing { this.repositories { sonatype { diff --git a/providers/env-var/build.gradle.kts b/providers/env-var/build.gradle.kts index 3ce8e16..a6d31a0 100644 --- a/providers/env-var/build.gradle.kts +++ b/providers/env-var/build.gradle.kts @@ -4,8 +4,6 @@ import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest plugins { alias(libs.plugins.kotlin.multiplatform) - alias(libs.plugins.binary.compatibility.validator) - alias(libs.plugins.ktlint) // Needed for the JS coroutine support for the tests alias(libs.plugins.kotlinx.atomicfu) } From 515dffa9347cb468d149af3e2a53500060f15b87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Fri, 6 Jun 2025 18:42:19 +0200 Subject: [PATCH 02/21] Add OfrepProvider from go-feature-flag-kotlin-provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- gradle.properties | 1 + gradle/libs.versions.toml | 7 +- providers/ofrep/README.md | 93 +++ providers/ofrep/build.gradle.kts | 55 ++ .../openfeature/ofrep/OfrepProvider.kt | 272 ++++++++ .../openfeature/ofrep/bean/OfrepApiRequest.kt | 5 + .../ofrep/bean/OfrepApiResponse.kt | 169 +++++ .../openfeature/ofrep/bean/OfrepOptions.kt | 44 ++ .../ofrep/bean/OfrepProviderMetadata.kt | 8 + .../ofrep/bean/PostBulkEvaluationResult.kt | 12 + .../openfeature/ofrep/controller/OfrepApi.kt | 100 +++ .../ofrep/enum/BulkEvaluationStatus.kt | 7 + .../openfeature/ofrep/error/OfrepError.kt | 10 + .../openfeature/ofrep/OfrepProviderTest.kt | 647 ++++++++++++++++++ .../ofrep/controller/OfrepApiTest.kt | 374 ++++++++++ .../resources/ofrep/invalid_api_response.json | 25 + .../resources/ofrep/invalid_context.json | 4 + .../resources/ofrep/parse_error.json | 4 + .../ofrep/valid_1_flag_in_parse_error.json | 20 + .../resources/ofrep/valid_api_response.json | 110 +++ .../resources/ofrep/valid_api_response_2.json | 110 +++ .../ofrep/valid_api_short_response.json | 26 + settings.gradle.kts | 10 + 23 files changed, 2112 insertions(+), 1 deletion(-) create mode 100644 providers/ofrep/README.md create mode 100644 providers/ofrep/build.gradle.kts create mode 100644 providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt create mode 100644 providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepApiRequest.kt create mode 100644 providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepApiResponse.kt create mode 100644 providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepOptions.kt create mode 100644 providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepProviderMetadata.kt create mode 100644 providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/PostBulkEvaluationResult.kt create mode 100644 providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApi.kt create mode 100644 providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/enum/BulkEvaluationStatus.kt create mode 100644 providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/error/OfrepError.kt create mode 100644 providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt create mode 100644 providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApiTest.kt create mode 100644 providers/ofrep/src/commonTest/resources/ofrep/invalid_api_response.json create mode 100644 providers/ofrep/src/commonTest/resources/ofrep/invalid_context.json create mode 100644 providers/ofrep/src/commonTest/resources/ofrep/parse_error.json create mode 100644 providers/ofrep/src/commonTest/resources/ofrep/valid_1_flag_in_parse_error.json create mode 100644 providers/ofrep/src/commonTest/resources/ofrep/valid_api_response.json create mode 100644 providers/ofrep/src/commonTest/resources/ofrep/valid_api_response_2.json create mode 100644 providers/ofrep/src/commonTest/resources/ofrep/valid_api_short_response.json diff --git a/gradle.properties b/gradle.properties index 00b5138..13b2ff1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,5 @@ org.gradle.configuration-cache=true +org.gradle.jvmargs=-Xmx1g "-XX:MaxMetaspaceSize=768m # This seems to be necessary for Coroutines to work on JS. Otherwise getting the following error: # > Task :providers:env-var:compileTestDevelopmentExecutableKotlinJs FAILED diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 68dbc80..e5321df 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,13 @@ [versions] kotlin = "2.1.21" kotlinx-coroutines = "1.10.2" +okhttp = "4.12.0" open-feature-kotlin-sdk = "0.4.1" +android = "8.10.1" [libraries] +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } openfeature-kotlin-sdk = { group="dev.openfeature", name="kotlin-sdk", version.ref="open-feature-kotlin-sdk" } kotlin-test = { group="org.jetbrains.kotlin", name="kotlin-test", version.ref="kotlin" } kotlinx-coroutines-core = { group="org.jetbrains.kotlinx", name="kotlinx-coroutines-core", version.ref="kotlinx-coroutines" } @@ -14,4 +18,5 @@ kotlin-multiplatform = { id="org.jetbrains.kotlin.multiplatform", version.ref="k kotlinx-atomicfu = { id="org.jetbrains.kotlinx.atomicfu", version="0.27.0" } ktlint = { id="org.jlleitschuh.gradle.ktlint", version="12.3.0" } nexus-publish = { id="io.github.gradle-nexus.publish-plugin", version="2.0.0" } -binary-compatibility-validator = { id="org.jetbrains.kotlinx.binary-compatibility-validator", version="0.17.0" } \ No newline at end of file +binary-compatibility-validator = { id="org.jetbrains.kotlinx.binary-compatibility-validator", version="0.17.0" } +android-library = { id="com.android.library", version.ref="android" } \ No newline at end of file diff --git a/providers/ofrep/README.md b/providers/ofrep/README.md new file mode 100644 index 0000000..ddae9ea --- /dev/null +++ b/providers/ofrep/README.md @@ -0,0 +1,93 @@ +# Environment Variables Kotlin Provider + +Environment Variables provider allows you to read feature flags from the [process's environment](https://en.wikipedia.org/wiki/Environment_variable). + +## Supported platforms + +| Supported | Platform | Supported versions | +|-----------|----------------------|--------------------------------------------------------------------------------| +| ❌ | Android | | +| ✅ | JVM | JDK 11+ | +| ✅ | Native | Linux x64 | +| ❌ | Native | [Other native targets](https://kotlinlang.org/docs/native-target-support.html) | +| ✅ | Javascript (Node.js) | | +| ❌ | Javascript (Browser) | | +| ❌ | Wasm | | + + +## Installation + +```xml + + dev.openfeature.kotlin.contrib.providers + env-var + 0.1.0 + +``` + +## Usage + +To use the `EnvVarProvider` create an instance and use it as a provider: + +```kotlin + val provider = EnvVarProvider() + OpenFeatureAPI.setProviderAndWait(provider) +``` + +### Configuring different methods for fetching environment variables + +This provider defines an `EnvironmentGateway` interface, which is used to access the actual environment variables. +The method [`platformSpecificEnvironmentGateway`][platformSpecificEnvironmentGateway], which is implemented for each supported platform, returns a default implementation. + +```kotlin + val testFake = EnvironmentGateway { arg -> "true" } // always returns true + + val provider = EnvVarProvider(testFake) + OpenFeatureAPI.getInstance().setProvider(provider) +``` + +### Key transformation + +This provider supports transformation of keys to support different patterns used for naming feature flags and for +naming environment variables, e.g. SCREAMING_SNAKE_CASE env variables vs. hyphen-case keys for feature flags. +It supports chaining/combining different transformers incl. self-written ones by providing a transforming function in the constructor. +Currently, the following transformations are supported out of the box: + +- converting to lower case (e.g. `Feature.Flag` => `feature.flag`) +- converting to UPPER CASE (e.g. `Feature.Flag` => `FEATURE.FLAG`) +- converting hyphen-case to SCREAMING_SNAKE_CASE (e.g. `Feature-Flag` => `FEATURE_FLAG`) +- convert to camelCase (e.g. `FEATURE_FLAG` => `featureFlag`) +- replace '_' with '.' (e.g. `feature_flag` => `feature.flag`) +- replace '.' with '_' (e.g. `feature.flag` => `feature_flag`) + +**Examples:** + +1. hyphen-case feature flag names to screaming snake-case environment variables: + + ```kotlin + // Definition of the EnvVarProvider: + val provider = EnvVarProvider(EnvironmentKeyTransformer.hyphenCaseToScreamingSnake()) + ``` + +2. chained/composed transformations: + + ```kotlin + // Definition of the EnvVarProvider: + val keyTransformer = EnvironmentKeyTransformer + .toLowerCaseTransformer() + .andThen(EnvironmentKeyTransformer.replaceUnderscoreWithDotTransformer()) + + val provider = EnvVarProvider(keyTransformer) + ``` + +3. freely defined transformation function: + + ```kotlin + // Definition of the EnvVarProvider: + val keyTransformer = EnvironmentKeyTransformer { key -> key.substring(1) } + val provider = EnvVarProvider(keyTransformer) + ``` + + + +[platformSpecificEnvironmentGateway]: src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/envvar/PlatformSpecificEnvironmentGateway.kt diff --git a/providers/ofrep/build.gradle.kts b/providers/ofrep/build.gradle.kts new file mode 100644 index 0000000..2261b75 --- /dev/null +++ b/providers/ofrep/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + id("com.android.library") version "8.10.1" +} + +kotlin { + androidTarget() +} + +kotlin { + androidTarget() + + sourceSets { + commonMain.dependencies { + // TODO: update to api(libs.openfeature.kotlin.sdk) + api("dev.openfeature:android-sdk:0.3.2") + + api(libs.kotlinx.coroutines.core) + api(libs.okhttp) + // TODO: replace with multiplatform JSON library + api("com.google.code.gson:gson:2.12.1") + } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.okhttp.mockwebserver) + } + } +} + +android { + namespace = "dev.openfeature.kotlin.contrib.providers.ofrep" + compileSdk = 35 + + defaultConfig { + minSdk = 21 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + publishing { + singleVariant("release") { + withJavadocJar() + withSourcesJar() + } + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt new file mode 100644 index 0000000..f6b1bf0 --- /dev/null +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt @@ -0,0 +1,272 @@ +package org.gofeatureflag.openfeature.ofrep + +import FlagDto +import dev.openfeature.sdk.EvaluationContext +import dev.openfeature.sdk.FeatureProvider +import dev.openfeature.sdk.Hook +import dev.openfeature.sdk.ImmutableContext +import dev.openfeature.sdk.ProviderEvaluation +import dev.openfeature.sdk.ProviderMetadata +import dev.openfeature.sdk.Value +import dev.openfeature.sdk.events.EventHandler +import dev.openfeature.sdk.events.OpenFeatureEvents +import dev.openfeature.sdk.exceptions.ErrorCode +import dev.openfeature.sdk.exceptions.OpenFeatureError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.gofeatureflag.openfeature.ofrep.bean.OfrepOptions +import org.gofeatureflag.openfeature.ofrep.bean.OfrepProviderMetadata +import org.gofeatureflag.openfeature.ofrep.controller.OfrepApi +import org.gofeatureflag.openfeature.ofrep.enum.BulkEvaluationStatus +import org.gofeatureflag.openfeature.ofrep.error.OfrepError +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.Timer +import java.util.TimerTask +import kotlin.reflect.KClass + + +class OfrepProvider( + private val ofrepOptions: OfrepOptions, + private val eventHandler: EventHandler = EventHandler(Dispatchers.IO) +) : FeatureProvider { + private val ofrepApi = OfrepApi(ofrepOptions) + override val hooks: List> + get() = listOf() + + override val metadata: ProviderMetadata + get() = OfrepProviderMetadata() + + private var evaluationContext: EvaluationContext? = null + private var inMemoryCache: Map = emptyMap() + private var retryAfter: Date? = null + private var pollingTimer: Timer? = null + + + override fun observe(): Flow = eventHandler.observe() + + override fun initialize(initialContext: EvaluationContext?) { + this.evaluationContext = initialContext + runBlocking { + launch { + try { + val bulkEvaluationStatus = evaluateFlags(initialContext ?: ImmutableContext()) + if (bulkEvaluationStatus == BulkEvaluationStatus.RATE_LIMITED) { + eventHandler.publish( + OpenFeatureEvents.ProviderError( + OfrepError.ApiTooManyRequestsError( + null + ) + ) + ) + return@launch + } + eventHandler.publish(OpenFeatureEvents.ProviderReady) + } catch (e: Exception) { + eventHandler.publish(OpenFeatureEvents.ProviderError(e)) + } + } + } + this.startPolling(this.ofrepOptions.pollingIntervalInMillis) + } + + /** + * Start polling for flag updates + */ + private fun startPolling(pollingIntervalInMillis: Long) { + val task: TimerTask = object : TimerTask() { + override fun run() { + runBlocking { + try { + val resp = + this@OfrepProvider.evaluateFlags(this@OfrepProvider.evaluationContext!!) + + when (resp) { + BulkEvaluationStatus.RATE_LIMITED, BulkEvaluationStatus.SUCCESS_NO_CHANGE -> { + // Nothing to do ! + // + // if rate limited: the provider should already be in stale status and + // we don't need to emit an event or call again the API + // + // if no change: the provider should already be in ready status and + // we don't need to emit an event if nothing has changed + } + + BulkEvaluationStatus.SUCCESS_UPDATED -> { + // TODO: we should migrate to configuration change event when it's available + // in the kotlin SDK + eventHandler.publish(OpenFeatureEvents.ProviderReady) + } + } + } catch (e: OfrepError.ApiTooManyRequestsError) { + // in that case the provider is just stale because we were not able to + eventHandler.publish(OpenFeatureEvents.ProviderStale) + } catch (e: Throwable) { + eventHandler.publish(OpenFeatureEvents.ProviderError(e)) + } + } + } + } + val timer = Timer() + timer.schedule(task, pollingIntervalInMillis, pollingIntervalInMillis) + this.pollingTimer = timer + } + + override fun getBooleanEvaluation( + key: String, + defaultValue: Boolean, + context: EvaluationContext? + ): ProviderEvaluation { + return genericEvaluation(key, Boolean::class) + } + + override fun getDoubleEvaluation( + key: String, + defaultValue: Double, + context: EvaluationContext? + ): ProviderEvaluation { + return genericEvaluation(key, Double::class) + } + + override fun getIntegerEvaluation( + key: String, + defaultValue: Int, + context: EvaluationContext? + ): ProviderEvaluation { + return genericEvaluation(key, Int::class) + } + + override fun getObjectEvaluation( + key: String, + defaultValue: Value, + context: EvaluationContext? + ): ProviderEvaluation { + return genericEvaluation(key, Object::class) + } + + override fun getStringEvaluation( + key: String, + defaultValue: String, + context: EvaluationContext? + ): ProviderEvaluation { + return genericEvaluation(key, String::class) + } + + override fun getProviderStatus(): OpenFeatureEvents { + return eventHandler.getProviderStatus() + } + + override fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { + this.eventHandler.publish(OpenFeatureEvents.ProviderStale) + this.evaluationContext = newContext + + runBlocking { + launch { + try { + val postBulkEvaluateFlags = evaluateFlags(newContext) + if (postBulkEvaluateFlags == BulkEvaluationStatus.RATE_LIMITED) { + // we don't emit event if the evaluation is rate limited because + // the provider is still stale + return@launch + } + eventHandler.publish(OpenFeatureEvents.ProviderReady) + } catch (e: Throwable) { + eventHandler.publish(OpenFeatureEvents.ProviderError(e)) + } + } + } + } + + override fun shutdown() { + this.pollingTimer?.cancel() + } + + private fun genericEvaluation( + key: String, + expectedType: KClass<*> + ): ProviderEvaluation { + val flag = this.inMemoryCache[key] ?: throw OpenFeatureError.FlagNotFoundError(key) + + if (flag.isError()) { + when (flag.errorCode) { + ErrorCode.FLAG_NOT_FOUND -> throw OpenFeatureError.FlagNotFoundError(key) + ErrorCode.INVALID_CONTEXT -> throw OpenFeatureError.InvalidContextError() + ErrorCode.PARSE_ERROR -> throw OpenFeatureError.ParseError( + flag.errorDetails ?: "parse error" + ) + + ErrorCode.PROVIDER_NOT_READY -> throw OpenFeatureError.ProviderNotReadyError() + ErrorCode.TARGETING_KEY_MISSING -> throw OpenFeatureError.TargetingKeyMissingError() + else -> throw OpenFeatureError.GeneralError(flag.errorDetails ?: "general error") + } + } + return flag.toProviderEvaluation(expectedType) + } + + + /** + * Evaluate the flags for the given context. + * It will store the flags in the in-memory cache, if any error occurs it will throw an exception. + */ + private suspend fun evaluateFlags(context: EvaluationContext): BulkEvaluationStatus { + if (this.retryAfter != null && this.retryAfter!! > Date()) { + return BulkEvaluationStatus.RATE_LIMITED + } + + try { + val postBulkEvaluateFlags = + this@OfrepProvider.ofrepApi.postBulkEvaluateFlags(context) + val ofrepEvalResp = postBulkEvaluateFlags.apiResponse + val httpResp = postBulkEvaluateFlags.httpResponse + + if (httpResp.code == 304) { + return BulkEvaluationStatus.SUCCESS_NO_CHANGE + } + + if (postBulkEvaluateFlags.isError()) { + when (ofrepEvalResp?.errorCode) { + ErrorCode.PROVIDER_NOT_READY -> throw OpenFeatureError.ProviderNotReadyError() + ErrorCode.PARSE_ERROR -> throw OpenFeatureError.ParseError( + ofrepEvalResp.errorDetails ?: "" + ) + + ErrorCode.TARGETING_KEY_MISSING -> throw OpenFeatureError.TargetingKeyMissingError() + ErrorCode.INVALID_CONTEXT -> throw OpenFeatureError.InvalidContextError() + else -> throw OpenFeatureError.GeneralError(ofrepEvalResp?.errorDetails ?: "") + } + } + val inMemoryCacheNew = ofrepEvalResp?.flags?.associateBy { it.key } ?: emptyMap() + this.inMemoryCache = inMemoryCacheNew + return BulkEvaluationStatus.SUCCESS_UPDATED + } catch (e: OfrepError.ApiTooManyRequestsError) { + this.retryAfter = calculateRetryDate(e.response?.headers?.get("Retry-After") ?: "") + return BulkEvaluationStatus.RATE_LIMITED + } catch (e: Throwable) { + throw e + } + } + + private fun calculateRetryDate(retryAfter: String): Date? { + if (retryAfter.isEmpty()) { + return null + } + + val retryDate: Calendar = Calendar.getInstance() + try { + // If retryAfter is a number, it represents seconds to wait. + val delayInSeconds = retryAfter.toInt() + retryDate.add(Calendar.SECOND, delayInSeconds) + } catch (e: NumberFormatException) { + // If retryAfter is not a number, it's an HTTP-date. + val dateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US) + dateFormat.timeZone = TimeZone.getTimeZone("GMT") + retryDate.time = dateFormat.parse(retryAfter) ?: return null + } + return retryDate.time + } +} \ No newline at end of file diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepApiRequest.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepApiRequest.kt new file mode 100644 index 0000000..f8c19ad --- /dev/null +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepApiRequest.kt @@ -0,0 +1,5 @@ +import dev.openfeature.sdk.EvaluationContext + +data class OfrepApiRequest(@Transient val ctx: EvaluationContext) { + private val context: Map = ctx.asObjectMap().plus("targetingKey" to ctx.getTargetingKey()) +} \ No newline at end of file diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepApiResponse.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepApiResponse.kt new file mode 100644 index 0000000..4ee153b --- /dev/null +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepApiResponse.kt @@ -0,0 +1,169 @@ +import dev.openfeature.sdk.EvaluationMetadata +import dev.openfeature.sdk.ProviderEvaluation +import dev.openfeature.sdk.Value +import dev.openfeature.sdk.exceptions.ErrorCode +import dev.openfeature.sdk.exceptions.OpenFeatureError +import kotlin.reflect.KClass + +data class OfrepApiResponse( + val flags: List? = null, + val errorCode: ErrorCode?, + val errorDetails: String? +) + +data class FlagDto( + val value: Any, + val key: String, + val reason: String, + val variant: String, + val errorCode: ErrorCode?, + val errorDetails: String?, + val metadata: Map? = emptyMap() +) { + fun isError(): Boolean { + return errorCode != null + } + + fun toProviderEvaluation(expectedType: KClass<*>): ProviderEvaluation { + if (!expectedType.isInstance(value)) { + val isSpecialCase = + expectedType == Int::class && value is Long && value.toInt().toLong() == value + if (!isSpecialCase) { + throw OpenFeatureError.TypeMismatchError("Type mismatch: expect ${expectedType.simpleName} - Unsupported type for: $value") + } + } + + if (expectedType == Int::class) { + val typedValue = (value as Long).toInt() + return ProviderEvaluation( + value = typedValue as T, + reason = reason, + variant = variant, + errorCode = errorCode, + errorMessage = errorDetails, + metadata = convertMetadata(metadata) + ) + } + + if (expectedType == Object::class) { + if (value is List<*>) { + val typedValue = Value.List(convertList(value as List)) + return ProviderEvaluation( + value = typedValue as T, + reason = reason, + variant = variant, + errorCode = errorCode, + errorMessage = errorDetails, + metadata = convertMetadata(metadata) + ) + } else if (value is Map<*, *>) { + val typedValue = convertObjectToStructure(value) + return ProviderEvaluation( + value = typedValue as T, + reason = reason, + variant = variant, + errorCode = errorCode, + errorMessage = errorDetails, + metadata = convertMetadata(metadata) + ) + } else { + throw IllegalArgumentException("Unsupported type for: $value") + } + } + + + + @Suppress("unchecked_cast") + return ProviderEvaluation( + value = value as T, + reason = reason, + variant = variant, + errorCode = errorCode, + errorMessage = errorDetails, + metadata = convertMetadata(metadata) + ) + } + + private fun convertMetadata(inputMap: Map?): EvaluationMetadata { + //check that inputMap is null or empty + if (inputMap.isNullOrEmpty()) { + return EvaluationMetadata.EMPTY + } + + val metadataBuilder = EvaluationMetadata.builder() + inputMap.forEach { entry -> + // switch case on entry.value types + when (entry.value) { + is String -> { + metadataBuilder.putString(entry.key, entry.value as String) + } + + is Boolean -> { + metadataBuilder.putBoolean(entry.key, entry.value as Boolean) + } + + is Int -> { + metadataBuilder.putInt(entry.key, entry.value as Int) + } + + is Long -> { + metadataBuilder.putInt(entry.key, (entry.value as Long).toInt()) + } + + is Double -> { + metadataBuilder.putDouble(entry.key, entry.value as Double) + } + } + } + + return metadataBuilder.build() + } + + private fun convertList(inputList: List<*>): List { + return inputList.map { item -> + when (item) { + is String -> Value.String(item) + is Boolean -> Value.Boolean(item) + is Long -> Value.Integer(item.toInt()) + is Double -> Value.Double(item) + is java.util.Date -> Value.Date(item) + is Map<*, *> -> { + @Suppress("unchecked_cast") + Value.Structure(item as Map) + } + + is List<*> -> { + @Suppress("unchecked_cast") + Value.List(convertList(item as List)) + } + + else -> throw IllegalArgumentException( + "Unsupported type for: $item" + ) + } + } + } + + private fun convertObjectToStructure(obj: Any): Value.Structure { + if (obj !is Map<*, *>) { + throw IllegalArgumentException("Object must be a Map") + } + val convertedMap = obj.entries.associate { (key, value) -> + if (key !is String) { + throw IllegalArgumentException("Map key must be a String") + } + key to when (value) { + is String -> Value.String(value) + is Boolean -> Value.Boolean(value) + is Long -> Value.Integer(value.toInt()) + is Double -> Value.Double(value) + is java.util.Date -> Value.Date(value) + is Map<*, *> -> convertObjectToStructure(value) + is List<*> -> Value.List(convertList(value as List)) + else -> throw IllegalArgumentException("Unsupported type for: $value") + } + } + return Value.Structure(convertedMap) + } + +} diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepOptions.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepOptions.kt new file mode 100644 index 0000000..6ca62b8 --- /dev/null +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepOptions.kt @@ -0,0 +1,44 @@ +package org.gofeatureflag.openfeature.ofrep.bean + +import okhttp3.Headers + + +data class OfrepOptions( + /** + * (mandatory) endpoint contains the DNS of your GO Feature Flag relay proxy + * example: https://mydomain.com/gofeatureflagproxy/ + */ + val endpoint: String, + + /** + * (optional) timeout in millisecond we are waiting when calling the + * go-feature-flag relay proxy API. + * Default: 10000 ms + */ + val timeout: Long = 10000, + + /** + * (optional) maxIdleConnections is the maximum number of connexions in the connexion pool. + * Default: 1000 + */ + val maxIdleConnections: Int = 1000, + + /** + * (optional) keepAliveDuration is the time in millisecond we keep the connexion open. + * Default: 7200000 (2 hours) + */ + val keepAliveDuration: Long = 7200000, + + + /** + * (optional) headers to add to the OFREP calls + * Default: empty + */ + val headers: Headers? = null, + + /** + * (optional) polling interval in millisecond to refresh the flags + * Default: 300000 (5 minutes) + */ + val pollingIntervalInMillis: Long = 300000 +) diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepProviderMetadata.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepProviderMetadata.kt new file mode 100644 index 0000000..b83731a --- /dev/null +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepProviderMetadata.kt @@ -0,0 +1,8 @@ +package org.gofeatureflag.openfeature.ofrep.bean + +import dev.openfeature.sdk.ProviderMetadata + +class OfrepProviderMetadata : ProviderMetadata { + override val name: String + get() = "OFREP Provider" +} \ No newline at end of file diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/PostBulkEvaluationResult.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/PostBulkEvaluationResult.kt new file mode 100644 index 0000000..bedda64 --- /dev/null +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/PostBulkEvaluationResult.kt @@ -0,0 +1,12 @@ +package org.gofeatureflag.openfeature.ofrep.bean + +import OfrepApiResponse + +data class PostBulkEvaluationResult( + val apiResponse: OfrepApiResponse?, + val httpResponse: okhttp3.Response +) { + fun isError(): Boolean { + return apiResponse?.errorCode != null + } +} diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApi.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApi.kt new file mode 100644 index 0000000..e04e17a --- /dev/null +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApi.kt @@ -0,0 +1,100 @@ +package org.gofeatureflag.openfeature.ofrep.controller + +import OfrepApiRequest +import OfrepApiResponse +import com.google.gson.GsonBuilder +import com.google.gson.JsonSyntaxException +import com.google.gson.ToNumberPolicy +import dev.openfeature.sdk.EvaluationContext +import dev.openfeature.sdk.exceptions.OpenFeatureError +import okhttp3.ConnectionPool +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import org.gofeatureflag.openfeature.ofrep.bean.OfrepOptions +import org.gofeatureflag.openfeature.ofrep.bean.PostBulkEvaluationResult +import org.gofeatureflag.openfeature.ofrep.error.OfrepError +import java.util.concurrent.TimeUnit + +class OfrepApi(private val options: OfrepOptions) { + companion object { + private val gson = + GsonBuilder().setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE).create() + } + + private var httpClient: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(this.options.timeout, TimeUnit.MILLISECONDS) + .readTimeout(this.options.timeout, TimeUnit.MILLISECONDS) + .callTimeout(this.options.timeout, TimeUnit.MILLISECONDS) + .writeTimeout(this.options.timeout, TimeUnit.MILLISECONDS) + .connectionPool( + ConnectionPool( + this.options.maxIdleConnections, + this.options.keepAliveDuration, + TimeUnit.MILLISECONDS + ) + ) + .build() + private var parsedEndpoint: HttpUrl = + options.endpoint.toHttpUrlOrNull() + ?: throw OfrepError.InvalidOptionsError("invalid endpoint configuration: ${options.endpoint}") + private var etag: String? = null + + /** + * Call the OFREP API to evaluate in bulk the flags for the given context. + */ + suspend fun postBulkEvaluateFlags(context: EvaluationContext?): PostBulkEvaluationResult { + val nonNullContext = + context ?: throw OpenFeatureError.InvalidContextError("EvaluationContext is null") + validateContext(nonNullContext) + + val urlBuilder = parsedEndpoint.newBuilder() + .addEncodedPathSegment("ofrep") + .addEncodedPathSegment("v1") + .addEncodedPathSegment("evaluate") + .addEncodedPathSegment("flags") + + val mediaType = "application/json".toMediaTypeOrNull() + val requestBody = gson.toJson(OfrepApiRequest(nonNullContext)).toRequestBody(mediaType) + val reqBuilder = okhttp3.Request.Builder() + .url(urlBuilder.build()) + .post(requestBody) + + // add all the headers + options.headers?.let { reqBuilder.headers(it) } + etag?.let { reqBuilder.addHeader("If-None-Match", it) } + httpClient.newCall(reqBuilder.build()).execute().use { response -> + when (response.code) { + 401 -> throw OfrepError.ApiUnauthorizedError(response) + 403 -> throw OfrepError.ForbiddenError(response) + 429 -> throw OfrepError.ApiTooManyRequestsError(response) + 304 -> return PostBulkEvaluationResult(null, response) + in 200..299, 400 -> { + try { + response.headers["ETag"].let { this.etag = it } + val ofrepResp = + gson.fromJson(response.body?.string(), OfrepApiResponse::class.java) + return PostBulkEvaluationResult(ofrepResp, response) + } catch (e: JsonSyntaxException) { + throw OfrepError.UnmarshallError(e) + } catch (e: Exception) { + println(e) + throw OfrepError.UnexpectedResponseError(response) + } + } + + else -> { + throw OfrepError.UnexpectedResponseError(response) + } + } + } + } + + private fun validateContext(context: EvaluationContext) { + if (context.getTargetingKey().isEmpty()) { + throw OpenFeatureError.TargetingKeyMissingError() + } + } +} \ No newline at end of file diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/enum/BulkEvaluationStatus.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/enum/BulkEvaluationStatus.kt new file mode 100644 index 0000000..cea1377 --- /dev/null +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/enum/BulkEvaluationStatus.kt @@ -0,0 +1,7 @@ +package org.gofeatureflag.openfeature.ofrep.enum + +enum class BulkEvaluationStatus { + SUCCESS_NO_CHANGE, + SUCCESS_UPDATED, + RATE_LIMITED +} \ No newline at end of file diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/error/OfrepError.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/error/OfrepError.kt new file mode 100644 index 0000000..c44d865 --- /dev/null +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/error/OfrepError.kt @@ -0,0 +1,10 @@ +package org.gofeatureflag.openfeature.ofrep.error + +sealed class OfrepError : Exception() { + class ApiUnauthorizedError(val response: okhttp3.Response) : OfrepError() + class ForbiddenError(val response: okhttp3.Response) : OfrepError() + class ApiTooManyRequestsError(val response: okhttp3.Response? = null) : OfrepError() + class UnexpectedResponseError(val response: okhttp3.Response) : OfrepError() + class UnmarshallError(val e: Exception) : OfrepError() + class InvalidOptionsError(override val message: String?) : OfrepError() +} \ No newline at end of file diff --git a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt new file mode 100644 index 0000000..21979fa --- /dev/null +++ b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt @@ -0,0 +1,647 @@ +package org.gofeatureflag.openfeature.ofrep + +import dev.openfeature.sdk.EvaluationContext +import dev.openfeature.sdk.EvaluationMetadata +import dev.openfeature.sdk.FlagEvaluationDetails +import dev.openfeature.sdk.ImmutableContext +import dev.openfeature.sdk.OpenFeatureAPI +import dev.openfeature.sdk.Value +import dev.openfeature.sdk.events.OpenFeatureEvents +import dev.openfeature.sdk.events.observe +import dev.openfeature.sdk.exceptions.ErrorCode +import dev.openfeature.sdk.exceptions.OpenFeatureError +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import okhttp3.Headers +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.gofeatureflag.openfeature.ofrep.bean.OfrepOptions +import org.gofeatureflag.openfeature.ofrep.error.OfrepError +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.nio.file.Files +import java.nio.file.Paths +import java.util.UUID + +class OfrepProviderTest { + private var mockWebServer: MockWebServer? = null + private var defaultEvalCtx: EvaluationContext = + ImmutableContext(targetingKey = UUID.randomUUID().toString()) + + @Before + fun before() { + mockWebServer = MockWebServer() + mockWebServer!!.start(10031) + } + + @After + fun after() { + OpenFeatureAPI.shutdown() + OpenFeatureAPI.clearProvider() + OpenFeatureAPI.clearHooks() + mockWebServer?.shutdown() + mockWebServer = null + } + + @Test + fun `should have a provider metadata`() { + val provider = OfrepProvider(OfrepOptions(endpoint = "http://localhost:1031")) + assertEquals("OFREP Provider", provider.metadata.name) + } + + @Test + fun `should be in Fatal status if 401 error during initialise`(): Unit = + runBlocking { + enqueueMockResponse("ofrep/valid_api_response.json", 401) + + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + var providerErrorReceived = false + + val coroutineScope = CoroutineScope(Dispatchers.IO) + coroutineScope.launch { + provider.observe().take(1).collect { + providerErrorReceived = true + } + } + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + assert(providerErrorReceived) { "ProviderError event was not received" } + } + + @Test + fun `should be in Fatal status if 403 error during initialise`(): Unit = + runBlocking { + enqueueMockResponse("ofrep/valid_api_response.json", 403) + + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + var providerErrorReceived = false + + val coroutineScope = CoroutineScope(Dispatchers.IO) + coroutineScope.launch { + provider.observe().take(1).collect { + providerErrorReceived = true + } + } + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + assert(providerErrorReceived) { "ProviderError event was not received" } + } + + @Test + fun `should be in Error status if 429 error during initialise`(): Unit = + runBlocking { + enqueueMockResponse( + "ofrep/valid_api_response.json", + 429, + Headers.headersOf("Retry-After", "3"), + ) + + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + var providerErrorReceived = false + var exceptionReceived: Throwable? = null + + val coroutineScope = CoroutineScope(Dispatchers.IO) + coroutineScope.launch { + provider.observe().take(1).collect { + providerErrorReceived = true + exceptionReceived = it.error + } + } + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + assert(providerErrorReceived) { "ProviderError event was not received" } + assert(exceptionReceived is OfrepError.ApiTooManyRequestsError) { "The exception is not of type ApiTooManyRequestsError" } + } + + @Test + fun `should be in Error status if error targeting key is empty`(): Unit = + runBlocking { + enqueueMockResponse("ofrep/valid_api_response.json", 200) + + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + var providerErrorReceived = false + var exceptionReceived: Throwable? = null + + val coroutineScope = CoroutineScope(Dispatchers.IO) + coroutineScope.launch { + provider.observe().take(1).collect { + providerErrorReceived = true + exceptionReceived = it.error + } + } + val evalCtx = ImmutableContext(targetingKey = "") + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, evalCtx) + assert(providerErrorReceived) { "ProviderError event was not received" } + assert( + exceptionReceived is OpenFeatureError.TargetingKeyMissingError, + ) { "The exception is not of type TargetingKeyMissingError" } + } + + @Test + fun `should be in Error status if error targeting key is missing`(): Unit = + runBlocking { + enqueueMockResponse("ofrep/valid_api_response.json", 200) + + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + var providerErrorReceived = false + var exceptionReceived: Throwable? = null + + val coroutineScope = CoroutineScope(Dispatchers.IO) + coroutineScope.launch { + provider.observe().take(1).collect { + providerErrorReceived = true + exceptionReceived = it.error + } + } + val evalCtx = ImmutableContext() + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, evalCtx) + assert(providerErrorReceived) { "ProviderError event was not received" } + assert( + exceptionReceived is OpenFeatureError.TargetingKeyMissingError, + ) { "The exception is not of type TargetingKeyMissingError" } + } + + @Test + fun `should be in error status if error invalid context`(): Unit = + runBlocking { + enqueueMockResponse("ofrep/invalid_context.json", 400) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + var providerErrorReceived = false + var exceptionReceived: Throwable? = null + + val coroutineScope = CoroutineScope(Dispatchers.IO) + coroutineScope.launch { + provider.observe().take(1).collect { + providerErrorReceived = true + exceptionReceived = it.error + } + } + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + assert(providerErrorReceived) { "ProviderError event was not received" } + assert(exceptionReceived is OpenFeatureError.InvalidContextError) { "The exception is not of type InvalidContextError" } + } + + @Test + fun `should be in error status if error parse error`(): Unit = + runBlocking { + enqueueMockResponse("ofrep/parse_error.json", 400) + + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + var providerErrorReceived = false + var exceptionReceived: Throwable? = null + + val coroutineScope = CoroutineScope(Dispatchers.IO) + coroutineScope.launch { + provider.observe().take(1).collect { + providerErrorReceived = true + exceptionReceived = it.error + } + } + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + assert(providerErrorReceived) { "ProviderError event was not received" } + assert(exceptionReceived is OpenFeatureError.ParseError) { "The exception is not of type ParseError" } + } + + @Test + fun `should return a flag not found error if the flag does not exist`(): Unit = + runBlocking { + enqueueMockResponse("ofrep/valid_api_response.json", 200) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + val client = OpenFeatureAPI.getClient() + val got = client.getBooleanDetails("non-existent-flag", false) + val want = + FlagEvaluationDetails( + flagKey = "non-existent-flag", + value = false, + variant = null, + reason = "ERROR", + errorCode = ErrorCode.FLAG_NOT_FOUND, + errorMessage = "Could not find flag named: non-existent-flag", + ) + assertEquals(want, got) + } + + @Test + fun `should return evaluation details if the flag exists`(): Unit = + runBlocking { + enqueueMockResponse( + "ofrep/valid_api_short_response.json", + 200, + ) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + val client = OpenFeatureAPI.getClient() + val got = client.getStringDetails("title-flag", "default") + val want = + FlagEvaluationDetails( + flagKey = "title-flag", + value = "GO Feature Flag", + variant = "default_title", + reason = "DEFAULT", + errorCode = null, + errorMessage = null, + metadata = + EvaluationMetadata + .builder() + .putString("description", "This flag controls the title of the feature flag") + .putString("title", "Feature Flag Title") + .build(), + ) + assertEquals(want, got) + } + + @Test + fun `should return parse error if the API returns the error`(): Unit = + runBlocking { + enqueueMockResponse( + "ofrep/valid_1_flag_in_parse_error.json", + 200, + ) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + val client = OpenFeatureAPI.getClient() + val got = client.getStringDetails("my-other-flag", "default") + val want = + FlagEvaluationDetails( + flagKey = "my-other-flag", + value = "default", + variant = null, + reason = "ERROR", + errorCode = ErrorCode.PARSE_ERROR, + errorMessage = "Error details about PARSE_ERROR", + ) + assertEquals(want, got) + } + + @Test + fun `should send a context changed event if context has changed`(): Unit = + runBlocking { + enqueueMockResponse( + "ofrep/valid_api_response.json", + 200, + ) + enqueueMockResponse( + "ofrep/valid_api_response_2.json", + 200, + ) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + + // TODO: should change when we have a way to observe context changes event + // check issue https://github.com/open-feature/kotlin-sdk/issues/107 + var providerStaleEventReceived = false + var providerReadyEventReceived = false + + val coroutineScope = CoroutineScope(Dispatchers.IO) + coroutineScope.launch { + provider.observe().take(1).collect { + providerStaleEventReceived = true + } + provider.observe().take(1).collect { + providerReadyEventReceived = true + } + } + Thread.sleep(1000) // waiting to be sure that setEvaluationContext has been processed + val newEvalCtx = ImmutableContext(targetingKey = UUID.randomUUID().toString()) + OpenFeatureAPI.setEvaluationContext(newEvalCtx) + Thread.sleep(1000) // waiting to be sure that setEvaluationContext has been processed + assert(providerStaleEventReceived) { "ProviderStale event was not received" } + assert(providerReadyEventReceived) { "ProviderReady event was not received" } + } + + @Test + fun `should not try to call the API before Retry-After header`(): Unit = + runBlocking { + mockWebServer!!.enqueue( + MockResponse() + .setResponseCode(429) + .setHeader("Retry-After", "3"), + ) + val provider = + OfrepProvider( + OfrepOptions( + pollingIntervalInMillis = 100, + endpoint = mockWebServer?.url("/").toString(), + ), + ) + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + val client = OpenFeatureAPI.getClient() + client.getStringDetails("my-other-flag", "default") + client.getStringDetails("my-other-flag", "default") + Thread.sleep(2000) // we wait 2 seconds to let the polling loop run + assertEquals(1, mockWebServer!!.requestCount) + } + + @Test + fun `should return a valid evaluation for Boolean`(): Unit = + runBlocking { + enqueueMockResponse("ofrep/valid_api_response.json", 200) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + val client = OpenFeatureAPI.getClient() + val got = client.getBooleanDetails("bool-flag", false) + val want = + FlagEvaluationDetails( + flagKey = "bool-flag", + value = true, + variant = "variantA", + reason = "TARGETING_MATCH", + errorCode = null, + errorMessage = null, + metadata = + EvaluationMetadata + .builder() + .putBoolean("additionalProp1", true) + .putString("additionalProp2", "value") + .putInt("additionalProp3", 123) + .build(), + ) + assertEquals(want, got) + } + + @Test + fun `should return a valid evaluation for Int`(): Unit = + runBlocking { + enqueueMockResponse("ofrep/valid_api_response.json", 200) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + val client = OpenFeatureAPI.getClient() + val got = client.getIntegerDetails("int-flag", 1) + val want = + FlagEvaluationDetails( + flagKey = "int-flag", + value = 1234, + variant = "variantA", + reason = "TARGETING_MATCH", + errorCode = null, + errorMessage = null, + metadata = + EvaluationMetadata + .builder() + .putBoolean("additionalProp1", true) + .putString("additionalProp2", "value") + .putInt("additionalProp3", 123) + .build(), + ) + assertEquals(want, got) + } + + @Test + fun `should return a valid evaluation for Double`(): Unit = + runBlocking { + enqueueMockResponse("ofrep/valid_api_response.json", 200) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + val client = OpenFeatureAPI.getClient() + val got = client.getDoubleDetails("double-flag", 1.1) + val want = + FlagEvaluationDetails( + flagKey = "double-flag", + value = 12.34, + variant = "variantA", + reason = "TARGETING_MATCH", + errorCode = null, + errorMessage = null, + metadata = + EvaluationMetadata + .builder() + .putBoolean("additionalProp1", true) + .putString("additionalProp2", "value") + .putInt("additionalProp3", 123) + .build(), + ) + assertEquals(want, got) + } + + @Test + fun `should return a valid evaluation for String`(): Unit = + runBlocking { + enqueueMockResponse("ofrep/valid_api_response.json", 200) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + val client = OpenFeatureAPI.getClient() + val got = client.getStringDetails("string-flag", "default") + val want = + FlagEvaluationDetails( + flagKey = "string-flag", + value = "1234value", + variant = "variantA", + reason = "TARGETING_MATCH", + errorCode = null, + errorMessage = null, + metadata = + EvaluationMetadata + .builder() + .putBoolean("additionalProp1", true) + .putString("additionalProp2", "value") + .putInt("additionalProp3", 123) + .build(), + ) + assertEquals(want, got) + } + + @Test + fun `should return a valid evaluation for List`(): Unit = + runBlocking { + enqueueMockResponse("ofrep/valid_api_response.json", 200) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + val client = OpenFeatureAPI.getClient() + val got = + client.getObjectDetails( + "array-flag", + Value.List(MutableList(1) { Value.Integer(1234567890) }), + ) + + val want = + FlagEvaluationDetails( + flagKey = "array-flag", + value = Value.List(listOf(Value.Integer(1234), Value.Integer(5678))), + variant = "variantA", + reason = "TARGETING_MATCH", + errorCode = null, + errorMessage = null, + metadata = + EvaluationMetadata + .builder() + .putBoolean("additionalProp1", true) + .putString("additionalProp2", "value") + .putInt("additionalProp3", 123) + .build(), + ) + assertEquals(want, got) + } + + @Test + fun `should return a valid evaluation for Map`(): Unit = + runBlocking { + enqueueMockResponse("ofrep/valid_api_response.json", 200) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + val client = OpenFeatureAPI.getClient() + val got = + client.getObjectDetails( + "object-flag", + Value.Structure( + mapOf( + "default" to Value.Boolean(true), + ), + ), + ) + + val want = + FlagEvaluationDetails( + flagKey = "object-flag", + value = + Value.Structure( + mapOf( + "testValue" to + Value.Structure( + mapOf( + "toto" to Value.Integer(1234), + ), + ), + ), + ), + variant = "variantA", + reason = "TARGETING_MATCH", + errorCode = null, + errorMessage = null, + metadata = + EvaluationMetadata + .builder() + .putBoolean("additionalProp1", true) + .putString("additionalProp2", "value") + .putInt("additionalProp3", 123) + .build(), + ) + assertEquals(want, got) + } + + @Test + fun `should return TypeMismatch Bool`(): Unit = + runBlocking { + enqueueMockResponse("ofrep/valid_api_response.json", 200) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + val client = OpenFeatureAPI.getClient() + val got = client.getBooleanDetails("object-flag", false) + val want = + FlagEvaluationDetails( + flagKey = "object-flag", + value = false, + variant = null, + reason = "ERROR", + errorCode = ErrorCode.TYPE_MISMATCH, + errorMessage = "Type mismatch: expect Boolean - Unsupported type for: {testValue={toto=1234}}", + ) + assertEquals(want, got) + } + + @Test + fun `should return TypeMismatch String`(): Unit = + runBlocking { + enqueueMockResponse("ofrep/valid_api_response.json", 200) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + val client = OpenFeatureAPI.getClient() + val got = client.getStringDetails("object-flag", "default") + val want = + FlagEvaluationDetails( + flagKey = "object-flag", + value = "default", + variant = null, + reason = "ERROR", + errorCode = ErrorCode.TYPE_MISMATCH, + errorMessage = "Type mismatch: expect String - Unsupported type for: {testValue={toto=1234}}", + ) + assertEquals(want, got) + } + + @Test + fun `should return TypeMismatch Double`(): Unit = + runBlocking { + enqueueMockResponse("ofrep/valid_api_response.json", 200) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + val client = OpenFeatureAPI.getClient() + val got = client.getDoubleDetails("object-flag", 1.233) + val want = + FlagEvaluationDetails( + flagKey = "object-flag", + value = 1.233, + variant = null, + reason = "ERROR", + errorCode = ErrorCode.TYPE_MISMATCH, + errorMessage = "Type mismatch: expect Double - Unsupported type for: {testValue={toto=1234}}", + ) + assertEquals(want, got) + } + + @Test + fun `should have different result if waiting for next polling interval`(): Unit = + runBlocking { + enqueueMockResponse( + "ofrep/valid_api_short_response.json", + 200, + ) + enqueueMockResponse( + "ofrep/valid_api_response_2.json", + 200, + ) + + val provider = + OfrepProvider( + OfrepOptions( + pollingIntervalInMillis = 100, + endpoint = mockWebServer?.url("/").toString(), + ), + ) + OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + val client = OpenFeatureAPI.getClient() + val got = client.getStringDetails("badge-class2", "default") + val want = + FlagEvaluationDetails( + flagKey = "badge-class2", + value = "green", + variant = "nocolor", + reason = "DEFAULT", + errorCode = null, + errorMessage = null, + ) + assertEquals(want, got) + Thread.sleep(1000) + val got2 = client.getStringDetails("badge-class2", "default") + val want2 = + FlagEvaluationDetails( + flagKey = "badge-class2", + value = "blue", + variant = "xxxx", + reason = "TARGETING_MATCH", + errorCode = null, + errorMessage = null, + ) + assertEquals(want2, got2) + } + + private fun enqueueMockResponse( + fileName: String, + responseCode: Int = 200, + headers: Headers? = null, + ) { + val jsonFilePath = + javaClass.classLoader?.getResource(fileName)?.file + val jsonString = String(Files.readAllBytes(Paths.get(jsonFilePath))) + var resp = + MockResponse() + .setBody(jsonString.trimIndent()) + .setResponseCode(responseCode) + if (headers != null) { + resp = resp.setHeaders(headers) + } + mockWebServer!!.enqueue(resp) + } +} diff --git a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApiTest.kt b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApiTest.kt new file mode 100644 index 0000000..431a66b --- /dev/null +++ b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApiTest.kt @@ -0,0 +1,374 @@ +package org.gofeatureflag.openfeature.ofrep.controller + +import FlagDto +import OfrepApiResponse +import dev.openfeature.sdk.ImmutableContext +import dev.openfeature.sdk.Value +import dev.openfeature.sdk.exceptions.ErrorCode +import dev.openfeature.sdk.exceptions.OpenFeatureError +import junit.framework.TestCase.assertFalse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.gofeatureflag.openfeature.ofrep.bean.OfrepOptions +import org.gofeatureflag.openfeature.ofrep.error.OfrepError +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.nio.file.Files +import java.nio.file.Paths + +class OfrepApiTest { + private var mockWebServer: MockWebServer? = null + + @Before + fun before() { + mockWebServer = MockWebServer() + mockWebServer!!.start(10031) + } + + @After + fun after() { + mockWebServer!!.shutdown() + } + + @Test + fun shouldReturnAValidEvaluationResponse() = + runBlocking { + val jsonFilePath = + javaClass.classLoader?.getResource("ofrep/valid_api_short_response.json")?.file + val jsonString = + String( + withContext(Dispatchers.IO) { + Files.readAllBytes(Paths.get(jsonFilePath)) + }, + ) + + mockWebServer!!.enqueue(MockResponse().setBody(jsonString.trimIndent())) + + val ofrepApi = + OfrepApi( + OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + ) + val ctx = + ImmutableContext( + targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd", + attributes = + mutableMapOf( + "email" to Value.String("batman@gofeatureflag.org"), + ), + ) + val res = ofrepApi.postBulkEvaluateFlags(ctx) + assertEquals(200, res.httpResponse.code) + + val expected = + OfrepApiResponse( + flags = + listOf( + FlagDto( + key = "badge-class2", + value = "green", + reason = "DEFAULT", + variant = "nocolor", + errorCode = null, + errorDetails = null, + metadata = null, + ), + FlagDto( + key = "hide-logo", + value = false, + reason = "STATIC", + variant = "var_false", + errorCode = null, + errorDetails = null, + metadata = null, + ), + FlagDto( + key = "title-flag", + value = "GO Feature Flag", + reason = "DEFAULT", + variant = "default_title", + errorCode = null, + errorDetails = null, + metadata = + hashMapOf( + "description" to "This flag controls the title of the feature flag", + "title" to "Feature Flag Title", + ), + ), + ), + null, + null, + ) + assertEquals(expected, res.apiResponse) + } + + @Test + fun shouldThrowAnUnauthorizedError(): Unit = + runBlocking { + mockWebServer!!.enqueue(MockResponse().setBody("{}").setResponseCode(401)) + val ofrepApi = + OfrepApi( + OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + ) + val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") + assertThrows(OfrepError.ApiUnauthorizedError::class.java) { + runBlocking { + ofrepApi.postBulkEvaluateFlags(ctx) + } + } + } + + @Test + fun shouldThrowAForbiddenError(): Unit = + runBlocking { + mockWebServer!!.enqueue(MockResponse().setBody("{}").setResponseCode(403)) + val ofrepApi = + OfrepApi( + OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + ) + val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") + assertThrows(OfrepError.ForbiddenError::class.java) { + runBlocking { + ofrepApi.postBulkEvaluateFlags(ctx) + } + } + } + + @Test + fun shouldThrowTooManyRequest(): Unit = + runBlocking { + mockWebServer!!.enqueue( + MockResponse() + .setBody("{}") + .setResponseCode(429) + .setHeader("Retry-After", "120"), + ) + val ofrepApi = + OfrepApi( + OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + ) + val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") + try { + ofrepApi.postBulkEvaluateFlags(ctx) + assertTrue("we exited the try block without throwing an exception", false) + } catch (e: OfrepError.ApiTooManyRequestsError) { + assertEquals(429, e.response?.code) + assertEquals(e.response?.headers?.get("Retry-After"), "120") + } + } + + @Test + fun shouldThrowUnexpectedError(): Unit = + runBlocking { + mockWebServer!!.enqueue( + MockResponse() + .setBody("{}") + .setResponseCode(500), + ) + val ofrepApi = + OfrepApi( + OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + ) + + val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") + assertThrows(OfrepError.UnexpectedResponseError::class.java) { + runBlocking { + ofrepApi.postBulkEvaluateFlags(ctx) + } + } + } + + @Test + fun shouldReturnAnEvaluationResponseInError(): Unit = + runBlocking { + mockWebServer!!.enqueue( + MockResponse() + .setBody( + """ + {"errorCode": "INVALID_CONTEXT", "errorDetails":"explanation of the error"} + """.trimIndent(), + ).setResponseCode(400), + ) + val ofrepApi = + OfrepApi( + OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + ) + + val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") + val resp = ofrepApi.postBulkEvaluateFlags(ctx) + assertTrue(resp.isError()) + assertEquals(ErrorCode.INVALID_CONTEXT, resp.apiResponse?.errorCode) + assertEquals("explanation of the error", resp.apiResponse?.errorDetails) + assertEquals(400, resp.httpResponse.code) + } + + @Test + fun shouldReturnaEvaluationResponseIfWeReceiveA304(): Unit = + runBlocking { + mockWebServer!!.enqueue( + MockResponse() + .setBody("{}") + .setResponseCode(304), + ) + val ofrepApi = + OfrepApi( + OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + ) + + val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") + val resp = ofrepApi.postBulkEvaluateFlags(ctx) + assertFalse(resp.isError()) + assertEquals(304, resp.httpResponse.code) + } + + @Test + fun shouldThrowTargetingKeyMissingErrorWithNoTargetingKey(): Unit = + runBlocking { + mockWebServer!!.enqueue( + MockResponse() + .setBody("{}") + .setResponseCode(304), + ) + val ofrepApi = + OfrepApi( + OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + ) + + val ctx = ImmutableContext(targetingKey = "") + assertThrows(OpenFeatureError.TargetingKeyMissingError::class.java) { + runBlocking { + ofrepApi.postBulkEvaluateFlags(ctx) + } + } + } + + @Test + fun shouldThrowUnmarshallErrorWithInvalidJson(): Unit = + runBlocking { + val jsonFilePath = + javaClass.classLoader?.getResource("ofrep/invalid_api_response.json")?.file + val jsonString = + String( + withContext(Dispatchers.IO) { + Files.readAllBytes(Paths.get(jsonFilePath)) + }, + ) + + mockWebServer!!.enqueue( + MockResponse().setBody(jsonString.trimIndent()).setResponseCode(400), + ) + val ofrepApi = + OfrepApi( + OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + ) + + val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") + assertThrows(OfrepError.UnmarshallError::class.java) { + runBlocking { + ofrepApi.postBulkEvaluateFlags(ctx) + } + } + } + + @Test + fun shouldThrowWithInvalidOptions(): Unit = + runBlocking { + val jsonFilePath = + javaClass.classLoader?.getResource("ofrep/invalid_api_response.json")?.file + val jsonString = + String( + withContext(Dispatchers.IO) { + Files.readAllBytes(Paths.get(jsonFilePath)) + }, + ) + + mockWebServer!!.enqueue( + MockResponse().setBody(jsonString.trimIndent()).setResponseCode(400), + ) + assertThrows(OfrepError.InvalidOptionsError::class.java) { + runBlocking { + OfrepApi(OfrepOptions(endpoint = "invalid_url")) + } + } + } + + @Test + fun shouldETagShouldNotMatch(): Unit = + runBlocking { + val jsonFilePath = + javaClass.classLoader?.getResource("ofrep/valid_api_response.json")?.file + val jsonString = + String( + withContext(Dispatchers.IO) { + Files.readAllBytes(Paths.get(jsonFilePath)) + }, + ) + + mockWebServer!!.enqueue( + MockResponse() + .setBody(jsonString.trimIndent()) + .setResponseCode(200) + .addHeader("ETag", "123"), + ) + mockWebServer!!.enqueue( + MockResponse() + .setResponseCode(304) + .addHeader("ETag", "123"), + ) + + val ofrepApi = + OfrepApi( + OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + ) + + val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") + val eval1 = ofrepApi.postBulkEvaluateFlags(ctx) + val eval2 = ofrepApi.postBulkEvaluateFlags(ctx) + assertEquals(eval1.httpResponse.code, 200) + assertEquals(eval2.httpResponse.code, 304) + assertEquals(2, mockWebServer!!.requestCount) + } + + @Test + fun shouldHaveIfNoneNullInTheHeaders(): Unit = + runBlocking { + val jsonFilePath = + javaClass.classLoader?.getResource("ofrep/valid_api_response.json")?.file + val jsonString = + String( + withContext(Dispatchers.IO) { + Files.readAllBytes(Paths.get(jsonFilePath)) + }, + ) + + mockWebServer!!.enqueue( + MockResponse() + .setBody(jsonString.trimIndent()) + .setResponseCode(200) + .addHeader("ETag", "123"), + ) + mockWebServer!!.enqueue( + MockResponse() + .setResponseCode(304) + .addHeader("ETag", "123"), + ) + + val ofrepApi = + OfrepApi( + OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + ) + + val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") + val eval1 = ofrepApi.postBulkEvaluateFlags(ctx) + val eval2 = ofrepApi.postBulkEvaluateFlags(ctx) + assertEquals(eval1.httpResponse.code, 200) + assertEquals(eval2.httpResponse.code, 304) + assertEquals(2, mockWebServer!!.requestCount) + } +} diff --git a/providers/ofrep/src/commonTest/resources/ofrep/invalid_api_response.json b/providers/ofrep/src/commonTest/resources/ofrep/invalid_api_response.json new file mode 100644 index 0000000..33608b9 --- /dev/null +++ b/providers/ofrep/src/commonTest/resources/ofrep/invalid_api_response.json @@ -0,0 +1,25 @@ +{ + "flags": [ + { + "key": "badge-class2", + "value": "green", + "reason": "DEFAULT", + "variant": "nocolor" + }, + { + "key": "hide-logo", + "value": false, + "reason": "STATIC", + "variant": "var_false" + }, + { + "key": "title-flag", + "value": "GO Feature Flag", + "reason": "DEFAULT", + "variant": "default_title", + "metadata": { + "description": "This flag controls the title of the feature flag", + "title": "Feature Flag Title" + } + } +} \ No newline at end of file diff --git a/providers/ofrep/src/commonTest/resources/ofrep/invalid_context.json b/providers/ofrep/src/commonTest/resources/ofrep/invalid_context.json new file mode 100644 index 0000000..c48d7cd --- /dev/null +++ b/providers/ofrep/src/commonTest/resources/ofrep/invalid_context.json @@ -0,0 +1,4 @@ +{ + "errorCode": "INVALID_CONTEXT", + "errorDetails": "Error details about INVALID_CONTEXT" +} \ No newline at end of file diff --git a/providers/ofrep/src/commonTest/resources/ofrep/parse_error.json b/providers/ofrep/src/commonTest/resources/ofrep/parse_error.json new file mode 100644 index 0000000..5c82ae4 --- /dev/null +++ b/providers/ofrep/src/commonTest/resources/ofrep/parse_error.json @@ -0,0 +1,4 @@ +{ + "errorCode": "PARSE_ERROR", + "errorDetails": "Error details about PARSE_ERROR" +} \ No newline at end of file diff --git a/providers/ofrep/src/commonTest/resources/ofrep/valid_1_flag_in_parse_error.json b/providers/ofrep/src/commonTest/resources/ofrep/valid_1_flag_in_parse_error.json new file mode 100644 index 0000000..2c06120 --- /dev/null +++ b/providers/ofrep/src/commonTest/resources/ofrep/valid_1_flag_in_parse_error.json @@ -0,0 +1,20 @@ +{ + "flags": [ + { + "value": true, + "key": "my-flag", + "reason": "STATIC", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": true, + "additionalProp3": true + } + }, + { + "key": "my-other-flag", + "errorCode": "PARSE_ERROR", + "errorDetails": "Error details about PARSE_ERROR" + } + ] +} \ No newline at end of file diff --git a/providers/ofrep/src/commonTest/resources/ofrep/valid_api_response.json b/providers/ofrep/src/commonTest/resources/ofrep/valid_api_response.json new file mode 100644 index 0000000..52d48f2 --- /dev/null +++ b/providers/ofrep/src/commonTest/resources/ofrep/valid_api_response.json @@ -0,0 +1,110 @@ +{ + "flags": [ + { + "key": "badge-class2", + "value": "green", + "reason": "DEFAULT", + "variant": "nocolor" + }, + { + "key": "hide-logo", + "value": false, + "reason": "STATIC", + "variant": "var_false" + }, + { + "key": "title-flag", + "value": "GO Feature Flag", + "reason": "DEFAULT", + "variant": "default_title", + "metadata": { + "description": "This flag controls the title of the feature flag", + "title": "Feature Flag Title" + } + }, + { + "value": true, + "key": "my-flag", + "reason": "STATIC", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": "value", + "additionalProp3": 123 + } + }, + { + "value": true, + "key": "bool-flag", + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": "value", + "additionalProp3": 123 + } + }, + { + "value": 1234, + "key": "int-flag", + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": "value", + "additionalProp3": 123 + } + }, + { + "value": 12.34, + "key": "double-flag", + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": "value", + "additionalProp3": 123 + } + }, + { + "value": "1234value", + "key": "string-flag", + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": "value", + "additionalProp3": 123 + } + }, + { + "value": { + "testValue": { + "toto": 1234 + } + }, + "key": "object-flag", + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": "value", + "additionalProp3": 123 + } + }, + { + "value": [ + 1234, + 5678 + ], + "key": "array-flag", + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": "value", + "additionalProp3": 123 + } + } + ] +} \ No newline at end of file diff --git a/providers/ofrep/src/commonTest/resources/ofrep/valid_api_response_2.json b/providers/ofrep/src/commonTest/resources/ofrep/valid_api_response_2.json new file mode 100644 index 0000000..87089dd --- /dev/null +++ b/providers/ofrep/src/commonTest/resources/ofrep/valid_api_response_2.json @@ -0,0 +1,110 @@ +{ + "flags": [ + { + "key": "badge-class2", + "value": "blue", + "reason": "TARGETING_MATCH", + "variant": "xxxx" + }, + { + "key": "hide-logo", + "value": false, + "reason": "STATIC", + "variant": "var_false" + }, + { + "key": "title-flag", + "value": "GO Feature Flag", + "reason": "DEFAULT", + "variant": "default_title", + "metadata": { + "description": "This flag controls the title of the feature flag", + "title": "Feature Flag Title" + } + }, + { + "value": true, + "key": "my-flag", + "reason": "STATIC", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": "value", + "additionalProp3": 123 + } + }, + { + "value": true, + "key": "bool-flag", + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": "value", + "additionalProp3": 123 + } + }, + { + "value": 1234, + "key": "int-flag", + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": "value", + "additionalProp3": 123 + } + }, + { + "value": 12.34, + "key": "double-flag", + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": "value", + "additionalProp3": 123 + } + }, + { + "value": "1234value", + "key": "string-flag", + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": "value", + "additionalProp3": 123 + } + }, + { + "value": { + "testValue": { + "toto": 1234 + } + }, + "key": "object-flag", + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": "value", + "additionalProp3": 123 + } + }, + { + "value": [ + 1234, + 5678 + ], + "key": "array-flag", + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": "value", + "additionalProp3": 123 + } + } + ] +} \ No newline at end of file diff --git a/providers/ofrep/src/commonTest/resources/ofrep/valid_api_short_response.json b/providers/ofrep/src/commonTest/resources/ofrep/valid_api_short_response.json new file mode 100644 index 0000000..316ced0 --- /dev/null +++ b/providers/ofrep/src/commonTest/resources/ofrep/valid_api_short_response.json @@ -0,0 +1,26 @@ +{ + "flags": [ + { + "key": "badge-class2", + "value": "green", + "reason": "DEFAULT", + "variant": "nocolor" + }, + { + "key": "hide-logo", + "value": false, + "reason": "STATIC", + "variant": "var_false" + }, + { + "key": "title-flag", + "value": "GO Feature Flag", + "reason": "DEFAULT", + "variant": "default_title", + "metadata": { + "description": "This flag controls the title of the feature flag", + "title": "Feature Flag Title" + } + } + ] +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index c11ce6f..4cabdec 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,10 +1,20 @@ rootProject.name = "open-feature-kotlin-sdk-contrib" +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + dependencyResolutionManagement { repositories { + google() mavenLocal() mavenCentral() } } include(":providers:env-var") +include(":providers:ofrep") From 17072197cdeceacf5c7769763ec7213d5dc9df4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Fri, 6 Jun 2025 18:51:29 +0200 Subject: [PATCH 03/21] Extract getResourceAsString() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- .../openfeature/ofrep/GetResourceAsString.kt | 9 ++++ .../openfeature/ofrep/OfrepProviderTest.kt | 6 +-- .../ofrep/controller/OfrepApiTest.kt | 50 +++---------------- 3 files changed, 16 insertions(+), 49 deletions(-) create mode 100644 providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/GetResourceAsString.kt diff --git a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/GetResourceAsString.kt b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/GetResourceAsString.kt new file mode 100644 index 0000000..906f7c2 --- /dev/null +++ b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/GetResourceAsString.kt @@ -0,0 +1,9 @@ +package org.gofeatureflag.openfeature.ofrep + +import okio.FileSystem +import okio.Path.Companion.toPath + +fun getResourceAsString(resourceName: String): String = + FileSystem.SYSTEM.read("src/commonTest/resources".toPath() / resourceName) { + readUtf8() + } diff --git a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt index 21979fa..6c02d60 100644 --- a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt @@ -24,8 +24,6 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test -import java.nio.file.Files -import java.nio.file.Paths import java.util.UUID class OfrepProviderTest { @@ -632,9 +630,7 @@ class OfrepProviderTest { responseCode: Int = 200, headers: Headers? = null, ) { - val jsonFilePath = - javaClass.classLoader?.getResource(fileName)?.file - val jsonString = String(Files.readAllBytes(Paths.get(jsonFilePath))) + val jsonString = getResourceAsString(fileName) var resp = MockResponse() .setBody(jsonString.trimIndent()) diff --git a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApiTest.kt b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApiTest.kt index 431a66b..3dee054 100644 --- a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApiTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApiTest.kt @@ -7,21 +7,18 @@ import dev.openfeature.sdk.Value import dev.openfeature.sdk.exceptions.ErrorCode import dev.openfeature.sdk.exceptions.OpenFeatureError import junit.framework.TestCase.assertFalse -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.gofeatureflag.openfeature.ofrep.bean.OfrepOptions import org.gofeatureflag.openfeature.ofrep.error.OfrepError +import org.gofeatureflag.openfeature.ofrep.getResourceAsString import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -import java.nio.file.Files -import java.nio.file.Paths class OfrepApiTest { private var mockWebServer: MockWebServer? = null @@ -40,14 +37,7 @@ class OfrepApiTest { @Test fun shouldReturnAValidEvaluationResponse() = runBlocking { - val jsonFilePath = - javaClass.classLoader?.getResource("ofrep/valid_api_short_response.json")?.file - val jsonString = - String( - withContext(Dispatchers.IO) { - Files.readAllBytes(Paths.get(jsonFilePath)) - }, - ) + val jsonString = getResourceAsString("ofrep/valid_api_short_response.json") mockWebServer!!.enqueue(MockResponse().setBody(jsonString.trimIndent())) @@ -251,14 +241,7 @@ class OfrepApiTest { @Test fun shouldThrowUnmarshallErrorWithInvalidJson(): Unit = runBlocking { - val jsonFilePath = - javaClass.classLoader?.getResource("ofrep/invalid_api_response.json")?.file - val jsonString = - String( - withContext(Dispatchers.IO) { - Files.readAllBytes(Paths.get(jsonFilePath)) - }, - ) + val jsonString = getResourceAsString("ofrep/invalid_api_response.json") mockWebServer!!.enqueue( MockResponse().setBody(jsonString.trimIndent()).setResponseCode(400), @@ -279,14 +262,7 @@ class OfrepApiTest { @Test fun shouldThrowWithInvalidOptions(): Unit = runBlocking { - val jsonFilePath = - javaClass.classLoader?.getResource("ofrep/invalid_api_response.json")?.file - val jsonString = - String( - withContext(Dispatchers.IO) { - Files.readAllBytes(Paths.get(jsonFilePath)) - }, - ) + val jsonString = getResourceAsString("ofrep/invalid_api_response.json") mockWebServer!!.enqueue( MockResponse().setBody(jsonString.trimIndent()).setResponseCode(400), @@ -301,14 +277,7 @@ class OfrepApiTest { @Test fun shouldETagShouldNotMatch(): Unit = runBlocking { - val jsonFilePath = - javaClass.classLoader?.getResource("ofrep/valid_api_response.json")?.file - val jsonString = - String( - withContext(Dispatchers.IO) { - Files.readAllBytes(Paths.get(jsonFilePath)) - }, - ) + val jsonString = getResourceAsString("ofrep/valid_api_response.json") mockWebServer!!.enqueue( MockResponse() @@ -338,14 +307,7 @@ class OfrepApiTest { @Test fun shouldHaveIfNoneNullInTheHeaders(): Unit = runBlocking { - val jsonFilePath = - javaClass.classLoader?.getResource("ofrep/valid_api_response.json")?.file - val jsonString = - String( - withContext(Dispatchers.IO) { - Files.readAllBytes(Paths.get(jsonFilePath)) - }, - ) + val jsonString = getResourceAsString("ofrep/valid_api_response.json") mockWebServer!!.enqueue( MockResponse() From e14a6164327f12dcde508727d9b200a2aae0e7d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Fri, 6 Jun 2025 19:56:24 +0200 Subject: [PATCH 04/21] Upgrade to latest OpenFeature kotlin-sdk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- providers/ofrep/build.gradle.kts | 3 +- .../openfeature/ofrep/OfrepProvider.kt | 173 ++++++++---------- .../ofrep/bean/OfrepApiResponse.kt | 64 +++---- .../openfeature/ofrep/controller/OfrepApi.kt | 53 +++--- .../openfeature/ofrep/OfrepProviderTest.kt | 79 ++++---- 5 files changed, 176 insertions(+), 196 deletions(-) diff --git a/providers/ofrep/build.gradle.kts b/providers/ofrep/build.gradle.kts index 2261b75..db8f98d 100644 --- a/providers/ofrep/build.gradle.kts +++ b/providers/ofrep/build.gradle.kts @@ -12,8 +12,7 @@ kotlin { sourceSets { commonMain.dependencies { - // TODO: update to api(libs.openfeature.kotlin.sdk) - api("dev.openfeature:android-sdk:0.3.2") + api(libs.openfeature.kotlin.sdk) api(libs.kotlinx.coroutines.core) api(libs.okhttp) diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt index f6b1bf0..72dbc08 100644 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt @@ -8,13 +8,11 @@ import dev.openfeature.sdk.ImmutableContext import dev.openfeature.sdk.ProviderEvaluation import dev.openfeature.sdk.ProviderMetadata import dev.openfeature.sdk.Value -import dev.openfeature.sdk.events.EventHandler -import dev.openfeature.sdk.events.OpenFeatureEvents +import dev.openfeature.sdk.events.OpenFeatureProviderEvents import dev.openfeature.sdk.exceptions.ErrorCode import dev.openfeature.sdk.exceptions.OpenFeatureError -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.runBlocking import org.gofeatureflag.openfeature.ofrep.bean.OfrepOptions import org.gofeatureflag.openfeature.ofrep.bean.OfrepProviderMetadata @@ -30,10 +28,8 @@ import java.util.Timer import java.util.TimerTask import kotlin.reflect.KClass - class OfrepProvider( private val ofrepOptions: OfrepOptions, - private val eventHandler: EventHandler = EventHandler(Dispatchers.IO) ) : FeatureProvider { private val ofrepApi = OfrepApi(ofrepOptions) override val hooks: List> @@ -47,30 +43,25 @@ class OfrepProvider( private var retryAfter: Date? = null private var pollingTimer: Timer? = null + private val statusFlow = MutableSharedFlow(replay = 1) - override fun observe(): Flow = eventHandler.observe() + override fun observe(): Flow = statusFlow - override fun initialize(initialContext: EvaluationContext?) { + override suspend fun initialize(initialContext: EvaluationContext?) { this.evaluationContext = initialContext - runBlocking { - launch { - try { - val bulkEvaluationStatus = evaluateFlags(initialContext ?: ImmutableContext()) - if (bulkEvaluationStatus == BulkEvaluationStatus.RATE_LIMITED) { - eventHandler.publish( - OpenFeatureEvents.ProviderError( - OfrepError.ApiTooManyRequestsError( - null - ) - ) - ) - return@launch - } - eventHandler.publish(OpenFeatureEvents.ProviderReady) - } catch (e: Exception) { - eventHandler.publish(OpenFeatureEvents.ProviderError(e)) - } + try { + val bulkEvaluationStatus = evaluateFlags(initialContext ?: ImmutableContext()) + if (bulkEvaluationStatus == BulkEvaluationStatus.RATE_LIMITED) { + statusFlow.emit( + OpenFeatureProviderEvents.ProviderError( + OpenFeatureError.GeneralError("Rate limited"), + ), + ) + } else { + statusFlow.emit(OpenFeatureProviderEvents.ProviderReady) } + } catch (e: Exception) { + statusFlow.emit(OpenFeatureProviderEvents.ProviderError(OpenFeatureError.GeneralError(e.message ?: "Unknown error"))) } this.startPolling(this.ofrepOptions.pollingIntervalInMillis) } @@ -79,39 +70,40 @@ class OfrepProvider( * Start polling for flag updates */ private fun startPolling(pollingIntervalInMillis: Long) { - val task: TimerTask = object : TimerTask() { - override fun run() { - runBlocking { - try { - val resp = - this@OfrepProvider.evaluateFlags(this@OfrepProvider.evaluationContext!!) - - when (resp) { - BulkEvaluationStatus.RATE_LIMITED, BulkEvaluationStatus.SUCCESS_NO_CHANGE -> { - // Nothing to do ! - // - // if rate limited: the provider should already be in stale status and - // we don't need to emit an event or call again the API - // - // if no change: the provider should already be in ready status and - // we don't need to emit an event if nothing has changed - } - - BulkEvaluationStatus.SUCCESS_UPDATED -> { - // TODO: we should migrate to configuration change event when it's available - // in the kotlin SDK - eventHandler.publish(OpenFeatureEvents.ProviderReady) + val task: TimerTask = + object : TimerTask() { + override fun run() { + runBlocking { + try { + val resp = + this@OfrepProvider.evaluateFlags(this@OfrepProvider.evaluationContext!!) + + when (resp) { + BulkEvaluationStatus.RATE_LIMITED, BulkEvaluationStatus.SUCCESS_NO_CHANGE -> { + // Nothing to do ! + // + // if rate limited: the provider should already be in stale status and + // we don't need to emit an event or call again the API + // + // if no change: the provider should already be in ready status and + // we don't need to emit an event if nothing has changed + } + + BulkEvaluationStatus.SUCCESS_UPDATED -> { + // TODO: we should migrate to configuration change event when it's available + // in the kotlin SDK + statusFlow.emit(OpenFeatureProviderEvents.ProviderReady) + } } + } catch (e: OfrepError.ApiTooManyRequestsError) { + // in that case the provider is just stale because we were not able to + statusFlow.emit(OpenFeatureProviderEvents.ProviderStale) + } catch (e: Throwable) { + statusFlow.emit(OpenFeatureProviderEvents.ProviderError(OpenFeatureError.GeneralError(e.message ?: ""))) } - } catch (e: OfrepError.ApiTooManyRequestsError) { - // in that case the provider is just stale because we were not able to - eventHandler.publish(OpenFeatureEvents.ProviderStale) - } catch (e: Throwable) { - eventHandler.publish(OpenFeatureEvents.ProviderError(e)) } } } - } val timer = Timer() timer.schedule(task, pollingIntervalInMillis, pollingIntervalInMillis) this.pollingTimer = timer @@ -120,65 +112,49 @@ class OfrepProvider( override fun getBooleanEvaluation( key: String, defaultValue: Boolean, - context: EvaluationContext? - ): ProviderEvaluation { - return genericEvaluation(key, Boolean::class) - } + context: EvaluationContext?, + ): ProviderEvaluation = genericEvaluation(key, Boolean::class) override fun getDoubleEvaluation( key: String, defaultValue: Double, - context: EvaluationContext? - ): ProviderEvaluation { - return genericEvaluation(key, Double::class) - } + context: EvaluationContext?, + ): ProviderEvaluation = genericEvaluation(key, Double::class) override fun getIntegerEvaluation( key: String, defaultValue: Int, - context: EvaluationContext? - ): ProviderEvaluation { - return genericEvaluation(key, Int::class) - } + context: EvaluationContext?, + ): ProviderEvaluation = genericEvaluation(key, Int::class) override fun getObjectEvaluation( key: String, defaultValue: Value, - context: EvaluationContext? - ): ProviderEvaluation { - return genericEvaluation(key, Object::class) - } + context: EvaluationContext?, + ): ProviderEvaluation = genericEvaluation(key, Object::class) override fun getStringEvaluation( key: String, defaultValue: String, - context: EvaluationContext? - ): ProviderEvaluation { - return genericEvaluation(key, String::class) - } - - override fun getProviderStatus(): OpenFeatureEvents { - return eventHandler.getProviderStatus() - } - - override fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { - this.eventHandler.publish(OpenFeatureEvents.ProviderStale) + context: EvaluationContext?, + ): ProviderEvaluation = genericEvaluation(key, String::class) + + override suspend fun onContextSet( + oldContext: EvaluationContext?, + newContext: EvaluationContext, + ) { + this.statusFlow.emit(OpenFeatureProviderEvents.ProviderStale) this.evaluationContext = newContext - runBlocking { - launch { - try { - val postBulkEvaluateFlags = evaluateFlags(newContext) - if (postBulkEvaluateFlags == BulkEvaluationStatus.RATE_LIMITED) { - // we don't emit event if the evaluation is rate limited because - // the provider is still stale - return@launch - } - eventHandler.publish(OpenFeatureEvents.ProviderReady) - } catch (e: Throwable) { - eventHandler.publish(OpenFeatureEvents.ProviderError(e)) - } + try { + val postBulkEvaluateFlags = evaluateFlags(newContext) + // we don't emit event if the evaluation is rate limited because + // the provider is still stale + if (postBulkEvaluateFlags != BulkEvaluationStatus.RATE_LIMITED) { + statusFlow.emit(OpenFeatureProviderEvents.ProviderReady) } + } catch (e: Throwable) { + statusFlow.emit(OpenFeatureProviderEvents.ProviderError(OpenFeatureError.GeneralError(e.message ?: ""))) } } @@ -188,7 +164,7 @@ class OfrepProvider( private fun genericEvaluation( key: String, - expectedType: KClass<*> + expectedType: KClass<*>, ): ProviderEvaluation { val flag = this.inMemoryCache[key] ?: throw OpenFeatureError.FlagNotFoundError(key) @@ -197,7 +173,7 @@ class OfrepProvider( ErrorCode.FLAG_NOT_FOUND -> throw OpenFeatureError.FlagNotFoundError(key) ErrorCode.INVALID_CONTEXT -> throw OpenFeatureError.InvalidContextError() ErrorCode.PARSE_ERROR -> throw OpenFeatureError.ParseError( - flag.errorDetails ?: "parse error" + flag.errorDetails ?: "parse error", ) ErrorCode.PROVIDER_NOT_READY -> throw OpenFeatureError.ProviderNotReadyError() @@ -208,7 +184,6 @@ class OfrepProvider( return flag.toProviderEvaluation(expectedType) } - /** * Evaluate the flags for the given context. * It will store the flags in the in-memory cache, if any error occurs it will throw an exception. @@ -232,7 +207,7 @@ class OfrepProvider( when (ofrepEvalResp?.errorCode) { ErrorCode.PROVIDER_NOT_READY -> throw OpenFeatureError.ProviderNotReadyError() ErrorCode.PARSE_ERROR -> throw OpenFeatureError.ParseError( - ofrepEvalResp.errorDetails ?: "" + ofrepEvalResp.errorDetails ?: "", ) ErrorCode.TARGETING_KEY_MISSING -> throw OpenFeatureError.TargetingKeyMissingError() @@ -269,4 +244,4 @@ class OfrepProvider( } return retryDate.time } -} \ No newline at end of file +} diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepApiResponse.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepApiResponse.kt index 4ee153b..acf1950 100644 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepApiResponse.kt +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepApiResponse.kt @@ -1,14 +1,18 @@ +@file:OptIn(ExperimentalTime::class) + import dev.openfeature.sdk.EvaluationMetadata import dev.openfeature.sdk.ProviderEvaluation import dev.openfeature.sdk.Value import dev.openfeature.sdk.exceptions.ErrorCode import dev.openfeature.sdk.exceptions.OpenFeatureError import kotlin.reflect.KClass +import kotlin.time.ExperimentalTime +import kotlin.time.Instant data class OfrepApiResponse( val flags: List? = null, val errorCode: ErrorCode?, - val errorDetails: String? + val errorDetails: String?, ) data class FlagDto( @@ -18,11 +22,9 @@ data class FlagDto( val variant: String, val errorCode: ErrorCode?, val errorDetails: String?, - val metadata: Map? = emptyMap() + val metadata: Map? = emptyMap(), ) { - fun isError(): Boolean { - return errorCode != null - } + fun isError(): Boolean = errorCode != null fun toProviderEvaluation(expectedType: KClass<*>): ProviderEvaluation { if (!expectedType.isInstance(value)) { @@ -41,7 +43,7 @@ data class FlagDto( variant = variant, errorCode = errorCode, errorMessage = errorDetails, - metadata = convertMetadata(metadata) + metadata = convertMetadata(metadata), ) } @@ -54,7 +56,7 @@ data class FlagDto( variant = variant, errorCode = errorCode, errorMessage = errorDetails, - metadata = convertMetadata(metadata) + metadata = convertMetadata(metadata), ) } else if (value is Map<*, *>) { val typedValue = convertObjectToStructure(value) @@ -64,15 +66,13 @@ data class FlagDto( variant = variant, errorCode = errorCode, errorMessage = errorDetails, - metadata = convertMetadata(metadata) + metadata = convertMetadata(metadata), ) } else { throw IllegalArgumentException("Unsupported type for: $value") } } - - @Suppress("unchecked_cast") return ProviderEvaluation( value = value as T, @@ -80,12 +80,12 @@ data class FlagDto( variant = variant, errorCode = errorCode, errorMessage = errorDetails, - metadata = convertMetadata(metadata) + metadata = convertMetadata(metadata), ) } private fun convertMetadata(inputMap: Map?): EvaluationMetadata { - //check that inputMap is null or empty + // check that inputMap is null or empty if (inputMap.isNullOrEmpty()) { return EvaluationMetadata.EMPTY } @@ -119,14 +119,14 @@ data class FlagDto( return metadataBuilder.build() } - private fun convertList(inputList: List<*>): List { - return inputList.map { item -> + private fun convertList(inputList: List<*>): List = + inputList.map { item -> when (item) { is String -> Value.String(item) is Boolean -> Value.Boolean(item) is Long -> Value.Integer(item.toInt()) is Double -> Value.Double(item) - is java.util.Date -> Value.Date(item) + is Instant -> Value.Instant(item) is Map<*, *> -> { @Suppress("unchecked_cast") Value.Structure(item as Map) @@ -138,32 +138,32 @@ data class FlagDto( } else -> throw IllegalArgumentException( - "Unsupported type for: $item" + "Unsupported type for: $item", ) } } - } private fun convertObjectToStructure(obj: Any): Value.Structure { if (obj !is Map<*, *>) { throw IllegalArgumentException("Object must be a Map") } - val convertedMap = obj.entries.associate { (key, value) -> - if (key !is String) { - throw IllegalArgumentException("Map key must be a String") - } - key to when (value) { - is String -> Value.String(value) - is Boolean -> Value.Boolean(value) - is Long -> Value.Integer(value.toInt()) - is Double -> Value.Double(value) - is java.util.Date -> Value.Date(value) - is Map<*, *> -> convertObjectToStructure(value) - is List<*> -> Value.List(convertList(value as List)) - else -> throw IllegalArgumentException("Unsupported type for: $value") + val convertedMap = + obj.entries.associate { (key, value) -> + if (key !is String) { + throw IllegalArgumentException("Map key must be a String") + } + key to + when (value) { + is String -> Value.String(value) + is Boolean -> Value.Boolean(value) + is Long -> Value.Integer(value.toInt()) + is Double -> Value.Double(value) + is Instant -> Value.Instant(value) + is Map<*, *> -> convertObjectToStructure(value) + is List<*> -> Value.List(convertList(value as List)) + else -> throw IllegalArgumentException("Unsupported type for: $value") + } } - } return Value.Structure(convertedMap) } - } diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApi.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApi.kt index e04e17a..c4ca4c5 100644 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApi.kt +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApi.kt @@ -18,25 +18,28 @@ import org.gofeatureflag.openfeature.ofrep.bean.PostBulkEvaluationResult import org.gofeatureflag.openfeature.ofrep.error.OfrepError import java.util.concurrent.TimeUnit -class OfrepApi(private val options: OfrepOptions) { +class OfrepApi( + private val options: OfrepOptions, +) { companion object { private val gson = GsonBuilder().setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE).create() } - private var httpClient: OkHttpClient = OkHttpClient.Builder() - .connectTimeout(this.options.timeout, TimeUnit.MILLISECONDS) - .readTimeout(this.options.timeout, TimeUnit.MILLISECONDS) - .callTimeout(this.options.timeout, TimeUnit.MILLISECONDS) - .writeTimeout(this.options.timeout, TimeUnit.MILLISECONDS) - .connectionPool( - ConnectionPool( - this.options.maxIdleConnections, - this.options.keepAliveDuration, - TimeUnit.MILLISECONDS - ) - ) - .build() + private var httpClient: OkHttpClient = + OkHttpClient + .Builder() + .connectTimeout(this.options.timeout, TimeUnit.MILLISECONDS) + .readTimeout(this.options.timeout, TimeUnit.MILLISECONDS) + .callTimeout(this.options.timeout, TimeUnit.MILLISECONDS) + .writeTimeout(this.options.timeout, TimeUnit.MILLISECONDS) + .connectionPool( + ConnectionPool( + this.options.maxIdleConnections, + this.options.keepAliveDuration, + TimeUnit.MILLISECONDS, + ), + ).build() private var parsedEndpoint: HttpUrl = options.endpoint.toHttpUrlOrNull() ?: throw OfrepError.InvalidOptionsError("invalid endpoint configuration: ${options.endpoint}") @@ -50,17 +53,21 @@ class OfrepApi(private val options: OfrepOptions) { context ?: throw OpenFeatureError.InvalidContextError("EvaluationContext is null") validateContext(nonNullContext) - val urlBuilder = parsedEndpoint.newBuilder() - .addEncodedPathSegment("ofrep") - .addEncodedPathSegment("v1") - .addEncodedPathSegment("evaluate") - .addEncodedPathSegment("flags") + val urlBuilder = + parsedEndpoint + .newBuilder() + .addEncodedPathSegment("ofrep") + .addEncodedPathSegment("v1") + .addEncodedPathSegment("evaluate") + .addEncodedPathSegment("flags") val mediaType = "application/json".toMediaTypeOrNull() val requestBody = gson.toJson(OfrepApiRequest(nonNullContext)).toRequestBody(mediaType) - val reqBuilder = okhttp3.Request.Builder() - .url(urlBuilder.build()) - .post(requestBody) + val reqBuilder = + okhttp3.Request + .Builder() + .url(urlBuilder.build()) + .post(requestBody) // add all the headers options.headers?.let { reqBuilder.headers(it) } @@ -97,4 +104,4 @@ class OfrepApi(private val options: OfrepOptions) { throw OpenFeatureError.TargetingKeyMissingError() } } -} \ No newline at end of file +} diff --git a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt index 6c02d60..d71cd86 100644 --- a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt @@ -6,12 +6,12 @@ import dev.openfeature.sdk.FlagEvaluationDetails import dev.openfeature.sdk.ImmutableContext import dev.openfeature.sdk.OpenFeatureAPI import dev.openfeature.sdk.Value -import dev.openfeature.sdk.events.OpenFeatureEvents -import dev.openfeature.sdk.events.observe +import dev.openfeature.sdk.events.OpenFeatureProviderEvents import dev.openfeature.sdk.exceptions.ErrorCode import dev.openfeature.sdk.exceptions.OpenFeatureError import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -38,13 +38,12 @@ class OfrepProviderTest { } @After - fun after() { - OpenFeatureAPI.shutdown() - OpenFeatureAPI.clearProvider() - OpenFeatureAPI.clearHooks() - mockWebServer?.shutdown() - mockWebServer = null - } + fun after() = + runBlocking { + OpenFeatureAPI.shutdown() + mockWebServer?.shutdown() + mockWebServer = null + } @Test fun `should have a provider metadata`() { @@ -62,11 +61,11 @@ class OfrepProviderTest { val coroutineScope = CoroutineScope(Dispatchers.IO) coroutineScope.launch { - provider.observe().take(1).collect { + provider.observe().filterIsInstance().take(1).collect { providerErrorReceived = true } } - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) assert(providerErrorReceived) { "ProviderError event was not received" } } @@ -80,11 +79,11 @@ class OfrepProviderTest { val coroutineScope = CoroutineScope(Dispatchers.IO) coroutineScope.launch { - provider.observe().take(1).collect { + provider.observe().filterIsInstance().take(1).collect { providerErrorReceived = true } } - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) assert(providerErrorReceived) { "ProviderError event was not received" } } @@ -103,12 +102,12 @@ class OfrepProviderTest { val coroutineScope = CoroutineScope(Dispatchers.IO) coroutineScope.launch { - provider.observe().take(1).collect { + provider.observe().filterIsInstance().take(1).collect { providerErrorReceived = true exceptionReceived = it.error } } - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) assert(providerErrorReceived) { "ProviderError event was not received" } assert(exceptionReceived is OfrepError.ApiTooManyRequestsError) { "The exception is not of type ApiTooManyRequestsError" } } @@ -124,13 +123,13 @@ class OfrepProviderTest { val coroutineScope = CoroutineScope(Dispatchers.IO) coroutineScope.launch { - provider.observe().take(1).collect { + provider.observe().filterIsInstance().take(1).collect { providerErrorReceived = true exceptionReceived = it.error } } val evalCtx = ImmutableContext(targetingKey = "") - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, evalCtx) + OpenFeatureAPI.setProviderAndWait(provider, evalCtx, Dispatchers.IO) assert(providerErrorReceived) { "ProviderError event was not received" } assert( exceptionReceived is OpenFeatureError.TargetingKeyMissingError, @@ -148,13 +147,13 @@ class OfrepProviderTest { val coroutineScope = CoroutineScope(Dispatchers.IO) coroutineScope.launch { - provider.observe().take(1).collect { + provider.observe().filterIsInstance().take(1).collect { providerErrorReceived = true exceptionReceived = it.error } } val evalCtx = ImmutableContext() - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, evalCtx) + OpenFeatureAPI.setProviderAndWait(provider, evalCtx, Dispatchers.IO) assert(providerErrorReceived) { "ProviderError event was not received" } assert( exceptionReceived is OpenFeatureError.TargetingKeyMissingError, @@ -171,12 +170,12 @@ class OfrepProviderTest { val coroutineScope = CoroutineScope(Dispatchers.IO) coroutineScope.launch { - provider.observe().take(1).collect { + provider.observe().filterIsInstance().take(1).collect { providerErrorReceived = true exceptionReceived = it.error } } - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) assert(providerErrorReceived) { "ProviderError event was not received" } assert(exceptionReceived is OpenFeatureError.InvalidContextError) { "The exception is not of type InvalidContextError" } } @@ -192,12 +191,12 @@ class OfrepProviderTest { val coroutineScope = CoroutineScope(Dispatchers.IO) coroutineScope.launch { - provider.observe().take(1).collect { + provider.observe().filterIsInstance().take(1).collect { providerErrorReceived = true exceptionReceived = it.error } } - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) assert(providerErrorReceived) { "ProviderError event was not received" } assert(exceptionReceived is OpenFeatureError.ParseError) { "The exception is not of type ParseError" } } @@ -207,7 +206,7 @@ class OfrepProviderTest { runBlocking { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getBooleanDetails("non-existent-flag", false) val want = @@ -230,7 +229,7 @@ class OfrepProviderTest { 200, ) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getStringDetails("title-flag", "default") val want = @@ -259,7 +258,7 @@ class OfrepProviderTest { 200, ) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getStringDetails("my-other-flag", "default") val want = @@ -286,7 +285,7 @@ class OfrepProviderTest { 200, ) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) // TODO: should change when we have a way to observe context changes event // check issue https://github.com/open-feature/kotlin-sdk/issues/107 @@ -295,10 +294,10 @@ class OfrepProviderTest { val coroutineScope = CoroutineScope(Dispatchers.IO) coroutineScope.launch { - provider.observe().take(1).collect { + provider.observe().filterIsInstance().take(1).collect { providerStaleEventReceived = true } - provider.observe().take(1).collect { + provider.observe().filterIsInstance().take(1).collect { providerReadyEventReceived = true } } @@ -325,7 +324,7 @@ class OfrepProviderTest { endpoint = mockWebServer?.url("/").toString(), ), ) - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() client.getStringDetails("my-other-flag", "default") client.getStringDetails("my-other-flag", "default") @@ -338,7 +337,7 @@ class OfrepProviderTest { runBlocking { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getBooleanDetails("bool-flag", false) val want = @@ -365,7 +364,7 @@ class OfrepProviderTest { runBlocking { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getIntegerDetails("int-flag", 1) val want = @@ -392,7 +391,7 @@ class OfrepProviderTest { runBlocking { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getDoubleDetails("double-flag", 1.1) val want = @@ -419,7 +418,7 @@ class OfrepProviderTest { runBlocking { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getStringDetails("string-flag", "default") val want = @@ -446,7 +445,7 @@ class OfrepProviderTest { runBlocking { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getObjectDetails( @@ -478,7 +477,7 @@ class OfrepProviderTest { runBlocking { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getObjectDetails( @@ -524,7 +523,7 @@ class OfrepProviderTest { runBlocking { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getBooleanDetails("object-flag", false) val want = @@ -544,7 +543,7 @@ class OfrepProviderTest { runBlocking { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getStringDetails("object-flag", "default") val want = @@ -564,7 +563,7 @@ class OfrepProviderTest { runBlocking { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getDoubleDetails("object-flag", 1.233) val want = @@ -598,7 +597,7 @@ class OfrepProviderTest { endpoint = mockWebServer?.url("/").toString(), ), ) - OpenFeatureAPI.setProviderAndWait(provider, Dispatchers.IO, defaultEvalCtx) + OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getStringDetails("badge-class2", "default") val want = From e7f4392a10704c20059bac220f42de285c354ab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Fri, 6 Jun 2025 20:04:13 +0200 Subject: [PATCH 05/21] Fix test flakiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- providers/ofrep/build.gradle.kts | 1 + .../openfeature/ofrep/OfrepProviderTest.kt | 93 +++++++++++-------- 2 files changed, 53 insertions(+), 41 deletions(-) diff --git a/providers/ofrep/build.gradle.kts b/providers/ofrep/build.gradle.kts index db8f98d..1ef1ad2 100644 --- a/providers/ofrep/build.gradle.kts +++ b/providers/ofrep/build.gradle.kts @@ -21,6 +21,7 @@ kotlin { } commonTest.dependencies { implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) implementation(libs.okhttp.mockwebserver) } } diff --git a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt index d71cd86..bccf15f 100644 --- a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + package org.gofeatureflag.openfeature.ofrep import dev.openfeature.sdk.EvaluationContext @@ -9,12 +11,13 @@ import dev.openfeature.sdk.Value import dev.openfeature.sdk.events.OpenFeatureProviderEvents import dev.openfeature.sdk.exceptions.ErrorCode import dev.openfeature.sdk.exceptions.OpenFeatureError -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import okhttp3.Headers import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -39,7 +42,7 @@ class OfrepProviderTest { @After fun after() = - runBlocking { + runTest { OpenFeatureAPI.shutdown() mockWebServer?.shutdown() mockWebServer = null @@ -53,43 +56,45 @@ class OfrepProviderTest { @Test fun `should be in Fatal status if 401 error during initialise`(): Unit = - runBlocking { + runTest { enqueueMockResponse("ofrep/valid_api_response.json", 401) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) var providerErrorReceived = false - val coroutineScope = CoroutineScope(Dispatchers.IO) - coroutineScope.launch { + launch { provider.observe().filterIsInstance().take(1).collect { providerErrorReceived = true } } + runCurrent() OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) + runCurrent() assert(providerErrorReceived) { "ProviderError event was not received" } } @Test fun `should be in Fatal status if 403 error during initialise`(): Unit = - runBlocking { + runTest { enqueueMockResponse("ofrep/valid_api_response.json", 403) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) var providerErrorReceived = false - val coroutineScope = CoroutineScope(Dispatchers.IO) - coroutineScope.launch { + launch { provider.observe().filterIsInstance().take(1).collect { providerErrorReceived = true } } + runCurrent() OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) + runCurrent() assert(providerErrorReceived) { "ProviderError event was not received" } } @Test fun `should be in Error status if 429 error during initialise`(): Unit = - runBlocking { + runTest { enqueueMockResponse( "ofrep/valid_api_response.json", 429, @@ -100,36 +105,38 @@ class OfrepProviderTest { var providerErrorReceived = false var exceptionReceived: Throwable? = null - val coroutineScope = CoroutineScope(Dispatchers.IO) - coroutineScope.launch { + launch { provider.observe().filterIsInstance().take(1).collect { providerErrorReceived = true exceptionReceived = it.error } } + runCurrent() OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) + runCurrent() assert(providerErrorReceived) { "ProviderError event was not received" } assert(exceptionReceived is OfrepError.ApiTooManyRequestsError) { "The exception is not of type ApiTooManyRequestsError" } } @Test fun `should be in Error status if error targeting key is empty`(): Unit = - runBlocking { + runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) var providerErrorReceived = false var exceptionReceived: Throwable? = null - val coroutineScope = CoroutineScope(Dispatchers.IO) - coroutineScope.launch { + launch { provider.observe().filterIsInstance().take(1).collect { providerErrorReceived = true exceptionReceived = it.error } } + runCurrent() val evalCtx = ImmutableContext(targetingKey = "") OpenFeatureAPI.setProviderAndWait(provider, evalCtx, Dispatchers.IO) + runCurrent() assert(providerErrorReceived) { "ProviderError event was not received" } assert( exceptionReceived is OpenFeatureError.TargetingKeyMissingError, @@ -138,22 +145,23 @@ class OfrepProviderTest { @Test fun `should be in Error status if error targeting key is missing`(): Unit = - runBlocking { + runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) var providerErrorReceived = false var exceptionReceived: Throwable? = null - val coroutineScope = CoroutineScope(Dispatchers.IO) - coroutineScope.launch { + launch { provider.observe().filterIsInstance().take(1).collect { providerErrorReceived = true exceptionReceived = it.error } } + runCurrent() val evalCtx = ImmutableContext() OpenFeatureAPI.setProviderAndWait(provider, evalCtx, Dispatchers.IO) + runCurrent() assert(providerErrorReceived) { "ProviderError event was not received" } assert( exceptionReceived is OpenFeatureError.TargetingKeyMissingError, @@ -162,48 +170,50 @@ class OfrepProviderTest { @Test fun `should be in error status if error invalid context`(): Unit = - runBlocking { + runTest { enqueueMockResponse("ofrep/invalid_context.json", 400) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) var providerErrorReceived = false var exceptionReceived: Throwable? = null - val coroutineScope = CoroutineScope(Dispatchers.IO) - coroutineScope.launch { + launch { provider.observe().filterIsInstance().take(1).collect { providerErrorReceived = true exceptionReceived = it.error } } + runCurrent() OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) + runCurrent() assert(providerErrorReceived) { "ProviderError event was not received" } assert(exceptionReceived is OpenFeatureError.InvalidContextError) { "The exception is not of type InvalidContextError" } } @Test fun `should be in error status if error parse error`(): Unit = - runBlocking { + runTest { enqueueMockResponse("ofrep/parse_error.json", 400) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) var providerErrorReceived = false var exceptionReceived: Throwable? = null - val coroutineScope = CoroutineScope(Dispatchers.IO) - coroutineScope.launch { + launch { provider.observe().filterIsInstance().take(1).collect { providerErrorReceived = true exceptionReceived = it.error } } + runCurrent() OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) + runCurrent() assert(providerErrorReceived) { "ProviderError event was not received" } assert(exceptionReceived is OpenFeatureError.ParseError) { "The exception is not of type ParseError" } } @Test fun `should return a flag not found error if the flag does not exist`(): Unit = - runBlocking { + runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) @@ -223,7 +233,7 @@ class OfrepProviderTest { @Test fun `should return evaluation details if the flag exists`(): Unit = - runBlocking { + runTest { enqueueMockResponse( "ofrep/valid_api_short_response.json", 200, @@ -252,7 +262,7 @@ class OfrepProviderTest { @Test fun `should return parse error if the API returns the error`(): Unit = - runBlocking { + runTest { enqueueMockResponse( "ofrep/valid_1_flag_in_parse_error.json", 200, @@ -275,7 +285,7 @@ class OfrepProviderTest { @Test fun `should send a context changed event if context has changed`(): Unit = - runBlocking { + runTest { enqueueMockResponse( "ofrep/valid_api_response.json", 200, @@ -292,8 +302,7 @@ class OfrepProviderTest { var providerStaleEventReceived = false var providerReadyEventReceived = false - val coroutineScope = CoroutineScope(Dispatchers.IO) - coroutineScope.launch { + launch { provider.observe().filterIsInstance().take(1).collect { providerStaleEventReceived = true } @@ -301,17 +310,19 @@ class OfrepProviderTest { providerReadyEventReceived = true } } + runCurrent() Thread.sleep(1000) // waiting to be sure that setEvaluationContext has been processed val newEvalCtx = ImmutableContext(targetingKey = UUID.randomUUID().toString()) OpenFeatureAPI.setEvaluationContext(newEvalCtx) Thread.sleep(1000) // waiting to be sure that setEvaluationContext has been processed + runCurrent() assert(providerStaleEventReceived) { "ProviderStale event was not received" } assert(providerReadyEventReceived) { "ProviderReady event was not received" } } @Test fun `should not try to call the API before Retry-After header`(): Unit = - runBlocking { + runTest { mockWebServer!!.enqueue( MockResponse() .setResponseCode(429) @@ -334,7 +345,7 @@ class OfrepProviderTest { @Test fun `should return a valid evaluation for Boolean`(): Unit = - runBlocking { + runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) @@ -361,7 +372,7 @@ class OfrepProviderTest { @Test fun `should return a valid evaluation for Int`(): Unit = - runBlocking { + runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) @@ -388,7 +399,7 @@ class OfrepProviderTest { @Test fun `should return a valid evaluation for Double`(): Unit = - runBlocking { + runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) @@ -415,7 +426,7 @@ class OfrepProviderTest { @Test fun `should return a valid evaluation for String`(): Unit = - runBlocking { + runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) @@ -442,7 +453,7 @@ class OfrepProviderTest { @Test fun `should return a valid evaluation for List`(): Unit = - runBlocking { + runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) @@ -474,7 +485,7 @@ class OfrepProviderTest { @Test fun `should return a valid evaluation for Map`(): Unit = - runBlocking { + runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) @@ -520,7 +531,7 @@ class OfrepProviderTest { @Test fun `should return TypeMismatch Bool`(): Unit = - runBlocking { + runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) @@ -540,7 +551,7 @@ class OfrepProviderTest { @Test fun `should return TypeMismatch String`(): Unit = - runBlocking { + runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) @@ -560,7 +571,7 @@ class OfrepProviderTest { @Test fun `should return TypeMismatch Double`(): Unit = - runBlocking { + runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) @@ -580,7 +591,7 @@ class OfrepProviderTest { @Test fun `should have different result if waiting for next polling interval`(): Unit = - runBlocking { + runTest { enqueueMockResponse( "ofrep/valid_api_short_response.json", 200, From 07a07d63bfd0a585c1cb7519eb3474e4d49e67dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Fri, 6 Jun 2025 20:11:28 +0200 Subject: [PATCH 06/21] Fix error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- .../org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt | 4 ++-- .../org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt index 72dbc08..661472d 100644 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt @@ -60,6 +60,8 @@ class OfrepProvider( } else { statusFlow.emit(OpenFeatureProviderEvents.ProviderReady) } + } catch (e: OpenFeatureError) { + statusFlow.emit(OpenFeatureProviderEvents.ProviderError(e)) } catch (e: Exception) { statusFlow.emit(OpenFeatureProviderEvents.ProviderError(OpenFeatureError.GeneralError(e.message ?: "Unknown error"))) } @@ -221,8 +223,6 @@ class OfrepProvider( } catch (e: OfrepError.ApiTooManyRequestsError) { this.retryAfter = calculateRetryDate(e.response?.headers?.get("Retry-After") ?: "") return BulkEvaluationStatus.RATE_LIMITED - } catch (e: Throwable) { - throw e } } diff --git a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt index bccf15f..f76b0fb 100644 --- a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt @@ -22,7 +22,6 @@ import okhttp3.Headers import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.gofeatureflag.openfeature.ofrep.bean.OfrepOptions -import org.gofeatureflag.openfeature.ofrep.error.OfrepError import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before @@ -115,7 +114,8 @@ class OfrepProviderTest { OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) runCurrent() assert(providerErrorReceived) { "ProviderError event was not received" } - assert(exceptionReceived is OfrepError.ApiTooManyRequestsError) { "The exception is not of type ApiTooManyRequestsError" } + assert(exceptionReceived is OpenFeatureError.GeneralError) { "The exception is not of type GeneralError" } + assert(exceptionReceived?.message == "Rate limited") { "The exception's message is not correct" } } @Test From cfd6d460d0ef2efaefb5056258919ae9f2b37541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Sat, 7 Jun 2025 10:52:42 +0200 Subject: [PATCH 07/21] Remove redundant suspend keywords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- .../kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt | 2 +- .../org/gofeatureflag/openfeature/ofrep/controller/OfrepApi.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt index 661472d..7df42df 100644 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt @@ -190,7 +190,7 @@ class OfrepProvider( * Evaluate the flags for the given context. * It will store the flags in the in-memory cache, if any error occurs it will throw an exception. */ - private suspend fun evaluateFlags(context: EvaluationContext): BulkEvaluationStatus { + private fun evaluateFlags(context: EvaluationContext): BulkEvaluationStatus { if (this.retryAfter != null && this.retryAfter!! > Date()) { return BulkEvaluationStatus.RATE_LIMITED } diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApi.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApi.kt index c4ca4c5..ab6b369 100644 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApi.kt +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApi.kt @@ -48,7 +48,7 @@ class OfrepApi( /** * Call the OFREP API to evaluate in bulk the flags for the given context. */ - suspend fun postBulkEvaluateFlags(context: EvaluationContext?): PostBulkEvaluationResult { + fun postBulkEvaluateFlags(context: EvaluationContext?): PostBulkEvaluationResult { val nonNullContext = context ?: throw OpenFeatureError.InvalidContextError("EvaluationContext is null") validateContext(nonNullContext) From a8d2facee0e72a52621021782a5c1e7d91c7291c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Sat, 7 Jun 2025 10:54:40 +0200 Subject: [PATCH 08/21] Use MockWebServer as a jUnit External Resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- .../openfeature/ofrep/OfrepProviderTest.kt | 65 ++++++++--------- .../ofrep/controller/OfrepApiTest.kt | 71 ++++++++----------- 2 files changed, 59 insertions(+), 77 deletions(-) diff --git a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt index f76b0fb..07d56bb 100644 --- a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt @@ -24,27 +24,20 @@ import okhttp3.mockwebserver.MockWebServer import org.gofeatureflag.openfeature.ofrep.bean.OfrepOptions import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Before +import org.junit.Rule import org.junit.Test import java.util.UUID class OfrepProviderTest { - private var mockWebServer: MockWebServer? = null - private var defaultEvalCtx: EvaluationContext = + @get:Rule + val mockWebServer = MockWebServer() + private val defaultEvalCtx: EvaluationContext = ImmutableContext(targetingKey = UUID.randomUUID().toString()) - @Before - fun before() { - mockWebServer = MockWebServer() - mockWebServer!!.start(10031) - } - @After fun after() = runTest { OpenFeatureAPI.shutdown() - mockWebServer?.shutdown() - mockWebServer = null } @Test @@ -58,7 +51,7 @@ class OfrepProviderTest { runTest { enqueueMockResponse("ofrep/valid_api_response.json", 401) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) var providerErrorReceived = false launch { @@ -77,7 +70,7 @@ class OfrepProviderTest { runTest { enqueueMockResponse("ofrep/valid_api_response.json", 403) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) var providerErrorReceived = false launch { @@ -100,7 +93,7 @@ class OfrepProviderTest { Headers.headersOf("Retry-After", "3"), ) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) var providerErrorReceived = false var exceptionReceived: Throwable? = null @@ -123,7 +116,7 @@ class OfrepProviderTest { runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) var providerErrorReceived = false var exceptionReceived: Throwable? = null @@ -148,7 +141,7 @@ class OfrepProviderTest { runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) var providerErrorReceived = false var exceptionReceived: Throwable? = null @@ -172,7 +165,7 @@ class OfrepProviderTest { fun `should be in error status if error invalid context`(): Unit = runTest { enqueueMockResponse("ofrep/invalid_context.json", 400) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) var providerErrorReceived = false var exceptionReceived: Throwable? = null @@ -194,7 +187,7 @@ class OfrepProviderTest { runTest { enqueueMockResponse("ofrep/parse_error.json", 400) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) var providerErrorReceived = false var exceptionReceived: Throwable? = null @@ -215,7 +208,7 @@ class OfrepProviderTest { fun `should return a flag not found error if the flag does not exist`(): Unit = runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getBooleanDetails("non-existent-flag", false) @@ -238,7 +231,7 @@ class OfrepProviderTest { "ofrep/valid_api_short_response.json", 200, ) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getStringDetails("title-flag", "default") @@ -267,7 +260,7 @@ class OfrepProviderTest { "ofrep/valid_1_flag_in_parse_error.json", 200, ) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getStringDetails("my-other-flag", "default") @@ -294,7 +287,7 @@ class OfrepProviderTest { "ofrep/valid_api_response_2.json", 200, ) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) // TODO: should change when we have a way to observe context changes event @@ -323,7 +316,7 @@ class OfrepProviderTest { @Test fun `should not try to call the API before Retry-After header`(): Unit = runTest { - mockWebServer!!.enqueue( + mockWebServer.enqueue( MockResponse() .setResponseCode(429) .setHeader("Retry-After", "3"), @@ -332,7 +325,7 @@ class OfrepProviderTest { OfrepProvider( OfrepOptions( pollingIntervalInMillis = 100, - endpoint = mockWebServer?.url("/").toString(), + endpoint = mockWebServer.url("/").toString(), ), ) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) @@ -340,14 +333,14 @@ class OfrepProviderTest { client.getStringDetails("my-other-flag", "default") client.getStringDetails("my-other-flag", "default") Thread.sleep(2000) // we wait 2 seconds to let the polling loop run - assertEquals(1, mockWebServer!!.requestCount) + assertEquals(1, mockWebServer.requestCount) } @Test fun `should return a valid evaluation for Boolean`(): Unit = runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getBooleanDetails("bool-flag", false) @@ -374,7 +367,7 @@ class OfrepProviderTest { fun `should return a valid evaluation for Int`(): Unit = runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getIntegerDetails("int-flag", 1) @@ -401,7 +394,7 @@ class OfrepProviderTest { fun `should return a valid evaluation for Double`(): Unit = runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getDoubleDetails("double-flag", 1.1) @@ -428,7 +421,7 @@ class OfrepProviderTest { fun `should return a valid evaluation for String`(): Unit = runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getStringDetails("string-flag", "default") @@ -455,7 +448,7 @@ class OfrepProviderTest { fun `should return a valid evaluation for List`(): Unit = runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = @@ -487,7 +480,7 @@ class OfrepProviderTest { fun `should return a valid evaluation for Map`(): Unit = runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = @@ -533,7 +526,7 @@ class OfrepProviderTest { fun `should return TypeMismatch Bool`(): Unit = runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getBooleanDetails("object-flag", false) @@ -553,7 +546,7 @@ class OfrepProviderTest { fun `should return TypeMismatch String`(): Unit = runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getStringDetails("object-flag", "default") @@ -573,7 +566,7 @@ class OfrepProviderTest { fun `should return TypeMismatch Double`(): Unit = runTest { enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer?.url("/").toString())) + val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getDoubleDetails("object-flag", 1.233) @@ -605,7 +598,7 @@ class OfrepProviderTest { OfrepProvider( OfrepOptions( pollingIntervalInMillis = 100, - endpoint = mockWebServer?.url("/").toString(), + endpoint = mockWebServer.url("/").toString(), ), ) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) @@ -648,6 +641,6 @@ class OfrepProviderTest { if (headers != null) { resp = resp.setHeaders(headers) } - mockWebServer!!.enqueue(resp) + mockWebServer.enqueue(resp) } } diff --git a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApiTest.kt b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApiTest.kt index 3dee054..e709ad4 100644 --- a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApiTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApiTest.kt @@ -13,37 +13,26 @@ import okhttp3.mockwebserver.MockWebServer import org.gofeatureflag.openfeature.ofrep.bean.OfrepOptions import org.gofeatureflag.openfeature.ofrep.error.OfrepError import org.gofeatureflag.openfeature.ofrep.getResourceAsString -import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue -import org.junit.Before +import org.junit.Rule import org.junit.Test class OfrepApiTest { - private var mockWebServer: MockWebServer? = null - - @Before - fun before() { - mockWebServer = MockWebServer() - mockWebServer!!.start(10031) - } - - @After - fun after() { - mockWebServer!!.shutdown() - } + @get:Rule + val mockWebServer = MockWebServer() @Test fun shouldReturnAValidEvaluationResponse() = runBlocking { val jsonString = getResourceAsString("ofrep/valid_api_short_response.json") - mockWebServer!!.enqueue(MockResponse().setBody(jsonString.trimIndent())) + mockWebServer.enqueue(MockResponse().setBody(jsonString.trimIndent())) val ofrepApi = OfrepApi( - OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + OfrepOptions(endpoint = mockWebServer.url("/").toString()), ) val ctx = ImmutableContext( @@ -101,10 +90,10 @@ class OfrepApiTest { @Test fun shouldThrowAnUnauthorizedError(): Unit = runBlocking { - mockWebServer!!.enqueue(MockResponse().setBody("{}").setResponseCode(401)) + mockWebServer.enqueue(MockResponse().setBody("{}").setResponseCode(401)) val ofrepApi = OfrepApi( - OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + OfrepOptions(endpoint = mockWebServer.url("/").toString()), ) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") assertThrows(OfrepError.ApiUnauthorizedError::class.java) { @@ -117,10 +106,10 @@ class OfrepApiTest { @Test fun shouldThrowAForbiddenError(): Unit = runBlocking { - mockWebServer!!.enqueue(MockResponse().setBody("{}").setResponseCode(403)) + mockWebServer.enqueue(MockResponse().setBody("{}").setResponseCode(403)) val ofrepApi = OfrepApi( - OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + OfrepOptions(endpoint = mockWebServer.url("/").toString()), ) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") assertThrows(OfrepError.ForbiddenError::class.java) { @@ -133,7 +122,7 @@ class OfrepApiTest { @Test fun shouldThrowTooManyRequest(): Unit = runBlocking { - mockWebServer!!.enqueue( + mockWebServer.enqueue( MockResponse() .setBody("{}") .setResponseCode(429) @@ -141,7 +130,7 @@ class OfrepApiTest { ) val ofrepApi = OfrepApi( - OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + OfrepOptions(endpoint = mockWebServer.url("/").toString()), ) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") try { @@ -156,14 +145,14 @@ class OfrepApiTest { @Test fun shouldThrowUnexpectedError(): Unit = runBlocking { - mockWebServer!!.enqueue( + mockWebServer.enqueue( MockResponse() .setBody("{}") .setResponseCode(500), ) val ofrepApi = OfrepApi( - OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + OfrepOptions(endpoint = mockWebServer.url("/").toString()), ) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") @@ -177,7 +166,7 @@ class OfrepApiTest { @Test fun shouldReturnAnEvaluationResponseInError(): Unit = runBlocking { - mockWebServer!!.enqueue( + mockWebServer.enqueue( MockResponse() .setBody( """ @@ -187,7 +176,7 @@ class OfrepApiTest { ) val ofrepApi = OfrepApi( - OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + OfrepOptions(endpoint = mockWebServer.url("/").toString()), ) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") @@ -201,14 +190,14 @@ class OfrepApiTest { @Test fun shouldReturnaEvaluationResponseIfWeReceiveA304(): Unit = runBlocking { - mockWebServer!!.enqueue( + mockWebServer.enqueue( MockResponse() .setBody("{}") .setResponseCode(304), ) val ofrepApi = OfrepApi( - OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + OfrepOptions(endpoint = mockWebServer.url("/").toString()), ) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") @@ -220,14 +209,14 @@ class OfrepApiTest { @Test fun shouldThrowTargetingKeyMissingErrorWithNoTargetingKey(): Unit = runBlocking { - mockWebServer!!.enqueue( + mockWebServer.enqueue( MockResponse() .setBody("{}") .setResponseCode(304), ) val ofrepApi = OfrepApi( - OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + OfrepOptions(endpoint = mockWebServer.url("/").toString()), ) val ctx = ImmutableContext(targetingKey = "") @@ -243,12 +232,12 @@ class OfrepApiTest { runBlocking { val jsonString = getResourceAsString("ofrep/invalid_api_response.json") - mockWebServer!!.enqueue( + mockWebServer.enqueue( MockResponse().setBody(jsonString.trimIndent()).setResponseCode(400), ) val ofrepApi = OfrepApi( - OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + OfrepOptions(endpoint = mockWebServer.url("/").toString()), ) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") @@ -264,7 +253,7 @@ class OfrepApiTest { runBlocking { val jsonString = getResourceAsString("ofrep/invalid_api_response.json") - mockWebServer!!.enqueue( + mockWebServer.enqueue( MockResponse().setBody(jsonString.trimIndent()).setResponseCode(400), ) assertThrows(OfrepError.InvalidOptionsError::class.java) { @@ -279,13 +268,13 @@ class OfrepApiTest { runBlocking { val jsonString = getResourceAsString("ofrep/valid_api_response.json") - mockWebServer!!.enqueue( + mockWebServer.enqueue( MockResponse() .setBody(jsonString.trimIndent()) .setResponseCode(200) .addHeader("ETag", "123"), ) - mockWebServer!!.enqueue( + mockWebServer.enqueue( MockResponse() .setResponseCode(304) .addHeader("ETag", "123"), @@ -293,7 +282,7 @@ class OfrepApiTest { val ofrepApi = OfrepApi( - OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + OfrepOptions(endpoint = mockWebServer.url("/").toString()), ) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") @@ -301,7 +290,7 @@ class OfrepApiTest { val eval2 = ofrepApi.postBulkEvaluateFlags(ctx) assertEquals(eval1.httpResponse.code, 200) assertEquals(eval2.httpResponse.code, 304) - assertEquals(2, mockWebServer!!.requestCount) + assertEquals(2, mockWebServer.requestCount) } @Test @@ -309,13 +298,13 @@ class OfrepApiTest { runBlocking { val jsonString = getResourceAsString("ofrep/valid_api_response.json") - mockWebServer!!.enqueue( + mockWebServer.enqueue( MockResponse() .setBody(jsonString.trimIndent()) .setResponseCode(200) .addHeader("ETag", "123"), ) - mockWebServer!!.enqueue( + mockWebServer.enqueue( MockResponse() .setResponseCode(304) .addHeader("ETag", "123"), @@ -323,7 +312,7 @@ class OfrepApiTest { val ofrepApi = OfrepApi( - OfrepOptions(endpoint = mockWebServer!!.url("/").toString()), + OfrepOptions(endpoint = mockWebServer.url("/").toString()), ) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") @@ -331,6 +320,6 @@ class OfrepApiTest { val eval2 = ofrepApi.postBulkEvaluateFlags(ctx) assertEquals(eval1.httpResponse.code, 200) assertEquals(eval2.httpResponse.code, 304) - assertEquals(2, mockWebServer!!.requestCount) + assertEquals(2, mockWebServer.requestCount) } } From acebf32d04a1fe0ce122611cea51bdbe7cc9c520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Sat, 7 Jun 2025 11:21:42 +0200 Subject: [PATCH 09/21] Make the API more idiomatic to Kotlin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- .../openfeature/ofrep/OfrepProvider.kt | 7 +-- .../openfeature/ofrep/bean/OfrepApiRequest.kt | 6 ++- .../openfeature/ofrep/bean/OfrepOptions.kt | 44 +++++++++---------- .../ofrep/bean/OfrepProviderMetadata.kt | 2 +- .../ofrep/bean/PostBulkEvaluationResult.kt | 2 +- .../openfeature/ofrep/controller/OfrepApi.kt | 33 ++++++++------ .../ofrep/enum/BulkEvaluationStatus.kt | 4 +- .../openfeature/ofrep/error/OfrepError.kt | 7 ++- .../openfeature/ofrep/OfrepProviderTest.kt | 5 ++- 9 files changed, 62 insertions(+), 48 deletions(-) diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt index 7df42df..15b67ff 100644 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt @@ -65,13 +65,13 @@ class OfrepProvider( } catch (e: Exception) { statusFlow.emit(OpenFeatureProviderEvents.ProviderError(OpenFeatureError.GeneralError(e.message ?: "Unknown error"))) } - this.startPolling(this.ofrepOptions.pollingIntervalInMillis) + startPolling() } /** * Start polling for flag updates */ - private fun startPolling(pollingIntervalInMillis: Long) { + private fun startPolling() { val task: TimerTask = object : TimerTask() { override fun run() { @@ -107,8 +107,9 @@ class OfrepProvider( } } val timer = Timer() + val pollingIntervalInMillis = ofrepOptions.pollingInterval.inWholeMilliseconds timer.schedule(task, pollingIntervalInMillis, pollingIntervalInMillis) - this.pollingTimer = timer + pollingTimer = timer } override fun getBooleanEvaluation( diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepApiRequest.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepApiRequest.kt index f8c19ad..98cf802 100644 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepApiRequest.kt +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepApiRequest.kt @@ -1,5 +1,7 @@ import dev.openfeature.sdk.EvaluationContext -data class OfrepApiRequest(@Transient val ctx: EvaluationContext) { +data class OfrepApiRequest( + @Transient val ctx: EvaluationContext, +) { private val context: Map = ctx.asObjectMap().plus("targetingKey" to ctx.getTargetingKey()) -} \ No newline at end of file +} diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepOptions.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepOptions.kt index 6ca62b8..4811318 100644 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepOptions.kt +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepOptions.kt @@ -1,44 +1,44 @@ package org.gofeatureflag.openfeature.ofrep.bean import okhttp3.Headers - +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds data class OfrepOptions( /** - * (mandatory) endpoint contains the DNS of your GO Feature Flag relay proxy - * example: https://mydomain.com/gofeatureflagproxy/ + * The endpoint of the OFREP API. + * + * Example: `https://mydomain.com/gofeatureflagproxy/` */ val endpoint: String, - /** - * (optional) timeout in millisecond we are waiting when calling the - * go-feature-flag relay proxy API. - * Default: 10000 ms + * Timeout of the OFREP API calls. + * + * Default: `10.seconds` */ - val timeout: Long = 10000, - + val timeout: Duration = 10.seconds, /** - * (optional) maxIdleConnections is the maximum number of connexions in the connexion pool. - * Default: 1000 + * MaxIdleConnections is the maximum number of connexions in the connexion pool. + * + * Default: `1000` */ val maxIdleConnections: Int = 1000, - /** - * (optional) keepAliveDuration is the time in millisecond we keep the connexion open. - * Default: 7200000 (2 hours) + * The time to keep the connection open. + * + * Default: `2.hours` */ - val keepAliveDuration: Long = 7200000, - - + val keepAliveDuration: Duration = 2.hours, /** - * (optional) headers to add to the OFREP calls + * Headers to add to the OFREP calls * Default: empty */ val headers: Headers? = null, - /** - * (optional) polling interval in millisecond to refresh the flags - * Default: 300000 (5 minutes) + * Polling interval to refresh the flags + * Default: `5.minutes` */ - val pollingIntervalInMillis: Long = 300000 + val pollingInterval: Duration = 5.minutes, ) diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepProviderMetadata.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepProviderMetadata.kt index b83731a..4e19153 100644 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepProviderMetadata.kt +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepProviderMetadata.kt @@ -5,4 +5,4 @@ import dev.openfeature.sdk.ProviderMetadata class OfrepProviderMetadata : ProviderMetadata { override val name: String get() = "OFREP Provider" -} \ No newline at end of file +} diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/PostBulkEvaluationResult.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/PostBulkEvaluationResult.kt index bedda64..232134c 100644 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/PostBulkEvaluationResult.kt +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/PostBulkEvaluationResult.kt @@ -4,7 +4,7 @@ import OfrepApiResponse data class PostBulkEvaluationResult( val apiResponse: OfrepApiResponse?, - val httpResponse: okhttp3.Response + val httpResponse: okhttp3.Response, ) { fun isError(): Boolean { return apiResponse?.errorCode != null diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApi.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApi.kt index ab6b369..a356c01 100644 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApi.kt +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApi.kt @@ -26,25 +26,30 @@ class OfrepApi( GsonBuilder().setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE).create() } - private var httpClient: OkHttpClient = - OkHttpClient - .Builder() - .connectTimeout(this.options.timeout, TimeUnit.MILLISECONDS) - .readTimeout(this.options.timeout, TimeUnit.MILLISECONDS) - .callTimeout(this.options.timeout, TimeUnit.MILLISECONDS) - .writeTimeout(this.options.timeout, TimeUnit.MILLISECONDS) - .connectionPool( - ConnectionPool( - this.options.maxIdleConnections, - this.options.keepAliveDuration, - TimeUnit.MILLISECONDS, - ), - ).build() + private val httpClient: OkHttpClient private var parsedEndpoint: HttpUrl = options.endpoint.toHttpUrlOrNull() ?: throw OfrepError.InvalidOptionsError("invalid endpoint configuration: ${options.endpoint}") private var etag: String? = null + init { + val timeoutInMilliseconds = options.timeout.inWholeMilliseconds + httpClient = + OkHttpClient + .Builder() + .connectTimeout(timeoutInMilliseconds, TimeUnit.MILLISECONDS) + .readTimeout(timeoutInMilliseconds, TimeUnit.MILLISECONDS) + .callTimeout(timeoutInMilliseconds, TimeUnit.MILLISECONDS) + .writeTimeout(timeoutInMilliseconds, TimeUnit.MILLISECONDS) + .connectionPool( + ConnectionPool( + options.maxIdleConnections, + options.keepAliveDuration.inWholeMilliseconds, + TimeUnit.MILLISECONDS, + ), + ).build() + } + /** * Call the OFREP API to evaluate in bulk the flags for the given context. */ diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/enum/BulkEvaluationStatus.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/enum/BulkEvaluationStatus.kt index cea1377..ccde185 100644 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/enum/BulkEvaluationStatus.kt +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/enum/BulkEvaluationStatus.kt @@ -3,5 +3,5 @@ package org.gofeatureflag.openfeature.ofrep.enum enum class BulkEvaluationStatus { SUCCESS_NO_CHANGE, SUCCESS_UPDATED, - RATE_LIMITED -} \ No newline at end of file + RATE_LIMITED, +} diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/error/OfrepError.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/error/OfrepError.kt index c44d865..e9326c8 100644 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/error/OfrepError.kt +++ b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/error/OfrepError.kt @@ -2,9 +2,14 @@ package org.gofeatureflag.openfeature.ofrep.error sealed class OfrepError : Exception() { class ApiUnauthorizedError(val response: okhttp3.Response) : OfrepError() + class ForbiddenError(val response: okhttp3.Response) : OfrepError() + class ApiTooManyRequestsError(val response: okhttp3.Response? = null) : OfrepError() + class UnexpectedResponseError(val response: okhttp3.Response) : OfrepError() + class UnmarshallError(val e: Exception) : OfrepError() + class InvalidOptionsError(override val message: String?) : OfrepError() -} \ No newline at end of file +} diff --git a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt index 07d56bb..66b2e62 100644 --- a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt @@ -27,6 +27,7 @@ import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds class OfrepProviderTest { @get:Rule @@ -324,7 +325,7 @@ class OfrepProviderTest { val provider = OfrepProvider( OfrepOptions( - pollingIntervalInMillis = 100, + pollingInterval = 100.milliseconds, endpoint = mockWebServer.url("/").toString(), ), ) @@ -597,7 +598,7 @@ class OfrepProviderTest { val provider = OfrepProvider( OfrepOptions( - pollingIntervalInMillis = 100, + pollingInterval = 100.milliseconds, endpoint = mockWebServer.url("/").toString(), ), ) From 4ea833421dc5b8dbe7c4f36eef95af6ce3ed0c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Sat, 7 Jun 2025 11:24:35 +0200 Subject: [PATCH 10/21] Rename base package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- .../contrib/providers}/ofrep/OfrepProvider.kt | 12 ++++----- .../providers}/ofrep/bean/OfrepApiRequest.kt | 0 .../providers}/ofrep/bean/OfrepApiResponse.kt | 0 .../providers}/ofrep/bean/OfrepOptions.kt | 2 +- .../ofrep/bean/OfrepProviderMetadata.kt | 2 +- .../ofrep/bean/PostBulkEvaluationResult.kt | 6 ++--- .../providers}/ofrep/controller/OfrepApi.kt | 8 +++--- .../ofrep/enum/BulkEvaluationStatus.kt | 2 +- .../providers/ofrep/error/OfrepError.kt | 27 +++++++++++++++++++ .../openfeature/ofrep/error/OfrepError.kt | 15 ----------- .../providers}/ofrep/GetResourceAsString.kt | 2 +- .../providers}/ofrep/OfrepProviderTest.kt | 4 +-- .../ofrep/controller/OfrepApiTest.kt | 8 +++--- 13 files changed, 49 insertions(+), 39 deletions(-) rename providers/ofrep/src/commonMain/kotlin/{org/gofeatureflag/openfeature => dev/openfeature/kotlin/contrib/providers}/ofrep/OfrepProvider.kt (95%) rename providers/ofrep/src/commonMain/kotlin/{org/gofeatureflag/openfeature => dev/openfeature/kotlin/contrib/providers}/ofrep/bean/OfrepApiRequest.kt (100%) rename providers/ofrep/src/commonMain/kotlin/{org/gofeatureflag/openfeature => dev/openfeature/kotlin/contrib/providers}/ofrep/bean/OfrepApiResponse.kt (100%) rename providers/ofrep/src/commonMain/kotlin/{org/gofeatureflag/openfeature => dev/openfeature/kotlin/contrib/providers}/ofrep/bean/OfrepOptions.kt (94%) rename providers/ofrep/src/commonMain/kotlin/{org/gofeatureflag/openfeature => dev/openfeature/kotlin/contrib/providers}/ofrep/bean/OfrepProviderMetadata.kt (72%) rename providers/ofrep/src/commonMain/kotlin/{org/gofeatureflag/openfeature => dev/openfeature/kotlin/contrib/providers}/ofrep/bean/PostBulkEvaluationResult.kt (53%) rename providers/ofrep/src/commonMain/kotlin/{org/gofeatureflag/openfeature => dev/openfeature/kotlin/contrib/providers}/ofrep/controller/OfrepApi.kt (93%) rename providers/ofrep/src/commonMain/kotlin/{org/gofeatureflag/openfeature => dev/openfeature/kotlin/contrib/providers}/ofrep/enum/BulkEvaluationStatus.kt (62%) create mode 100644 providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/error/OfrepError.kt delete mode 100644 providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/error/OfrepError.kt rename providers/ofrep/src/commonTest/kotlin/{org/gofeatureflag/openfeature => dev/openfeature/kotlin/contrib/providers}/ofrep/GetResourceAsString.kt (80%) rename providers/ofrep/src/commonTest/kotlin/{org/gofeatureflag/openfeature => dev/openfeature/kotlin/contrib/providers}/ofrep/OfrepProviderTest.kt (99%) rename providers/ofrep/src/commonTest/kotlin/{org/gofeatureflag/openfeature => dev/openfeature/kotlin/contrib/providers}/ofrep/controller/OfrepApiTest.kt (97%) diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProvider.kt similarity index 95% rename from providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt rename to providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProvider.kt index 15b67ff..2394f8e 100644 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProvider.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProvider.kt @@ -1,6 +1,11 @@ -package org.gofeatureflag.openfeature.ofrep +package dev.openfeature.kotlin.contrib.providers.ofrep import FlagDto +import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepOptions +import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepProviderMetadata +import dev.openfeature.kotlin.contrib.providers.ofrep.controller.OfrepApi +import dev.openfeature.kotlin.contrib.providers.ofrep.enum.BulkEvaluationStatus +import dev.openfeature.kotlin.contrib.providers.ofrep.error.OfrepError import dev.openfeature.sdk.EvaluationContext import dev.openfeature.sdk.FeatureProvider import dev.openfeature.sdk.Hook @@ -14,11 +19,6 @@ import dev.openfeature.sdk.exceptions.OpenFeatureError import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.runBlocking -import org.gofeatureflag.openfeature.ofrep.bean.OfrepOptions -import org.gofeatureflag.openfeature.ofrep.bean.OfrepProviderMetadata -import org.gofeatureflag.openfeature.ofrep.controller.OfrepApi -import org.gofeatureflag.openfeature.ofrep.enum.BulkEvaluationStatus -import org.gofeatureflag.openfeature.ofrep.error.OfrepError import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepApiRequest.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepApiRequest.kt similarity index 100% rename from providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepApiRequest.kt rename to providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepApiRequest.kt diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepApiResponse.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepApiResponse.kt similarity index 100% rename from providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepApiResponse.kt rename to providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepApiResponse.kt diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepOptions.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepOptions.kt similarity index 94% rename from providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepOptions.kt rename to providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepOptions.kt index 4811318..ba83bf5 100644 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepOptions.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepOptions.kt @@ -1,4 +1,4 @@ -package org.gofeatureflag.openfeature.ofrep.bean +package dev.openfeature.kotlin.contrib.providers.ofrep.bean import okhttp3.Headers import kotlin.time.Duration diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepProviderMetadata.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepProviderMetadata.kt similarity index 72% rename from providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepProviderMetadata.kt rename to providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepProviderMetadata.kt index 4e19153..bc5f500 100644 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/OfrepProviderMetadata.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepProviderMetadata.kt @@ -1,4 +1,4 @@ -package org.gofeatureflag.openfeature.ofrep.bean +package dev.openfeature.kotlin.contrib.providers.ofrep.bean import dev.openfeature.sdk.ProviderMetadata diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/PostBulkEvaluationResult.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/PostBulkEvaluationResult.kt similarity index 53% rename from providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/PostBulkEvaluationResult.kt rename to providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/PostBulkEvaluationResult.kt index 232134c..066d262 100644 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/bean/PostBulkEvaluationResult.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/PostBulkEvaluationResult.kt @@ -1,4 +1,4 @@ -package org.gofeatureflag.openfeature.ofrep.bean +package dev.openfeature.kotlin.contrib.providers.ofrep.bean import OfrepApiResponse @@ -6,7 +6,5 @@ data class PostBulkEvaluationResult( val apiResponse: OfrepApiResponse?, val httpResponse: okhttp3.Response, ) { - fun isError(): Boolean { - return apiResponse?.errorCode != null - } + fun isError(): Boolean = apiResponse?.errorCode != null } diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApi.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApi.kt similarity index 93% rename from providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApi.kt rename to providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApi.kt index a356c01..194699f 100644 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApi.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApi.kt @@ -1,10 +1,13 @@ -package org.gofeatureflag.openfeature.ofrep.controller +package dev.openfeature.kotlin.contrib.providers.ofrep.controller import OfrepApiRequest import OfrepApiResponse import com.google.gson.GsonBuilder import com.google.gson.JsonSyntaxException import com.google.gson.ToNumberPolicy +import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepOptions +import dev.openfeature.kotlin.contrib.providers.ofrep.bean.PostBulkEvaluationResult +import dev.openfeature.kotlin.contrib.providers.ofrep.error.OfrepError import dev.openfeature.sdk.EvaluationContext import dev.openfeature.sdk.exceptions.OpenFeatureError import okhttp3.ConnectionPool @@ -13,9 +16,6 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.RequestBody.Companion.toRequestBody -import org.gofeatureflag.openfeature.ofrep.bean.OfrepOptions -import org.gofeatureflag.openfeature.ofrep.bean.PostBulkEvaluationResult -import org.gofeatureflag.openfeature.ofrep.error.OfrepError import java.util.concurrent.TimeUnit class OfrepApi( diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/enum/BulkEvaluationStatus.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/enum/BulkEvaluationStatus.kt similarity index 62% rename from providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/enum/BulkEvaluationStatus.kt rename to providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/enum/BulkEvaluationStatus.kt index ccde185..d25789d 100644 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/enum/BulkEvaluationStatus.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/enum/BulkEvaluationStatus.kt @@ -1,4 +1,4 @@ -package org.gofeatureflag.openfeature.ofrep.enum +package dev.openfeature.kotlin.contrib.providers.ofrep.enum enum class BulkEvaluationStatus { SUCCESS_NO_CHANGE, diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/error/OfrepError.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/error/OfrepError.kt new file mode 100644 index 0000000..fbf5ba2 --- /dev/null +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/error/OfrepError.kt @@ -0,0 +1,27 @@ +package dev.openfeature.kotlin.contrib.providers.ofrep.error + +sealed class OfrepError : Exception() { + class ApiUnauthorizedError( + val response: okhttp3.Response, + ) : OfrepError() + + class ForbiddenError( + val response: okhttp3.Response, + ) : OfrepError() + + class ApiTooManyRequestsError( + val response: okhttp3.Response? = null, + ) : OfrepError() + + class UnexpectedResponseError( + val response: okhttp3.Response, + ) : OfrepError() + + class UnmarshallError( + val e: Exception, + ) : OfrepError() + + class InvalidOptionsError( + override val message: String?, + ) : OfrepError() +} diff --git a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/error/OfrepError.kt b/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/error/OfrepError.kt deleted file mode 100644 index e9326c8..0000000 --- a/providers/ofrep/src/commonMain/kotlin/org/gofeatureflag/openfeature/ofrep/error/OfrepError.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.gofeatureflag.openfeature.ofrep.error - -sealed class OfrepError : Exception() { - class ApiUnauthorizedError(val response: okhttp3.Response) : OfrepError() - - class ForbiddenError(val response: okhttp3.Response) : OfrepError() - - class ApiTooManyRequestsError(val response: okhttp3.Response? = null) : OfrepError() - - class UnexpectedResponseError(val response: okhttp3.Response) : OfrepError() - - class UnmarshallError(val e: Exception) : OfrepError() - - class InvalidOptionsError(override val message: String?) : OfrepError() -} diff --git a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/GetResourceAsString.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/GetResourceAsString.kt similarity index 80% rename from providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/GetResourceAsString.kt rename to providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/GetResourceAsString.kt index 906f7c2..302062b 100644 --- a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/GetResourceAsString.kt +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/GetResourceAsString.kt @@ -1,4 +1,4 @@ -package org.gofeatureflag.openfeature.ofrep +package dev.openfeature.kotlin.contrib.providers.ofrep import okio.FileSystem import okio.Path.Companion.toPath diff --git a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt similarity index 99% rename from providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt rename to providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt index 66b2e62..c62e1bc 100644 --- a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/OfrepProviderTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt @@ -1,7 +1,8 @@ @file:OptIn(ExperimentalCoroutinesApi::class) -package org.gofeatureflag.openfeature.ofrep +package dev.openfeature.kotlin.contrib.providers.ofrep +import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepOptions import dev.openfeature.sdk.EvaluationContext import dev.openfeature.sdk.EvaluationMetadata import dev.openfeature.sdk.FlagEvaluationDetails @@ -21,7 +22,6 @@ import kotlinx.coroutines.test.runTest import okhttp3.Headers import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer -import org.gofeatureflag.openfeature.ofrep.bean.OfrepOptions import org.junit.After import org.junit.Assert.assertEquals import org.junit.Rule diff --git a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApiTest.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt similarity index 97% rename from providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApiTest.kt rename to providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt index e709ad4..e1109a2 100644 --- a/providers/ofrep/src/commonTest/kotlin/org/gofeatureflag/openfeature/ofrep/controller/OfrepApiTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt @@ -1,7 +1,10 @@ -package org.gofeatureflag.openfeature.ofrep.controller +package dev.openfeature.kotlin.contrib.providers.ofrep.controller import FlagDto import OfrepApiResponse +import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepOptions +import dev.openfeature.kotlin.contrib.providers.ofrep.error.OfrepError +import dev.openfeature.kotlin.contrib.providers.ofrep.getResourceAsString import dev.openfeature.sdk.ImmutableContext import dev.openfeature.sdk.Value import dev.openfeature.sdk.exceptions.ErrorCode @@ -10,9 +13,6 @@ import junit.framework.TestCase.assertFalse import kotlinx.coroutines.runBlocking import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer -import org.gofeatureflag.openfeature.ofrep.bean.OfrepOptions -import org.gofeatureflag.openfeature.ofrep.error.OfrepError -import org.gofeatureflag.openfeature.ofrep.getResourceAsString import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue From ec61be48ed072fba0b66fddd92f9fc8a56caa060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Mon, 9 Jun 2025 15:50:04 +0200 Subject: [PATCH 11/21] Replace OkHttp + GSON with Ktor + kontlinx-serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- gradle/libs.versions.toml | 7 +- providers/ofrep/README.md | 94 ++------- providers/ofrep/build.gradle.kts | 10 +- .../contrib/providers/ofrep/OfrepProvider.kt | 24 +-- .../providers/ofrep/bean/OfrepApiRequest.kt | 15 +- .../providers/ofrep/bean/OfrepApiResponse.kt | 198 +++++------------ .../providers/ofrep/bean/OfrepOptions.kt | 3 +- .../ofrep/bean/OfrepProviderMetadata.kt | 2 +- .../ofrep/bean/PostBulkEvaluationResult.kt | 6 +- .../providers/ofrep/controller/OfrepApi.kt | 147 ++++++------- .../ofrep/enum/BulkEvaluationStatus.kt | 2 +- .../providers/ofrep/error/OfrepError.kt | 12 +- .../EvaluationContextSerializer.kt | 25 +++ .../EvaluationMetadataSerializer.kt | 58 +++++ .../ofrep/serialization/ValueSerializer.kt | 199 ++++++++++++++++++ .../providers/ofrep/OfrepProviderTest.kt | 24 ++- .../ofrep/controller/OfrepApiTest.kt | 111 ++++++---- 17 files changed, 548 insertions(+), 389 deletions(-) create mode 100644 providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/EvaluationContextSerializer.kt create mode 100644 providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/EvaluationMetadataSerializer.kt create mode 100644 providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/ValueSerializer.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e5321df..d606aac 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,9 +4,13 @@ kotlinx-coroutines = "1.10.2" okhttp = "4.12.0" open-feature-kotlin-sdk = "0.4.1" android = "8.10.1" +ktor = "3.1.3" [libraries] -okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +ktor-core = { module="io.ktor:ktor-client-core", version.ref="ktor" } +ktor-cio = { module="io.ktor:ktor-client-cio", version.ref="ktor" } +ktor-client-content-negotiation = { module="io.ktor:ktor-client-content-negotiation", version.ref="ktor" } +ktor-serialization-kotlinx-json = { module="io.ktor:ktor-serialization-kotlinx-json", version.ref="ktor" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } openfeature-kotlin-sdk = { group="dev.openfeature", name="kotlin-sdk", version.ref="open-feature-kotlin-sdk" } kotlin-test = { group="org.jetbrains.kotlin", name="kotlin-test", version.ref="kotlin" } @@ -16,6 +20,7 @@ kotlinx-coroutines-test = { group="org.jetbrains.kotlinx", name="kotlinx-corouti [plugins] kotlin-multiplatform = { id="org.jetbrains.kotlin.multiplatform", version.ref="kotlin" } kotlinx-atomicfu = { id="org.jetbrains.kotlinx.atomicfu", version="0.27.0" } +kotlinx-serialization = { id="org.jetbrains.kotlin.plugin.serialization", version="2.1.21" } ktlint = { id="org.jlleitschuh.gradle.ktlint", version="12.3.0" } nexus-publish = { id="io.github.gradle-nexus.publish-plugin", version="2.0.0" } binary-compatibility-validator = { id="org.jetbrains.kotlinx.binary-compatibility-validator", version="0.17.0" } diff --git a/providers/ofrep/README.md b/providers/ofrep/README.md index ddae9ea..aff159d 100644 --- a/providers/ofrep/README.md +++ b/providers/ofrep/README.md @@ -1,93 +1,31 @@ -# Environment Variables Kotlin Provider +# Kotlin OFREP Provider -Environment Variables provider allows you to read feature flags from the [process's environment](https://en.wikipedia.org/wiki/Environment_variable). +This provider is designed to use the [OpenFeature Remote Evaluation Protocol (OFREP)](https://openfeature.dev/specification/appendix-c). ## Supported platforms -| Supported | Platform | Supported versions | -|-----------|----------------------|--------------------------------------------------------------------------------| -| ❌ | Android | | -| ✅ | JVM | JDK 11+ | -| ✅ | Native | Linux x64 | -| ❌ | Native | [Other native targets](https://kotlinlang.org/docs/native-target-support.html) | -| ✅ | Javascript (Node.js) | | -| ❌ | Javascript (Browser) | | -| ❌ | Wasm | | +| Supported | Platform | Supported versions | +|-----------|----------------------|--------------------| +| ✅ | Android | SDK 21+ | +| ✅ | JVM | JDK 11+ | +| ❌ | Native | | +| ❌ | Javascript (Node.js) | | +| ❌ | Javascript (Browser) | | +| ❌ | Wasm | | ## Installation -```xml - - dev.openfeature.kotlin.contrib.providers - env-var - 0.1.0 - -``` - -## Usage - -To use the `EnvVarProvider` create an instance and use it as a provider: - ```kotlin - val provider = EnvVarProvider() - OpenFeatureAPI.setProviderAndWait(provider) +implementation("dev.openfeature.kotlin.contrib.providers:ofrep:0.1.0") ``` -### Configuring different methods for fetching environment variables +## Usage -This provider defines an `EnvironmentGateway` interface, which is used to access the actual environment variables. -The method [`platformSpecificEnvironmentGateway`][platformSpecificEnvironmentGateway], which is implemented for each supported platform, returns a default implementation. +To use the `OfrepProvider` create an instance and use it as a provider: ```kotlin - val testFake = EnvironmentGateway { arg -> "true" } // always returns true - - val provider = EnvVarProvider(testFake) - OpenFeatureAPI.getInstance().setProvider(provider) +val options = OfrepOptions(endpoint="https://localhost:8080") +val provider = OfrepProvider(options) +OpenFeatureAPI.setProviderAndWait(provider) ``` - -### Key transformation - -This provider supports transformation of keys to support different patterns used for naming feature flags and for -naming environment variables, e.g. SCREAMING_SNAKE_CASE env variables vs. hyphen-case keys for feature flags. -It supports chaining/combining different transformers incl. self-written ones by providing a transforming function in the constructor. -Currently, the following transformations are supported out of the box: - -- converting to lower case (e.g. `Feature.Flag` => `feature.flag`) -- converting to UPPER CASE (e.g. `Feature.Flag` => `FEATURE.FLAG`) -- converting hyphen-case to SCREAMING_SNAKE_CASE (e.g. `Feature-Flag` => `FEATURE_FLAG`) -- convert to camelCase (e.g. `FEATURE_FLAG` => `featureFlag`) -- replace '_' with '.' (e.g. `feature_flag` => `feature.flag`) -- replace '.' with '_' (e.g. `feature.flag` => `feature_flag`) - -**Examples:** - -1. hyphen-case feature flag names to screaming snake-case environment variables: - - ```kotlin - // Definition of the EnvVarProvider: - val provider = EnvVarProvider(EnvironmentKeyTransformer.hyphenCaseToScreamingSnake()) - ``` - -2. chained/composed transformations: - - ```kotlin - // Definition of the EnvVarProvider: - val keyTransformer = EnvironmentKeyTransformer - .toLowerCaseTransformer() - .andThen(EnvironmentKeyTransformer.replaceUnderscoreWithDotTransformer()) - - val provider = EnvVarProvider(keyTransformer) - ``` - -3. freely defined transformation function: - - ```kotlin - // Definition of the EnvVarProvider: - val keyTransformer = EnvironmentKeyTransformer { key -> key.substring(1) } - val provider = EnvVarProvider(keyTransformer) - ``` - - - -[platformSpecificEnvironmentGateway]: src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/envvar/PlatformSpecificEnvironmentGateway.kt diff --git a/providers/ofrep/build.gradle.kts b/providers/ofrep/build.gradle.kts index 1ef1ad2..e4a9f39 100644 --- a/providers/ofrep/build.gradle.kts +++ b/providers/ofrep/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.kotlin.multiplatform) - id("com.android.library") version "8.10.1" + alias(libs.plugins.kotlinx.serialization) + alias(libs.plugins.android.library) } kotlin { @@ -15,9 +16,10 @@ kotlin { api(libs.openfeature.kotlin.sdk) api(libs.kotlinx.coroutines.core) - api(libs.okhttp) - // TODO: replace with multiplatform JSON library - api("com.google.code.gson:gson:2.12.1") + implementation(libs.ktor.core) + implementation(libs.ktor.cio) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProvider.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProvider.kt index 2394f8e..ef27482 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProvider.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProvider.kt @@ -1,8 +1,9 @@ package dev.openfeature.kotlin.contrib.providers.ofrep -import FlagDto +import dev.openfeature.kotlin.contrib.providers.ofrep.bean.FlagDto import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepOptions import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepProviderMetadata +import dev.openfeature.kotlin.contrib.providers.ofrep.bean.toProviderEvaluation import dev.openfeature.kotlin.contrib.providers.ofrep.controller.OfrepApi import dev.openfeature.kotlin.contrib.providers.ofrep.enum.BulkEvaluationStatus import dev.openfeature.kotlin.contrib.providers.ofrep.error.OfrepError @@ -26,7 +27,6 @@ import java.util.Locale import java.util.TimeZone import java.util.Timer import java.util.TimerTask -import kotlin.reflect.KClass class OfrepProvider( private val ofrepOptions: OfrepOptions, @@ -116,31 +116,31 @@ class OfrepProvider( key: String, defaultValue: Boolean, context: EvaluationContext?, - ): ProviderEvaluation = genericEvaluation(key, Boolean::class) + ): ProviderEvaluation = genericEvaluation(key, defaultValue) override fun getDoubleEvaluation( key: String, defaultValue: Double, context: EvaluationContext?, - ): ProviderEvaluation = genericEvaluation(key, Double::class) + ): ProviderEvaluation = genericEvaluation(key, defaultValue) override fun getIntegerEvaluation( key: String, defaultValue: Int, context: EvaluationContext?, - ): ProviderEvaluation = genericEvaluation(key, Int::class) + ): ProviderEvaluation = genericEvaluation(key, defaultValue) override fun getObjectEvaluation( key: String, defaultValue: Value, context: EvaluationContext?, - ): ProviderEvaluation = genericEvaluation(key, Object::class) + ): ProviderEvaluation = genericEvaluation(key, defaultValue) override fun getStringEvaluation( key: String, defaultValue: String, context: EvaluationContext?, - ): ProviderEvaluation = genericEvaluation(key, String::class) + ): ProviderEvaluation = genericEvaluation(key, defaultValue) override suspend fun onContextSet( oldContext: EvaluationContext?, @@ -165,9 +165,9 @@ class OfrepProvider( this.pollingTimer?.cancel() } - private fun genericEvaluation( + private inline fun genericEvaluation( key: String, - expectedType: KClass<*>, + defaultValue: T, ): ProviderEvaluation { val flag = this.inMemoryCache[key] ?: throw OpenFeatureError.FlagNotFoundError(key) @@ -184,14 +184,14 @@ class OfrepProvider( else -> throw OpenFeatureError.GeneralError(flag.errorDetails ?: "general error") } } - return flag.toProviderEvaluation(expectedType) + return flag.toProviderEvaluation(defaultValue) } /** * Evaluate the flags for the given context. * It will store the flags in the in-memory cache, if any error occurs it will throw an exception. */ - private fun evaluateFlags(context: EvaluationContext): BulkEvaluationStatus { + private suspend fun evaluateFlags(context: EvaluationContext): BulkEvaluationStatus { if (this.retryAfter != null && this.retryAfter!! > Date()) { return BulkEvaluationStatus.RATE_LIMITED } @@ -202,7 +202,7 @@ class OfrepProvider( val ofrepEvalResp = postBulkEvaluateFlags.apiResponse val httpResp = postBulkEvaluateFlags.httpResponse - if (httpResp.code == 304) { + if (httpResp.status.value == 304) { return BulkEvaluationStatus.SUCCESS_NO_CHANGE } diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepApiRequest.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepApiRequest.kt index 98cf802..2013fbb 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepApiRequest.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepApiRequest.kt @@ -1,7 +1,12 @@ +package dev.openfeature.kotlin.contrib.providers.ofrep.bean + +import dev.openfeature.kotlin.contrib.providers.ofrep.serialization.EvaluationContextSerializer import dev.openfeature.sdk.EvaluationContext +import dev.openfeature.sdk.ImmutableContext +import kotlinx.serialization.Serializable -data class OfrepApiRequest( - @Transient val ctx: EvaluationContext, -) { - private val context: Map = ctx.asObjectMap().plus("targetingKey" to ctx.getTargetingKey()) -} +@Serializable +internal data class OfrepApiRequest( + @Serializable(with = EvaluationContextSerializer::class) + val ctx: EvaluationContext = ImmutableContext(), +) diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepApiResponse.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepApiResponse.kt index acf1950..cb95150 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepApiResponse.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepApiResponse.kt @@ -1,169 +1,69 @@ @file:OptIn(ExperimentalTime::class) +package dev.openfeature.kotlin.contrib.providers.ofrep.bean + +import dev.openfeature.kotlin.contrib.providers.ofrep.serialization.EvaluationMetadataSerializer +import dev.openfeature.kotlin.contrib.providers.ofrep.serialization.ValueSerializer import dev.openfeature.sdk.EvaluationMetadata import dev.openfeature.sdk.ProviderEvaluation import dev.openfeature.sdk.Value import dev.openfeature.sdk.exceptions.ErrorCode import dev.openfeature.sdk.exceptions.OpenFeatureError -import kotlin.reflect.KClass +import kotlinx.serialization.Serializable import kotlin.time.ExperimentalTime import kotlin.time.Instant -data class OfrepApiResponse( +@Serializable +internal data class OfrepApiResponse( val flags: List? = null, - val errorCode: ErrorCode?, - val errorDetails: String?, + val errorCode: ErrorCode? = null, + val errorDetails: String? = null, ) -data class FlagDto( - val value: Any, +@Serializable +internal data class FlagDto( + @Serializable(with = ValueSerializer::class) + val value: Value? = null, val key: String, - val reason: String, - val variant: String, - val errorCode: ErrorCode?, - val errorDetails: String?, - val metadata: Map? = emptyMap(), + val reason: String? = null, + val variant: String? = null, + val errorCode: ErrorCode? = null, + val errorDetails: String? = null, + @Serializable(with = EvaluationMetadataSerializer::class) + val metadata: EvaluationMetadata = EvaluationMetadata.EMPTY, ) { fun isError(): Boolean = errorCode != null +} - fun toProviderEvaluation(expectedType: KClass<*>): ProviderEvaluation { - if (!expectedType.isInstance(value)) { - val isSpecialCase = - expectedType == Int::class && value is Long && value.toInt().toLong() == value - if (!isSpecialCase) { - throw OpenFeatureError.TypeMismatchError("Type mismatch: expect ${expectedType.simpleName} - Unsupported type for: $value") - } - } - - if (expectedType == Int::class) { - val typedValue = (value as Long).toInt() - return ProviderEvaluation( - value = typedValue as T, - reason = reason, - variant = variant, - errorCode = errorCode, - errorMessage = errorDetails, - metadata = convertMetadata(metadata), - ) - } - - if (expectedType == Object::class) { - if (value is List<*>) { - val typedValue = Value.List(convertList(value as List)) - return ProviderEvaluation( - value = typedValue as T, - reason = reason, - variant = variant, - errorCode = errorCode, - errorMessage = errorDetails, - metadata = convertMetadata(metadata), - ) - } else if (value is Map<*, *>) { - val typedValue = convertObjectToStructure(value) - return ProviderEvaluation( - value = typedValue as T, - reason = reason, - variant = variant, - errorCode = errorCode, - errorMessage = errorDetails, - metadata = convertMetadata(metadata), - ) - } else { - throw IllegalArgumentException("Unsupported type for: $value") - } - } - - @Suppress("unchecked_cast") - return ProviderEvaluation( - value = value as T, - reason = reason, - variant = variant, - errorCode = errorCode, - errorMessage = errorDetails, - metadata = convertMetadata(metadata), - ) - } - - private fun convertMetadata(inputMap: Map?): EvaluationMetadata { - // check that inputMap is null or empty - if (inputMap.isNullOrEmpty()) { - return EvaluationMetadata.EMPTY - } - - val metadataBuilder = EvaluationMetadata.builder() - inputMap.forEach { entry -> - // switch case on entry.value types - when (entry.value) { - is String -> { - metadataBuilder.putString(entry.key, entry.value as String) - } - - is Boolean -> { - metadataBuilder.putBoolean(entry.key, entry.value as Boolean) - } - - is Int -> { - metadataBuilder.putInt(entry.key, entry.value as Int) - } - - is Long -> { - metadataBuilder.putInt(entry.key, (entry.value as Long).toInt()) - } - - is Double -> { - metadataBuilder.putDouble(entry.key, entry.value as Double) - } - } - } - - return metadataBuilder.build() - } - - private fun convertList(inputList: List<*>): List = - inputList.map { item -> - when (item) { - is String -> Value.String(item) - is Boolean -> Value.Boolean(item) - is Long -> Value.Integer(item.toInt()) - is Double -> Value.Double(item) - is Instant -> Value.Instant(item) - is Map<*, *> -> { - @Suppress("unchecked_cast") - Value.Structure(item as Map) - } - - is List<*> -> { - @Suppress("unchecked_cast") - Value.List(convertList(item as List)) - } - - else -> throw IllegalArgumentException( - "Unsupported type for: $item", - ) - } +@OptIn(ExperimentalTime::class) +inline fun Value.toPrimitive(): T { + val value: T? = + when (T::class) { + Boolean::class -> asBoolean() as T? + String::class -> asString() as T? + Int::class -> asInteger() as T? + Double::class -> + // doubles might have been serialized as integers + (asDouble() ?: asInteger()?.toDouble()) as T? + + Instant::class -> + // Instants might have been serialized as a string + (asInstant() ?: asString()?.let { Instant.parse(it) }) as T? + else -> error("toPrimitive not implemented for ${T::class}") } + return value ?: throw OpenFeatureError.TypeMismatchError( + "Type mismatch: expect ${T::class.simpleName} - Unsupported type for: $this", + ) +} - private fun convertObjectToStructure(obj: Any): Value.Structure { - if (obj !is Map<*, *>) { - throw IllegalArgumentException("Object must be a Map") - } - val convertedMap = - obj.entries.associate { (key, value) -> - if (key !is String) { - throw IllegalArgumentException("Map key must be a String") - } - key to - when (value) { - is String -> Value.String(value) - is Boolean -> Value.Boolean(value) - is Long -> Value.Integer(value.toInt()) - is Double -> Value.Double(value) - is Instant -> Value.Instant(value) - is Map<*, *> -> convertObjectToStructure(value) - is List<*> -> Value.List(convertList(value as List)) - else -> throw IllegalArgumentException("Unsupported type for: $value") - } - } - return Value.Structure(convertedMap) - } +internal inline fun FlagDto.toProviderEvaluation(default: T): ProviderEvaluation { + val convertedValue: T? = if (T::class == Value::class) value as T else value?.toPrimitive() + return ProviderEvaluation( + value = convertedValue ?: default, + reason = reason, + variant = variant, + errorCode = errorCode, + errorMessage = errorDetails, + metadata = metadata, + ) } diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepOptions.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepOptions.kt index ba83bf5..ca02baa 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepOptions.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepOptions.kt @@ -1,6 +1,5 @@ package dev.openfeature.kotlin.contrib.providers.ofrep.bean -import okhttp3.Headers import kotlin.time.Duration import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes @@ -35,7 +34,7 @@ data class OfrepOptions( * Headers to add to the OFREP calls * Default: empty */ - val headers: Headers? = null, + val headers: Map = emptyMap(), /** * Polling interval to refresh the flags * Default: `5.minutes` diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepProviderMetadata.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepProviderMetadata.kt index bc5f500..09cc881 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepProviderMetadata.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepProviderMetadata.kt @@ -2,7 +2,7 @@ package dev.openfeature.kotlin.contrib.providers.ofrep.bean import dev.openfeature.sdk.ProviderMetadata -class OfrepProviderMetadata : ProviderMetadata { +internal class OfrepProviderMetadata : ProviderMetadata { override val name: String get() = "OFREP Provider" } diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/PostBulkEvaluationResult.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/PostBulkEvaluationResult.kt index 066d262..d64c271 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/PostBulkEvaluationResult.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/PostBulkEvaluationResult.kt @@ -1,10 +1,10 @@ package dev.openfeature.kotlin.contrib.providers.ofrep.bean -import OfrepApiResponse +import io.ktor.client.statement.HttpResponse -data class PostBulkEvaluationResult( +internal data class PostBulkEvaluationResult( val apiResponse: OfrepApiResponse?, - val httpResponse: okhttp3.Response, + val httpResponse: HttpResponse, ) { fun isError(): Boolean = apiResponse?.errorCode != null } diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApi.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApi.kt index 194699f..818bbb4 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApi.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApi.kt @@ -1,106 +1,97 @@ package dev.openfeature.kotlin.contrib.providers.ofrep.controller -import OfrepApiRequest -import OfrepApiResponse -import com.google.gson.GsonBuilder -import com.google.gson.JsonSyntaxException -import com.google.gson.ToNumberPolicy +import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepApiRequest +import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepApiResponse import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepOptions import dev.openfeature.kotlin.contrib.providers.ofrep.bean.PostBulkEvaluationResult import dev.openfeature.kotlin.contrib.providers.ofrep.error.OfrepError import dev.openfeature.sdk.EvaluationContext import dev.openfeature.sdk.exceptions.OpenFeatureError -import okhttp3.ConnectionPool -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.OkHttpClient -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.concurrent.TimeUnit +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.engine.cio.endpoint +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.headers +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.Url +import io.ktor.http.appendPathSegments +import io.ktor.http.contentType +import io.ktor.http.parseUrl +import io.ktor.serialization.JsonConvertException +import io.ktor.serialization.kotlinx.json.json -class OfrepApi( - private val options: OfrepOptions, -) { - companion object { - private val gson = - GsonBuilder().setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE).create() +private fun createHttpClient(options: OfrepOptions): HttpClient = + HttpClient(CIO) { + install(ContentNegotiation) { + json() + } + engine { + maxConnectionsCount = options.maxIdleConnections + endpoint { + keepAliveTime = options.keepAliveDuration.inWholeMilliseconds + connectTimeout = options.timeout.inWholeMilliseconds + } + } } - private val httpClient: OkHttpClient - private var parsedEndpoint: HttpUrl = - options.endpoint.toHttpUrlOrNull() - ?: throw OfrepError.InvalidOptionsError("invalid endpoint configuration: ${options.endpoint}") +internal class OfrepApi( + private val options: OfrepOptions, +) { + private val httpClient: HttpClient = createHttpClient(options) + private var parsedEndpoint: Url = + parseUrl(options.endpoint) ?: throw OfrepError.InvalidOptionsError("invalid endpoint configuration: ${options.endpoint}") private var etag: String? = null - init { - val timeoutInMilliseconds = options.timeout.inWholeMilliseconds - httpClient = - OkHttpClient - .Builder() - .connectTimeout(timeoutInMilliseconds, TimeUnit.MILLISECONDS) - .readTimeout(timeoutInMilliseconds, TimeUnit.MILLISECONDS) - .callTimeout(timeoutInMilliseconds, TimeUnit.MILLISECONDS) - .writeTimeout(timeoutInMilliseconds, TimeUnit.MILLISECONDS) - .connectionPool( - ConnectionPool( - options.maxIdleConnections, - options.keepAliveDuration.inWholeMilliseconds, - TimeUnit.MILLISECONDS, - ), - ).build() - } - /** * Call the OFREP API to evaluate in bulk the flags for the given context. */ - fun postBulkEvaluateFlags(context: EvaluationContext?): PostBulkEvaluationResult { + internal suspend fun postBulkEvaluateFlags(context: EvaluationContext?): PostBulkEvaluationResult { val nonNullContext = context ?: throw OpenFeatureError.InvalidContextError("EvaluationContext is null") validateContext(nonNullContext) - val urlBuilder = - parsedEndpoint - .newBuilder() - .addEncodedPathSegment("ofrep") - .addEncodedPathSegment("v1") - .addEncodedPathSegment("evaluate") - .addEncodedPathSegment("flags") - - val mediaType = "application/json".toMediaTypeOrNull() - val requestBody = gson.toJson(OfrepApiRequest(nonNullContext)).toRequestBody(mediaType) - val reqBuilder = - okhttp3.Request - .Builder() - .url(urlBuilder.build()) - .post(requestBody) - - // add all the headers - options.headers?.let { reqBuilder.headers(it) } - etag?.let { reqBuilder.addHeader("If-None-Match", it) } - httpClient.newCall(reqBuilder.build()).execute().use { response -> - when (response.code) { - 401 -> throw OfrepError.ApiUnauthorizedError(response) - 403 -> throw OfrepError.ForbiddenError(response) - 429 -> throw OfrepError.ApiTooManyRequestsError(response) - 304 -> return PostBulkEvaluationResult(null, response) - in 200..299, 400 -> { - try { - response.headers["ETag"].let { this.etag = it } - val ofrepResp = - gson.fromJson(response.body?.string(), OfrepApiResponse::class.java) - return PostBulkEvaluationResult(ofrepResp, response) - } catch (e: JsonSyntaxException) { - throw OfrepError.UnmarshallError(e) - } catch (e: Exception) { - println(e) - throw OfrepError.UnexpectedResponseError(response) + val response = + httpClient.post(parsedEndpoint) { + url { + appendPathSegments("ofrep", "v1", "evaluate", "flags") + } + contentType(ContentType.Application.Json) + headers { + options.headers.forEach { + append(it.key, it.value) + } + etag?.let { + append(HttpHeaders.IfNoneMatch, it) } } + setBody(OfrepApiRequest(nonNullContext)) + } - else -> { + when (response.status.value) { + 401 -> throw OfrepError.ApiUnauthorizedError(response) + 403 -> throw OfrepError.ForbiddenError(response) + 429 -> throw OfrepError.ApiTooManyRequestsError(response) + 304 -> return PostBulkEvaluationResult(null, response) + in 200..299, 400 -> { + try { + response.headers[HttpHeaders.ETag].let { this.etag = it } + val ofrepResp: OfrepApiResponse? = response.body() + return PostBulkEvaluationResult(ofrepResp, response) + } catch (e: JsonConvertException) { + throw OfrepError.UnmarshallError(e) + } catch (e: Exception) { + println(e) throw OfrepError.UnexpectedResponseError(response) } } + + else -> { + throw OfrepError.UnexpectedResponseError(response) + } } } diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/enum/BulkEvaluationStatus.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/enum/BulkEvaluationStatus.kt index d25789d..c9c4cff 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/enum/BulkEvaluationStatus.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/enum/BulkEvaluationStatus.kt @@ -1,6 +1,6 @@ package dev.openfeature.kotlin.contrib.providers.ofrep.enum -enum class BulkEvaluationStatus { +internal enum class BulkEvaluationStatus { SUCCESS_NO_CHANGE, SUCCESS_UPDATED, RATE_LIMITED, diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/error/OfrepError.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/error/OfrepError.kt index fbf5ba2..a510184 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/error/OfrepError.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/error/OfrepError.kt @@ -1,20 +1,22 @@ package dev.openfeature.kotlin.contrib.providers.ofrep.error -sealed class OfrepError : Exception() { +import io.ktor.client.statement.HttpResponse + +internal sealed class OfrepError : Exception() { class ApiUnauthorizedError( - val response: okhttp3.Response, + val response: HttpResponse, ) : OfrepError() class ForbiddenError( - val response: okhttp3.Response, + val response: HttpResponse, ) : OfrepError() class ApiTooManyRequestsError( - val response: okhttp3.Response? = null, + val response: HttpResponse? = null, ) : OfrepError() class UnexpectedResponseError( - val response: okhttp3.Response, + val response: HttpResponse, ) : OfrepError() class UnmarshallError( diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/EvaluationContextSerializer.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/EvaluationContextSerializer.kt new file mode 100644 index 0000000..163a6df --- /dev/null +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/EvaluationContextSerializer.kt @@ -0,0 +1,25 @@ +package dev.openfeature.kotlin.contrib.providers.ofrep.serialization + +import dev.openfeature.sdk.EvaluationContext +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +internal class EvaluationContextSerializer : KSerializer { + private val delegateSerializer = MapSerializer(String.serializer(), ValueSerializer) + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("dev.openfeature.sdk.EvaluationContext") + + override fun serialize( + encoder: Encoder, + value: EvaluationContext, + ) = delegateSerializer.serialize(encoder, value.asMap()) + + override fun deserialize(decoder: Decoder): EvaluationContext { + TODO("Not yet implemented") + } +} diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/EvaluationMetadataSerializer.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/EvaluationMetadataSerializer.kt new file mode 100644 index 0000000..6a40775 --- /dev/null +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/EvaluationMetadataSerializer.kt @@ -0,0 +1,58 @@ +package dev.openfeature.kotlin.contrib.providers.ofrep.serialization + +import dev.openfeature.sdk.EvaluationMetadata +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +internal class EvaluationMetadataSerializer : KSerializer { + override val descriptor: SerialDescriptor + get() = TODO("Not yet implemented") + + override fun deserialize(decoder: Decoder): EvaluationMetadata { + val jsonDecoder = decoder as JsonDecoder + + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + + // check that inputMap is null or empty + if (jsonObject.isEmpty()) { + return EvaluationMetadata.EMPTY + } + + val metadataBuilder = EvaluationMetadata.builder() + jsonObject.forEach { entry -> + val jsonPrimitive = entry.value.jsonPrimitive + val value = + jsonPrimitive.run { + if (isString) { + content + } else { + booleanOrNull ?: intOrNull ?: doubleOrNull + ?: error("Cannot parse value") + } + } + when (value) { + is String -> metadataBuilder.putString(entry.key, value) + is Boolean -> metadataBuilder.putBoolean(entry.key, value) + is Int -> metadataBuilder.putInt(entry.key, value) + else -> error("Unsupported type for: $value") + } + } + + return metadataBuilder.build() + } + + override fun serialize( + encoder: Encoder, + value: EvaluationMetadata, + ) { + TODO("Not yet implemented") + } +} diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/ValueSerializer.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/ValueSerializer.kt new file mode 100644 index 0000000..5228653 --- /dev/null +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/ValueSerializer.kt @@ -0,0 +1,199 @@ + +package dev.openfeature.kotlin.contrib.providers.ofrep.serialization + +import dev.openfeature.sdk.Value +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.PolymorphicKind +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.buildSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.long +import kotlinx.serialization.json.longOrNull +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +internal object ValueSerializer : KSerializer { + @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) + override val descriptor: SerialDescriptor = + buildSerialDescriptor("dev.openfeature.sdk.Value", PolymorphicKind.SEALED) + + // Serializers for the concrete types + private object BooleanValueSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("dev.openfeature.sdk.Value.Boolean") + + override fun serialize( + encoder: Encoder, + value: Value.Boolean, + ) = encoder.encodeBoolean(value.boolean) + + override fun deserialize(decoder: Decoder): Value.Boolean = Value.Boolean(decoder.decodeBoolean()) + } + + private object DoubleValueSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("dev.openfeature.sdk.Value.Double") + + override fun serialize( + encoder: Encoder, + value: Value.Double, + ) = encoder.encodeDouble(value.double) + + override fun deserialize(decoder: Decoder): Value.Double = Value.Double(decoder.decodeDouble()) + } + + private object IntValueSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("dev.openfeature.sdk.Value.Integer") + + override fun serialize( + encoder: Encoder, + value: Value.Integer, + ) = encoder.encodeInt(value.integer) + + override fun deserialize(decoder: Decoder): Value.Integer = Value.Integer(decoder.decodeInt()) + } + + @OptIn(ExperimentalTime::class) + private object InstantValueSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("dev.openfeature.sdk.Value.Instant") + + override fun serialize( + encoder: Encoder, + value: Value.Instant, + ) = encoder.encodeString(value.instant.toString()) + + override fun deserialize(decoder: Decoder): Value.Instant = Value.Instant(Instant.parse(decoder.decodeString())) + } + + private object StringValueSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("dev.openfeature.sdk.Value.String") + + override fun serialize( + encoder: Encoder, + value: Value.String, + ) = encoder.encodeString(value.string) + + override fun deserialize(decoder: Decoder): Value.String = Value.String(decoder.decodeString()) + } + + // For ListValue, we need this ValueSerializer itself for its elements + private object ListValueSerializer : KSerializer { + private val actualSerializer = ListSerializer(ValueSerializer) // Recursive use + override val descriptor: SerialDescriptor = actualSerializer.descriptor + + override fun serialize( + encoder: Encoder, + value: Value.List, + ) = encoder.encodeSerializableValue(actualSerializer, value.list) + + override fun deserialize(decoder: Decoder): Value.List = Value.List(decoder.decodeSerializableValue(actualSerializer)) + } + + // For StructureValue (Map), we need this ValueSerializer for its values + private object StructureValueSerializer : KSerializer { + private val actualSerializer = MapSerializer(String.serializer(), ValueSerializer) // Recursive use + override val descriptor: SerialDescriptor = actualSerializer.descriptor + + override fun serialize( + encoder: Encoder, + value: Value.Structure, + ) = encoder.encodeSerializableValue(actualSerializer, value.structure) + + override fun deserialize(decoder: Decoder): Value.Structure = Value.Structure(decoder.decodeSerializableValue(actualSerializer)) + } + + @OptIn(ExperimentalSerializationApi::class) + private object NullValueSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("dev.openfeature.sdk.Value.Null") + + override fun serialize( + encoder: Encoder, + value: Value.Null, + ) = encoder.encodeNull() + + override fun deserialize(decoder: Decoder): Value.Null { + decoder.decodeNull() // Consume the null value + return Value.Null + } + } + + override fun serialize( + encoder: Encoder, + value: Value, + ): Unit = + when (value) { + is Value.Boolean -> encoder.encodeSerializableValue(BooleanValueSerializer, value) + is Value.Double -> encoder.encodeSerializableValue(DoubleValueSerializer, value) + is Value.Integer -> encoder.encodeSerializableValue(IntValueSerializer, value) + is Value.Instant -> encoder.encodeSerializableValue(InstantValueSerializer, value) + is Value.List -> encoder.encodeSerializableValue(ListValueSerializer, value) + is Value.String -> encoder.encodeSerializableValue(StringValueSerializer, value) + is Value.Structure -> encoder.encodeSerializableValue(StructureValueSerializer, value) + is Value.Null -> encoder.encodeSerializableValue(NullValueSerializer, value) + } + + private fun selectDeserializer(element: JsonElement): DeserializationStrategy = + when (element) { + is JsonNull -> NullValueSerializer + is JsonObject -> StructureValueSerializer // Assumes JsonObject is always a Structure + is JsonArray -> ListValueSerializer // Assumes JsonArray is always a List + is JsonPrimitive -> { + when { + // Note: we are not attempting to deserialize any Strings into Instants, because + // they might as well be a normal date-looking String + element.isString -> StringValueSerializer + element.booleanOrNull != null -> BooleanValueSerializer + // Order matters here: check for Int before Double to avoid loss of precision + // if a number is a whole number but represented as a double (e.g., 5.0) + element.longOrNull != null -> { + // If it fits in Int, use IntValueSerializer, otherwise could be an issue + // or you might need a Value.Long type. For now, assume it fits Int if it's an int. + // This part might need refinement based on how you handle large integers. + // If Value.Integer only holds Int, then a long might be an error or fallback to Double. + // Let's assume for now that if it has no decimal, it could be an Int or a long that + // should be treated as Int if it fits, or Double if it's too large for Int but fits Double. + val longVal = element.long + if (longVal >= Int.MIN_VALUE && longVal <= Int.MAX_VALUE) { + IntValueSerializer + } else { + // Fallback to Double if it's a long that doesn't fit Int + // or if your Value.Double can represent whole numbers. + DoubleValueSerializer + } + } + element.doubleOrNull != null -> DoubleValueSerializer + else -> error("Unknown JsonPrimitive type: $element") + } + } + } + + /** + * Main deserialize method. It decodes the JsonElement and then uses selectDeserializer + * to pick the correct strategy for actual object creation. + */ + override fun deserialize(decoder: Decoder): Value { + val jsonDecoder = + decoder as? JsonDecoder + ?: throw IllegalStateException("This serializer can only be used with JsonInput") + + val jsonElement = jsonDecoder.decodeJsonElement() + + val actualDeserializer = selectDeserializer(jsonElement) + + return jsonDecoder.json.decodeFromJsonElement(actualDeserializer, jsonElement) + } +} diff --git a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt index c62e1bc..3a3fc1e 100644 --- a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt @@ -19,7 +19,6 @@ import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import okhttp3.Headers import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.After @@ -91,7 +90,7 @@ class OfrepProviderTest { enqueueMockResponse( "ofrep/valid_api_response.json", 429, - Headers.headersOf("Retry-After", "3"), + mapOf("Retry-After" to "3"), ) val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) @@ -538,7 +537,9 @@ class OfrepProviderTest { variant = null, reason = "ERROR", errorCode = ErrorCode.TYPE_MISMATCH, - errorMessage = "Type mismatch: expect Boolean - Unsupported type for: {testValue={toto=1234}}", + errorMessage = + "Type mismatch: expect Boolean - Unsupported type for: " + + "Structure(structure={testValue=Structure(structure={toto=Integer(integer=1234)})})", ) assertEquals(want, got) } @@ -558,7 +559,9 @@ class OfrepProviderTest { variant = null, reason = "ERROR", errorCode = ErrorCode.TYPE_MISMATCH, - errorMessage = "Type mismatch: expect String - Unsupported type for: {testValue={toto=1234}}", + errorMessage = + "Type mismatch: expect String - Unsupported type for: " + + "Structure(structure={testValue=Structure(structure={toto=Integer(integer=1234)})})", ) assertEquals(want, got) } @@ -578,7 +581,9 @@ class OfrepProviderTest { variant = null, reason = "ERROR", errorCode = ErrorCode.TYPE_MISMATCH, - errorMessage = "Type mismatch: expect Double - Unsupported type for: {testValue={toto=1234}}", + errorMessage = + "Type mismatch: expect Double - Unsupported type for: " + + "Structure(structure={testValue=Structure(structure={toto=Integer(integer=1234)})})", ) assertEquals(want, got) } @@ -632,16 +637,15 @@ class OfrepProviderTest { private fun enqueueMockResponse( fileName: String, responseCode: Int = 200, - headers: Headers? = null, + headers: Map = emptyMap(), ) { val jsonString = getResourceAsString(fileName) - var resp = + val resp = MockResponse() .setBody(jsonString.trimIndent()) .setResponseCode(responseCode) - if (headers != null) { - resp = resp.setHeaders(headers) - } + .addHeader("Content-Type", "application/json") + headers.forEach { (key, value) -> resp.addHeader(key, value) } mockWebServer.enqueue(resp) } } diff --git a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt index e1109a2..42782be 100644 --- a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt @@ -1,10 +1,11 @@ package dev.openfeature.kotlin.contrib.providers.ofrep.controller -import FlagDto -import OfrepApiResponse +import dev.openfeature.kotlin.contrib.providers.ofrep.bean.FlagDto +import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepApiResponse import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepOptions import dev.openfeature.kotlin.contrib.providers.ofrep.error.OfrepError import dev.openfeature.kotlin.contrib.providers.ofrep.getResourceAsString +import dev.openfeature.sdk.EvaluationMetadata import dev.openfeature.sdk.ImmutableContext import dev.openfeature.sdk.Value import dev.openfeature.sdk.exceptions.ErrorCode @@ -28,7 +29,11 @@ class OfrepApiTest { runBlocking { val jsonString = getResourceAsString("ofrep/valid_api_short_response.json") - mockWebServer.enqueue(MockResponse().setBody(jsonString.trimIndent())) + mockWebServer.enqueue( + MockResponse() + .setBody(jsonString.trimIndent()) + .setHeader("Content-Type", "application/json"), + ) val ofrepApi = OfrepApi( @@ -43,7 +48,7 @@ class OfrepApiTest { ), ) val res = ofrepApi.postBulkEvaluateFlags(ctx) - assertEquals(200, res.httpResponse.code) + assertEquals(200, res.httpResponse.status.value) val expected = OfrepApiResponse( @@ -51,34 +56,35 @@ class OfrepApiTest { listOf( FlagDto( key = "badge-class2", - value = "green", + value = Value.String("green"), reason = "DEFAULT", variant = "nocolor", errorCode = null, errorDetails = null, - metadata = null, + metadata = EvaluationMetadata.EMPTY, ), FlagDto( key = "hide-logo", - value = false, + value = Value.Boolean(false), reason = "STATIC", variant = "var_false", errorCode = null, errorDetails = null, - metadata = null, + metadata = EvaluationMetadata.EMPTY, ), FlagDto( key = "title-flag", - value = "GO Feature Flag", + value = Value.String("GO Feature Flag"), reason = "DEFAULT", variant = "default_title", errorCode = null, errorDetails = null, metadata = - hashMapOf( - "description" to "This flag controls the title of the feature flag", - "title" to "Feature Flag Title", - ), + EvaluationMetadata + .builder() + .putString("description", "This flag controls the title of the feature flag") + .putString("title", "Feature Flag Title") + .build(), ), ), null, @@ -90,7 +96,12 @@ class OfrepApiTest { @Test fun shouldThrowAnUnauthorizedError(): Unit = runBlocking { - mockWebServer.enqueue(MockResponse().setBody("{}").setResponseCode(401)) + mockWebServer.enqueue( + MockResponse() + .setBody("{}") + .setResponseCode(401) + .setHeader("Content-Type", "application/json"), + ) val ofrepApi = OfrepApi( OfrepOptions(endpoint = mockWebServer.url("/").toString()), @@ -106,7 +117,12 @@ class OfrepApiTest { @Test fun shouldThrowAForbiddenError(): Unit = runBlocking { - mockWebServer.enqueue(MockResponse().setBody("{}").setResponseCode(403)) + mockWebServer.enqueue( + MockResponse() + .setBody("{}") + .setResponseCode(403) + .setHeader("Content-Type", "application/json"), + ) val ofrepApi = OfrepApi( OfrepOptions(endpoint = mockWebServer.url("/").toString()), @@ -126,7 +142,8 @@ class OfrepApiTest { MockResponse() .setBody("{}") .setResponseCode(429) - .setHeader("Retry-After", "120"), + .setHeader("Retry-After", "120") + .setHeader("Content-Type", "application/json"), ) val ofrepApi = OfrepApi( @@ -137,7 +154,7 @@ class OfrepApiTest { ofrepApi.postBulkEvaluateFlags(ctx) assertTrue("we exited the try block without throwing an exception", false) } catch (e: OfrepError.ApiTooManyRequestsError) { - assertEquals(429, e.response?.code) + assertEquals(429, e.response?.status?.value) assertEquals(e.response?.headers?.get("Retry-After"), "120") } } @@ -148,7 +165,8 @@ class OfrepApiTest { mockWebServer.enqueue( MockResponse() .setBody("{}") - .setResponseCode(500), + .setResponseCode(500) + .setHeader("Content-Type", "application/json"), ) val ofrepApi = OfrepApi( @@ -166,14 +184,17 @@ class OfrepApiTest { @Test fun shouldReturnAnEvaluationResponseInError(): Unit = runBlocking { - mockWebServer.enqueue( - MockResponse() - .setBody( - """ - {"errorCode": "INVALID_CONTEXT", "errorDetails":"explanation of the error"} - """.trimIndent(), - ).setResponseCode(400), - ) + mockWebServer + .enqueue( + MockResponse() + .setBody( + """ + {"errorCode": "INVALID_CONTEXT", "errorDetails":"explanation of the error"} + """.trimIndent(), + ).setResponseCode(400) + .setHeader("Content-Type", "application/json"), + ) + val ofrepApi = OfrepApi( OfrepOptions(endpoint = mockWebServer.url("/").toString()), @@ -184,7 +205,7 @@ class OfrepApiTest { assertTrue(resp.isError()) assertEquals(ErrorCode.INVALID_CONTEXT, resp.apiResponse?.errorCode) assertEquals("explanation of the error", resp.apiResponse?.errorDetails) - assertEquals(400, resp.httpResponse.code) + assertEquals(400, resp.httpResponse.status.value) } @Test @@ -192,8 +213,8 @@ class OfrepApiTest { runBlocking { mockWebServer.enqueue( MockResponse() - .setBody("{}") - .setResponseCode(304), + .setResponseCode(304) + .setHeader("Content-Type", "application/json"), ) val ofrepApi = OfrepApi( @@ -203,7 +224,7 @@ class OfrepApiTest { val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") val resp = ofrepApi.postBulkEvaluateFlags(ctx) assertFalse(resp.isError()) - assertEquals(304, resp.httpResponse.code) + assertEquals(304, resp.httpResponse.status.value) } @Test @@ -212,7 +233,8 @@ class OfrepApiTest { mockWebServer.enqueue( MockResponse() .setBody("{}") - .setResponseCode(304), + .setResponseCode(304) + .setHeader("Content-Type", "application/json"), ) val ofrepApi = OfrepApi( @@ -233,7 +255,10 @@ class OfrepApiTest { val jsonString = getResourceAsString("ofrep/invalid_api_response.json") mockWebServer.enqueue( - MockResponse().setBody(jsonString.trimIndent()).setResponseCode(400), + MockResponse() + .setBody(jsonString.trimIndent()) + .setResponseCode(400) + .setHeader("Content-Type", "application/json"), ) val ofrepApi = OfrepApi( @@ -254,7 +279,10 @@ class OfrepApiTest { val jsonString = getResourceAsString("ofrep/invalid_api_response.json") mockWebServer.enqueue( - MockResponse().setBody(jsonString.trimIndent()).setResponseCode(400), + MockResponse() + .setBody(jsonString.trimIndent()) + .setResponseCode(400) + .setHeader("Content-Type", "application/json"), ) assertThrows(OfrepError.InvalidOptionsError::class.java) { runBlocking { @@ -272,7 +300,8 @@ class OfrepApiTest { MockResponse() .setBody(jsonString.trimIndent()) .setResponseCode(200) - .addHeader("ETag", "123"), + .addHeader("ETag", "123") + .setHeader("Content-Type", "application/json"), ) mockWebServer.enqueue( MockResponse() @@ -288,8 +317,8 @@ class OfrepApiTest { val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") val eval1 = ofrepApi.postBulkEvaluateFlags(ctx) val eval2 = ofrepApi.postBulkEvaluateFlags(ctx) - assertEquals(eval1.httpResponse.code, 200) - assertEquals(eval2.httpResponse.code, 304) + assertEquals(eval1.httpResponse.status.value, 200) + assertEquals(eval2.httpResponse.status.value, 304) assertEquals(2, mockWebServer.requestCount) } @@ -302,12 +331,14 @@ class OfrepApiTest { MockResponse() .setBody(jsonString.trimIndent()) .setResponseCode(200) - .addHeader("ETag", "123"), + .addHeader("ETag", "123") + .setHeader("Content-Type", "application/json"), ) mockWebServer.enqueue( MockResponse() .setResponseCode(304) - .addHeader("ETag", "123"), + .addHeader("ETag", "123") + .setHeader("Content-Type", "application/json"), ) val ofrepApi = @@ -318,8 +349,8 @@ class OfrepApiTest { val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") val eval1 = ofrepApi.postBulkEvaluateFlags(ctx) val eval2 = ofrepApi.postBulkEvaluateFlags(ctx) - assertEquals(eval1.httpResponse.code, 200) - assertEquals(eval2.httpResponse.code, 304) + assertEquals(eval1.httpResponse.status.value, 200) + assertEquals(eval2.httpResponse.status.value, 304) assertEquals(2, mockWebServer.requestCount) } } From 7457e1a352ae34149e46a03302570994d499cb8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Sat, 14 Jun 2025 09:07:50 +0200 Subject: [PATCH 12/21] Upgrade the OpenFeature Kotlin SDK to 0.5.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- gradle/libs.versions.toml | 2 +- .../providers/envvar/EnvVarProvider.kt | 16 +++++++-------- .../providers/envvar/EnvVarProviderE2eTest.kt | 6 +++--- .../providers/envvar/EnvVarProviderTest.kt | 12 +++++------ .../contrib/providers/ofrep/OfrepProvider.kt | 20 +++++++++---------- .../providers/ofrep/bean/OfrepApiRequest.kt | 4 ++-- .../providers/ofrep/bean/OfrepApiResponse.kt | 10 +++++----- .../ofrep/bean/OfrepProviderMetadata.kt | 2 +- .../providers/ofrep/controller/OfrepApi.kt | 4 ++-- .../EvaluationContextSerializer.kt | 4 ++-- .../EvaluationMetadataSerializer.kt | 2 +- .../ofrep/serialization/ValueSerializer.kt | 16 +++++++-------- .../providers/ofrep/OfrepProviderTest.kt | 18 ++++++++--------- .../ofrep/controller/OfrepApiTest.kt | 10 +++++----- 14 files changed, 63 insertions(+), 63 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d606aac..118b71e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ kotlin = "2.1.21" kotlinx-coroutines = "1.10.2" okhttp = "4.12.0" -open-feature-kotlin-sdk = "0.4.1" +open-feature-kotlin-sdk = "0.5.0" android = "8.10.1" ktor = "3.1.3" diff --git a/providers/env-var/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/envvar/EnvVarProvider.kt b/providers/env-var/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/envvar/EnvVarProvider.kt index 371a392..e7a284a 100644 --- a/providers/env-var/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/envvar/EnvVarProvider.kt +++ b/providers/env-var/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/envvar/EnvVarProvider.kt @@ -1,13 +1,13 @@ package dev.openfeature.kotlin.contrib.providers.envvar -import dev.openfeature.sdk.EvaluationContext -import dev.openfeature.sdk.FeatureProvider -import dev.openfeature.sdk.Hook -import dev.openfeature.sdk.ProviderEvaluation -import dev.openfeature.sdk.ProviderMetadata -import dev.openfeature.sdk.Reason -import dev.openfeature.sdk.Value -import dev.openfeature.sdk.exceptions.OpenFeatureError +import dev.openfeature.kotlin.sdk.EvaluationContext +import dev.openfeature.kotlin.sdk.FeatureProvider +import dev.openfeature.kotlin.sdk.Hook +import dev.openfeature.kotlin.sdk.ProviderEvaluation +import dev.openfeature.kotlin.sdk.ProviderMetadata +import dev.openfeature.kotlin.sdk.Reason +import dev.openfeature.kotlin.sdk.Value +import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError /** EnvVarProvider is the Kotlin provider implementation for the environment variables. */ class EnvVarProvider( diff --git a/providers/env-var/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/envvar/EnvVarProviderE2eTest.kt b/providers/env-var/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/envvar/EnvVarProviderE2eTest.kt index 577a22f..8e488b1 100644 --- a/providers/env-var/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/envvar/EnvVarProviderE2eTest.kt +++ b/providers/env-var/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/envvar/EnvVarProviderE2eTest.kt @@ -1,8 +1,8 @@ package dev.openfeature.kotlin.contrib.providers.envvar -import dev.openfeature.sdk.Client -import dev.openfeature.sdk.OpenFeatureAPI -import dev.openfeature.sdk.Reason +import dev.openfeature.kotlin.sdk.Client +import dev.openfeature.kotlin.sdk.OpenFeatureAPI +import dev.openfeature.kotlin.sdk.Reason import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals diff --git a/providers/env-var/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/envvar/EnvVarProviderTest.kt b/providers/env-var/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/envvar/EnvVarProviderTest.kt index 4f9dddb..cb19bf5 100644 --- a/providers/env-var/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/envvar/EnvVarProviderTest.kt +++ b/providers/env-var/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/envvar/EnvVarProviderTest.kt @@ -1,11 +1,11 @@ package dev.openfeature.kotlin.contrib.providers.envvar -import dev.openfeature.sdk.FeatureProvider -import dev.openfeature.sdk.ImmutableContext -import dev.openfeature.sdk.ProviderEvaluation -import dev.openfeature.sdk.Reason -import dev.openfeature.sdk.Value -import dev.openfeature.sdk.exceptions.OpenFeatureError +import dev.openfeature.kotlin.sdk.FeatureProvider +import dev.openfeature.kotlin.sdk.ImmutableContext +import dev.openfeature.kotlin.sdk.ProviderEvaluation +import dev.openfeature.kotlin.sdk.Reason +import dev.openfeature.kotlin.sdk.Value +import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProvider.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProvider.kt index ef27482..3a71371 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProvider.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProvider.kt @@ -7,16 +7,16 @@ import dev.openfeature.kotlin.contrib.providers.ofrep.bean.toProviderEvaluation import dev.openfeature.kotlin.contrib.providers.ofrep.controller.OfrepApi import dev.openfeature.kotlin.contrib.providers.ofrep.enum.BulkEvaluationStatus import dev.openfeature.kotlin.contrib.providers.ofrep.error.OfrepError -import dev.openfeature.sdk.EvaluationContext -import dev.openfeature.sdk.FeatureProvider -import dev.openfeature.sdk.Hook -import dev.openfeature.sdk.ImmutableContext -import dev.openfeature.sdk.ProviderEvaluation -import dev.openfeature.sdk.ProviderMetadata -import dev.openfeature.sdk.Value -import dev.openfeature.sdk.events.OpenFeatureProviderEvents -import dev.openfeature.sdk.exceptions.ErrorCode -import dev.openfeature.sdk.exceptions.OpenFeatureError +import dev.openfeature.kotlin.sdk.EvaluationContext +import dev.openfeature.kotlin.sdk.FeatureProvider +import dev.openfeature.kotlin.sdk.Hook +import dev.openfeature.kotlin.sdk.ImmutableContext +import dev.openfeature.kotlin.sdk.ProviderEvaluation +import dev.openfeature.kotlin.sdk.ProviderMetadata +import dev.openfeature.kotlin.sdk.Value +import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents +import dev.openfeature.kotlin.sdk.exceptions.ErrorCode +import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.runBlocking diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepApiRequest.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepApiRequest.kt index 2013fbb..3c10e5d 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepApiRequest.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepApiRequest.kt @@ -1,8 +1,8 @@ package dev.openfeature.kotlin.contrib.providers.ofrep.bean import dev.openfeature.kotlin.contrib.providers.ofrep.serialization.EvaluationContextSerializer -import dev.openfeature.sdk.EvaluationContext -import dev.openfeature.sdk.ImmutableContext +import dev.openfeature.kotlin.sdk.EvaluationContext +import dev.openfeature.kotlin.sdk.ImmutableContext import kotlinx.serialization.Serializable @Serializable diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepApiResponse.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepApiResponse.kt index cb95150..96dc390 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepApiResponse.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepApiResponse.kt @@ -4,11 +4,11 @@ package dev.openfeature.kotlin.contrib.providers.ofrep.bean import dev.openfeature.kotlin.contrib.providers.ofrep.serialization.EvaluationMetadataSerializer import dev.openfeature.kotlin.contrib.providers.ofrep.serialization.ValueSerializer -import dev.openfeature.sdk.EvaluationMetadata -import dev.openfeature.sdk.ProviderEvaluation -import dev.openfeature.sdk.Value -import dev.openfeature.sdk.exceptions.ErrorCode -import dev.openfeature.sdk.exceptions.OpenFeatureError +import dev.openfeature.kotlin.sdk.EvaluationMetadata +import dev.openfeature.kotlin.sdk.ProviderEvaluation +import dev.openfeature.kotlin.sdk.Value +import dev.openfeature.kotlin.sdk.exceptions.ErrorCode +import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError import kotlinx.serialization.Serializable import kotlin.time.ExperimentalTime import kotlin.time.Instant diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepProviderMetadata.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepProviderMetadata.kt index 09cc881..cfac24a 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepProviderMetadata.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepProviderMetadata.kt @@ -1,6 +1,6 @@ package dev.openfeature.kotlin.contrib.providers.ofrep.bean -import dev.openfeature.sdk.ProviderMetadata +import dev.openfeature.kotlin.sdk.ProviderMetadata internal class OfrepProviderMetadata : ProviderMetadata { override val name: String diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApi.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApi.kt index 818bbb4..b808af3 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApi.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApi.kt @@ -5,8 +5,8 @@ import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepApiResponse import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepOptions import dev.openfeature.kotlin.contrib.providers.ofrep.bean.PostBulkEvaluationResult import dev.openfeature.kotlin.contrib.providers.ofrep.error.OfrepError -import dev.openfeature.sdk.EvaluationContext -import dev.openfeature.sdk.exceptions.OpenFeatureError +import dev.openfeature.kotlin.sdk.EvaluationContext +import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.cio.CIO diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/EvaluationContextSerializer.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/EvaluationContextSerializer.kt index 163a6df..c362aa3 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/EvaluationContextSerializer.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/EvaluationContextSerializer.kt @@ -1,6 +1,6 @@ package dev.openfeature.kotlin.contrib.providers.ofrep.serialization -import dev.openfeature.sdk.EvaluationContext +import dev.openfeature.kotlin.sdk.EvaluationContext import kotlinx.serialization.KSerializer import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.serializer @@ -12,7 +12,7 @@ import kotlinx.serialization.encoding.Encoder internal class EvaluationContextSerializer : KSerializer { private val delegateSerializer = MapSerializer(String.serializer(), ValueSerializer) override val descriptor: SerialDescriptor = - buildClassSerialDescriptor("dev.openfeature.sdk.EvaluationContext") + buildClassSerialDescriptor("dev.openfeature.kotlin.sdk.EvaluationContext") override fun serialize( encoder: Encoder, diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/EvaluationMetadataSerializer.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/EvaluationMetadataSerializer.kt index 6a40775..3f883b0 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/EvaluationMetadataSerializer.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/EvaluationMetadataSerializer.kt @@ -1,6 +1,6 @@ package dev.openfeature.kotlin.contrib.providers.ofrep.serialization -import dev.openfeature.sdk.EvaluationMetadata +import dev.openfeature.kotlin.sdk.EvaluationMetadata import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/ValueSerializer.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/ValueSerializer.kt index 5228653..bbf076e 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/ValueSerializer.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/serialization/ValueSerializer.kt @@ -1,7 +1,7 @@ package dev.openfeature.kotlin.contrib.providers.ofrep.serialization -import dev.openfeature.sdk.Value +import dev.openfeature.kotlin.sdk.Value import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.InternalSerializationApi @@ -31,11 +31,11 @@ import kotlin.time.Instant internal object ValueSerializer : KSerializer { @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) override val descriptor: SerialDescriptor = - buildSerialDescriptor("dev.openfeature.sdk.Value", PolymorphicKind.SEALED) + buildSerialDescriptor("dev.openfeature.kotlin.sdk.Value", PolymorphicKind.SEALED) // Serializers for the concrete types private object BooleanValueSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("dev.openfeature.sdk.Value.Boolean") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("dev.openfeature.kotlin.sdk.Value.Boolean") override fun serialize( encoder: Encoder, @@ -46,7 +46,7 @@ internal object ValueSerializer : KSerializer { } private object DoubleValueSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("dev.openfeature.sdk.Value.Double") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("dev.openfeature.kotlin.sdk.Value.Double") override fun serialize( encoder: Encoder, @@ -57,7 +57,7 @@ internal object ValueSerializer : KSerializer { } private object IntValueSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("dev.openfeature.sdk.Value.Integer") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("dev.openfeature.kotlin.sdk.Value.Integer") override fun serialize( encoder: Encoder, @@ -69,7 +69,7 @@ internal object ValueSerializer : KSerializer { @OptIn(ExperimentalTime::class) private object InstantValueSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("dev.openfeature.sdk.Value.Instant") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("dev.openfeature.kotlin.sdk.Value.Instant") override fun serialize( encoder: Encoder, @@ -80,7 +80,7 @@ internal object ValueSerializer : KSerializer { } private object StringValueSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("dev.openfeature.sdk.Value.String") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("dev.openfeature.kotlin.sdk.Value.String") override fun serialize( encoder: Encoder, @@ -118,7 +118,7 @@ internal object ValueSerializer : KSerializer { @OptIn(ExperimentalSerializationApi::class) private object NullValueSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("dev.openfeature.sdk.Value.Null") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("dev.openfeature.kotlin.sdk.Value.Null") override fun serialize( encoder: Encoder, diff --git a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt index 3a3fc1e..476089c 100644 --- a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt @@ -3,15 +3,15 @@ package dev.openfeature.kotlin.contrib.providers.ofrep import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepOptions -import dev.openfeature.sdk.EvaluationContext -import dev.openfeature.sdk.EvaluationMetadata -import dev.openfeature.sdk.FlagEvaluationDetails -import dev.openfeature.sdk.ImmutableContext -import dev.openfeature.sdk.OpenFeatureAPI -import dev.openfeature.sdk.Value -import dev.openfeature.sdk.events.OpenFeatureProviderEvents -import dev.openfeature.sdk.exceptions.ErrorCode -import dev.openfeature.sdk.exceptions.OpenFeatureError +import dev.openfeature.kotlin.sdk.EvaluationContext +import dev.openfeature.kotlin.sdk.EvaluationMetadata +import dev.openfeature.kotlin.sdk.FlagEvaluationDetails +import dev.openfeature.kotlin.sdk.ImmutableContext +import dev.openfeature.kotlin.sdk.OpenFeatureAPI +import dev.openfeature.kotlin.sdk.Value +import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents +import dev.openfeature.kotlin.sdk.exceptions.ErrorCode +import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.filterIsInstance diff --git a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt index 42782be..aa4b9ea 100644 --- a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt @@ -5,11 +5,11 @@ import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepApiResponse import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepOptions import dev.openfeature.kotlin.contrib.providers.ofrep.error.OfrepError import dev.openfeature.kotlin.contrib.providers.ofrep.getResourceAsString -import dev.openfeature.sdk.EvaluationMetadata -import dev.openfeature.sdk.ImmutableContext -import dev.openfeature.sdk.Value -import dev.openfeature.sdk.exceptions.ErrorCode -import dev.openfeature.sdk.exceptions.OpenFeatureError +import dev.openfeature.kotlin.sdk.EvaluationMetadata +import dev.openfeature.kotlin.sdk.ImmutableContext +import dev.openfeature.kotlin.sdk.Value +import dev.openfeature.kotlin.sdk.exceptions.ErrorCode +import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError import junit.framework.TestCase.assertFalse import kotlinx.coroutines.runBlocking import okhttp3.mockwebserver.MockResponse From 65b434d2f794661e697376a7de93f8e14e4ab121 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Sat, 14 Jun 2025 18:45:44 +0200 Subject: [PATCH 13/21] Add JVM target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- providers/ofrep/build.gradle.kts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/providers/ofrep/build.gradle.kts b/providers/ofrep/build.gradle.kts index e4a9f39..af3b125 100644 --- a/providers/ofrep/build.gradle.kts +++ b/providers/ofrep/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlinx.serialization) @@ -6,10 +8,15 @@ plugins { kotlin { androidTarget() -} - -kotlin { - androidTarget() + jvm { + compilations.all { + compileTaskProvider.configure { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } + } + } + } sourceSets { commonMain.dependencies { From cdd7b43955f96a3dfb49b8dee44fd49020b17508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Sat, 14 Jun 2025 18:46:53 +0200 Subject: [PATCH 14/21] Replace okhttp mockwebserver with ktor client mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- gradle/libs.versions.toml | 4 +- providers/ofrep/build.gradle.kts | 5 +- .../contrib/providers/ofrep/bean/Http.kt | 26 ++ .../providers/ofrep/bean/OfrepOptions.kt | 7 + .../providers/ofrep/controller/OfrepApi.kt | 19 +- .../providers/ofrep/OfrepProviderTest.kt | 176 +++++++------- .../contrib/providers/ofrep/TestUtils.kt | 62 +++++ .../ofrep/controller/OfrepApiTest.kt | 223 +++++++----------- 8 files changed, 269 insertions(+), 253 deletions(-) create mode 100644 providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/Http.kt create mode 100644 providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/TestUtils.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 118b71e..534e2f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,6 @@ [versions] kotlin = "2.1.21" kotlinx-coroutines = "1.10.2" -okhttp = "4.12.0" open-feature-kotlin-sdk = "0.5.0" android = "8.10.1" ktor = "3.1.3" @@ -11,11 +10,12 @@ ktor-core = { module="io.ktor:ktor-client-core", version.ref="ktor" } ktor-cio = { module="io.ktor:ktor-client-cio", version.ref="ktor" } ktor-client-content-negotiation = { module="io.ktor:ktor-client-content-negotiation", version.ref="ktor" } ktor-serialization-kotlinx-json = { module="io.ktor:ktor-serialization-kotlinx-json", version.ref="ktor" } -okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } +ktor-client-mock = { module="io.ktor:ktor-client-mock", version.ref="ktor" } openfeature-kotlin-sdk = { group="dev.openfeature", name="kotlin-sdk", version.ref="open-feature-kotlin-sdk" } kotlin-test = { group="org.jetbrains.kotlin", name="kotlin-test", version.ref="kotlin" } kotlinx-coroutines-core = { group="org.jetbrains.kotlinx", name="kotlinx-coroutines-core", version.ref="kotlinx-coroutines" } kotlinx-coroutines-test = { group="org.jetbrains.kotlinx", name="kotlinx-coroutines-test", version.ref="kotlinx-coroutines" } +okio = { module="com.squareup.okio:okio", version="3.13.0" } [plugins] kotlin-multiplatform = { id="org.jetbrains.kotlin.multiplatform", version.ref="kotlin" } diff --git a/providers/ofrep/build.gradle.kts b/providers/ofrep/build.gradle.kts index af3b125..1d154f9 100644 --- a/providers/ofrep/build.gradle.kts +++ b/providers/ofrep/build.gradle.kts @@ -23,15 +23,16 @@ kotlin { api(libs.openfeature.kotlin.sdk) api(libs.kotlinx.coroutines.core) - implementation(libs.ktor.core) + api(libs.ktor.core) implementation(libs.ktor.cio) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.okio) } commonTest.dependencies { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) - implementation(libs.okhttp.mockwebserver) + implementation(libs.ktor.client.mock) } } } diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/Http.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/Http.kt new file mode 100644 index 0000000..b5056cb --- /dev/null +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/Http.kt @@ -0,0 +1,26 @@ +package dev.openfeature.kotlin.contrib.providers.ofrep.bean + +import io.ktor.client.HttpClient +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.engine.cio.CIO +import io.ktor.client.engine.cio.endpoint +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json + +private fun defaultHttpEngine(options: OfrepOptions): HttpClientEngine = + CIO.create { + maxConnectionsCount = options.maxIdleConnections + endpoint { + keepAliveTime = options.keepAliveDuration.inWholeMilliseconds + connectTimeout = options.timeout.inWholeMilliseconds + } + } + +internal fun createHttpClient(options: OfrepOptions): HttpClient { + val httpEngine = options.httpClientEngine ?: defaultHttpEngine(options) + return HttpClient(httpEngine) { + install(ContentNegotiation) { + json() + } + } +} diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepOptions.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepOptions.kt index ca02baa..89b03a5 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepOptions.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepOptions.kt @@ -1,5 +1,7 @@ package dev.openfeature.kotlin.contrib.providers.ofrep.bean +import io.ktor.client.HttpClient +import io.ktor.client.engine.HttpClientEngine import kotlin.time.Duration import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes @@ -40,4 +42,9 @@ data class OfrepOptions( * Default: `5.minutes` */ val pollingInterval: Duration = 5.minutes, + /** + * Overrides the [HttpClientEngine] that is used to create the [HttpClient] for making HTTP + * requests. + */ + val httpClientEngine: HttpClientEngine? = null, ) diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApi.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApi.kt index b808af3..c9b5c92 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApi.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApi.kt @@ -4,14 +4,12 @@ import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepApiRequest import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepApiResponse import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepOptions import dev.openfeature.kotlin.contrib.providers.ofrep.bean.PostBulkEvaluationResult +import dev.openfeature.kotlin.contrib.providers.ofrep.bean.createHttpClient import dev.openfeature.kotlin.contrib.providers.ofrep.error.OfrepError import dev.openfeature.kotlin.sdk.EvaluationContext import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError import io.ktor.client.HttpClient import io.ktor.client.call.body -import io.ktor.client.engine.cio.CIO -import io.ktor.client.engine.cio.endpoint -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.headers import io.ktor.client.request.post import io.ktor.client.request.setBody @@ -22,21 +20,6 @@ import io.ktor.http.appendPathSegments import io.ktor.http.contentType import io.ktor.http.parseUrl import io.ktor.serialization.JsonConvertException -import io.ktor.serialization.kotlinx.json.json - -private fun createHttpClient(options: OfrepOptions): HttpClient = - HttpClient(CIO) { - install(ContentNegotiation) { - json() - } - engine { - maxConnectionsCount = options.maxIdleConnections - endpoint { - keepAliveTime = options.keepAliveDuration.inWholeMilliseconds - connectTimeout = options.timeout.inWholeMilliseconds - } - } - } internal class OfrepApi( private val options: OfrepOptions, diff --git a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt index 476089c..812812a 100644 --- a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt @@ -12,6 +12,10 @@ import dev.openfeature.kotlin.sdk.Value import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents import dev.openfeature.kotlin.sdk.exceptions.ErrorCode import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError +import io.ktor.client.engine.mock.MockEngine +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.filterIsInstance @@ -19,18 +23,18 @@ import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Rule import org.junit.Test import java.util.UUID import kotlin.time.Duration.Companion.milliseconds +private fun createOfrepProvider(mockEngine: MockEngine) = + OfrepProvider( + OfrepOptions(endpoint = FAKE_ENDPOINT, httpClientEngine = mockEngine), + ) + class OfrepProviderTest { - @get:Rule - val mockWebServer = MockWebServer() private val defaultEvalCtx: EvaluationContext = ImmutableContext(targetingKey = UUID.randomUUID().toString()) @@ -49,9 +53,10 @@ class OfrepProviderTest { @Test fun `should be in Fatal status if 401 error during initialise`(): Unit = runTest { - enqueueMockResponse("ofrep/valid_api_response.json", 401) + val mockEngine = + mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json"), status = HttpStatusCode.fromValue(401)) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) + val provider = createOfrepProvider(mockEngine) var providerErrorReceived = false launch { @@ -68,9 +73,10 @@ class OfrepProviderTest { @Test fun `should be in Fatal status if 403 error during initialise`(): Unit = runTest { - enqueueMockResponse("ofrep/valid_api_response.json", 403) + val mockEngine = + mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json"), status = HttpStatusCode.fromValue(403)) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) + val provider = createOfrepProvider(mockEngine) var providerErrorReceived = false launch { @@ -87,13 +93,14 @@ class OfrepProviderTest { @Test fun `should be in Error status if 429 error during initialise`(): Unit = runTest { - enqueueMockResponse( - "ofrep/valid_api_response.json", - 429, - mapOf("Retry-After" to "3"), - ) + val mockEngine = + mockEngineWithOneResponse( + getResourceAsString("ofrep/valid_api_response.json"), + status = HttpStatusCode.fromValue(429), + additionalHeaders = headersOf(HttpHeaders.RetryAfter, "3"), + ) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) + val provider = createOfrepProvider(mockEngine) var providerErrorReceived = false var exceptionReceived: Throwable? = null @@ -114,9 +121,9 @@ class OfrepProviderTest { @Test fun `should be in Error status if error targeting key is empty`(): Unit = runTest { - enqueueMockResponse("ofrep/valid_api_response.json", 200) + val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) + val provider = createOfrepProvider(mockEngine) var providerErrorReceived = false var exceptionReceived: Throwable? = null @@ -139,9 +146,9 @@ class OfrepProviderTest { @Test fun `should be in Error status if error targeting key is missing`(): Unit = runTest { - enqueueMockResponse("ofrep/valid_api_response.json", 200) + val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) + val provider = createOfrepProvider(mockEngine) var providerErrorReceived = false var exceptionReceived: Throwable? = null @@ -164,8 +171,9 @@ class OfrepProviderTest { @Test fun `should be in error status if error invalid context`(): Unit = runTest { - enqueueMockResponse("ofrep/invalid_context.json", 400) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) + val mockEngine = + mockEngineWithOneResponse(getResourceAsString("ofrep/invalid_context.json"), status = HttpStatusCode.fromValue(400)) + val provider = createOfrepProvider(mockEngine) var providerErrorReceived = false var exceptionReceived: Throwable? = null @@ -185,9 +193,10 @@ class OfrepProviderTest { @Test fun `should be in error status if error parse error`(): Unit = runTest { - enqueueMockResponse("ofrep/parse_error.json", 400) + val mockEngine = + mockEngineWithOneResponse(getResourceAsString("ofrep/parse_error.json"), status = HttpStatusCode.fromValue(400)) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) + val provider = createOfrepProvider(mockEngine) var providerErrorReceived = false var exceptionReceived: Throwable? = null @@ -207,8 +216,8 @@ class OfrepProviderTest { @Test fun `should return a flag not found error if the flag does not exist`(): Unit = runTest { - enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) + val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val provider = createOfrepProvider(mockEngine) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getBooleanDetails("non-existent-flag", false) @@ -227,11 +236,11 @@ class OfrepProviderTest { @Test fun `should return evaluation details if the flag exists`(): Unit = runTest { - enqueueMockResponse( - "ofrep/valid_api_short_response.json", - 200, - ) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) + val mockEngine = + mockEngineWithOneResponse( + getResourceAsString("ofrep/valid_api_short_response.json"), + ) + val provider = createOfrepProvider(mockEngine) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getStringDetails("title-flag", "default") @@ -256,11 +265,11 @@ class OfrepProviderTest { @Test fun `should return parse error if the API returns the error`(): Unit = runTest { - enqueueMockResponse( - "ofrep/valid_1_flag_in_parse_error.json", - 200, - ) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) + val mockEngine = + mockEngineWithOneResponse( + getResourceAsString("ofrep/valid_1_flag_in_parse_error.json"), + ) + val provider = createOfrepProvider(mockEngine) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getStringDetails("my-other-flag", "default") @@ -279,15 +288,12 @@ class OfrepProviderTest { @Test fun `should send a context changed event if context has changed`(): Unit = runTest { - enqueueMockResponse( - "ofrep/valid_api_response.json", - 200, - ) - enqueueMockResponse( - "ofrep/valid_api_response_2.json", - 200, - ) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) + val mockEngine = + mockEngineWithTwoResponses( + firstContent = getResourceAsString("ofrep/valid_api_response.json"), + secondContent = getResourceAsString("ofrep/valid_api_response_2.json"), + ) + val provider = createOfrepProvider(mockEngine) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) // TODO: should change when we have a way to observe context changes event @@ -316,16 +322,17 @@ class OfrepProviderTest { @Test fun `should not try to call the API before Retry-After header`(): Unit = runTest { - mockWebServer.enqueue( - MockResponse() - .setResponseCode(429) - .setHeader("Retry-After", "3"), - ) + val mockEngine = + mockEngineWithOneResponse( + status = HttpStatusCode.fromValue(429), + additionalHeaders = headersOf("Retry-After", "3"), + ) val provider = OfrepProvider( OfrepOptions( pollingInterval = 100.milliseconds, - endpoint = mockWebServer.url("/").toString(), + endpoint = FAKE_ENDPOINT, + httpClientEngine = mockEngine, ), ) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) @@ -333,14 +340,14 @@ class OfrepProviderTest { client.getStringDetails("my-other-flag", "default") client.getStringDetails("my-other-flag", "default") Thread.sleep(2000) // we wait 2 seconds to let the polling loop run - assertEquals(1, mockWebServer.requestCount) + assertEquals(1, mockEngine.requestHistory.size) } @Test fun `should return a valid evaluation for Boolean`(): Unit = runTest { - enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) + val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val provider = createOfrepProvider(mockEngine) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getBooleanDetails("bool-flag", false) @@ -366,8 +373,8 @@ class OfrepProviderTest { @Test fun `should return a valid evaluation for Int`(): Unit = runTest { - enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) + val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val provider = createOfrepProvider(mockEngine) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getIntegerDetails("int-flag", 1) @@ -393,8 +400,8 @@ class OfrepProviderTest { @Test fun `should return a valid evaluation for Double`(): Unit = runTest { - enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) + val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val provider = createOfrepProvider(mockEngine) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getDoubleDetails("double-flag", 1.1) @@ -420,8 +427,8 @@ class OfrepProviderTest { @Test fun `should return a valid evaluation for String`(): Unit = runTest { - enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) + val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val provider = createOfrepProvider(mockEngine) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getStringDetails("string-flag", "default") @@ -447,8 +454,8 @@ class OfrepProviderTest { @Test fun `should return a valid evaluation for List`(): Unit = runTest { - enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) + val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val provider = createOfrepProvider(mockEngine) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = @@ -479,8 +486,8 @@ class OfrepProviderTest { @Test fun `should return a valid evaluation for Map`(): Unit = runTest { - enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) + val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val provider = createOfrepProvider(mockEngine) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = @@ -525,8 +532,8 @@ class OfrepProviderTest { @Test fun `should return TypeMismatch Bool`(): Unit = runTest { - enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) + val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val provider = createOfrepProvider(mockEngine) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getBooleanDetails("object-flag", false) @@ -547,8 +554,8 @@ class OfrepProviderTest { @Test fun `should return TypeMismatch String`(): Unit = runTest { - enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) + val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val provider = createOfrepProvider(mockEngine) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getStringDetails("object-flag", "default") @@ -569,8 +576,8 @@ class OfrepProviderTest { @Test fun `should return TypeMismatch Double`(): Unit = runTest { - enqueueMockResponse("ofrep/valid_api_response.json", 200) - val provider = OfrepProvider(OfrepOptions(endpoint = mockWebServer.url("/").toString())) + val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val provider = createOfrepProvider(mockEngine) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) val client = OpenFeatureAPI.getClient() val got = client.getDoubleDetails("object-flag", 1.233) @@ -591,20 +598,18 @@ class OfrepProviderTest { @Test fun `should have different result if waiting for next polling interval`(): Unit = runTest { - enqueueMockResponse( - "ofrep/valid_api_short_response.json", - 200, - ) - enqueueMockResponse( - "ofrep/valid_api_response_2.json", - 200, - ) + val mockEngine = + mockEngineWithTwoResponses( + firstContent = getResourceAsString("ofrep/valid_api_short_response.json"), + secondContent = getResourceAsString("ofrep/valid_api_response_2.json"), + ) val provider = OfrepProvider( OfrepOptions( pollingInterval = 100.milliseconds, - endpoint = mockWebServer.url("/").toString(), + endpoint = FAKE_ENDPOINT, + httpClientEngine = mockEngine, ), ) OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) @@ -633,19 +638,4 @@ class OfrepProviderTest { ) assertEquals(want2, got2) } - - private fun enqueueMockResponse( - fileName: String, - responseCode: Int = 200, - headers: Map = emptyMap(), - ) { - val jsonString = getResourceAsString(fileName) - val resp = - MockResponse() - .setBody(jsonString.trimIndent()) - .setResponseCode(responseCode) - .addHeader("Content-Type", "application/json") - headers.forEach { (key, value) -> resp.addHeader(key, value) } - mockWebServer.enqueue(resp) - } } diff --git a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/TestUtils.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/TestUtils.kt new file mode 100644 index 0000000..baa524c --- /dev/null +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/TestUtils.kt @@ -0,0 +1,62 @@ +package dev.openfeature.kotlin.contrib.providers.ofrep + +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headers +import io.ktor.http.headersOf + +internal const val FAKE_ENDPOINT = "http://localhost/" + +internal fun mockEngineWithOneResponse( + content: String = "", + status: HttpStatusCode = HttpStatusCode.OK, + additionalHeaders: Headers = headersOf(), +) = MockEngine { + respond( + content = content, + status = status, + headers = + headers { + appendAll(additionalHeaders) + append( + HttpHeaders.ContentType, + ContentType.Application.Json.toString(), + ) + }, + ) +} + +internal fun mockEngineWithTwoResponses( + firstContent: String, + firstStatus: HttpStatusCode = HttpStatusCode.OK, + firstAdditionalHeaders: Headers = headersOf(), + secondContent: String, + secondStatus: HttpStatusCode = HttpStatusCode.OK, + secondAdditionalHeaders: Headers = headersOf(), +): MockEngine { + var counter = 0 + return MockEngine { + val (content, status, additionalHeaders) = + when (counter++) { + 0 -> arrayOf(firstContent, firstStatus, firstAdditionalHeaders) + 1 -> arrayOf(secondContent, secondStatus, secondAdditionalHeaders) + else -> error("Only two calls expected") + } + respond( + content = content as String, + status = status as HttpStatusCode, + headers = + headers { + appendAll(additionalHeaders as Headers) + append( + HttpHeaders.ContentType, + ContentType.Application.Json.toString(), + ) + }, + ) + } +} diff --git a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt index aa4b9ea..387b51e 100644 --- a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt @@ -1,44 +1,43 @@ package dev.openfeature.kotlin.contrib.providers.ofrep.controller +import dev.openfeature.kotlin.contrib.providers.ofrep.FAKE_ENDPOINT import dev.openfeature.kotlin.contrib.providers.ofrep.bean.FlagDto import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepApiResponse import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepOptions import dev.openfeature.kotlin.contrib.providers.ofrep.error.OfrepError import dev.openfeature.kotlin.contrib.providers.ofrep.getResourceAsString +import dev.openfeature.kotlin.contrib.providers.ofrep.mockEngineWithOneResponse +import dev.openfeature.kotlin.contrib.providers.ofrep.mockEngineWithTwoResponses import dev.openfeature.kotlin.sdk.EvaluationMetadata import dev.openfeature.kotlin.sdk.ImmutableContext import dev.openfeature.kotlin.sdk.Value import dev.openfeature.kotlin.sdk.exceptions.ErrorCode import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError +import io.ktor.client.engine.mock.MockEngine +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf import junit.framework.TestCase.assertFalse import kotlinx.coroutines.runBlocking -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue -import org.junit.Rule import org.junit.Test -class OfrepApiTest { - @get:Rule - val mockWebServer = MockWebServer() +private fun createOfrepApi(mockEngine: MockEngine) = + OfrepApi( + OfrepOptions(endpoint = FAKE_ENDPOINT, httpClientEngine = mockEngine), + ) +class OfrepApiTest { @Test fun shouldReturnAValidEvaluationResponse() = runBlocking { val jsonString = getResourceAsString("ofrep/valid_api_short_response.json") + val mockEngine = mockEngineWithOneResponse(content = jsonString) - mockWebServer.enqueue( - MockResponse() - .setBody(jsonString.trimIndent()) - .setHeader("Content-Type", "application/json"), - ) + val ofrepApi = createOfrepApi(mockEngine) - val ofrepApi = - OfrepApi( - OfrepOptions(endpoint = mockWebServer.url("/").toString()), - ) val ctx = ImmutableContext( targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd", @@ -82,8 +81,10 @@ class OfrepApiTest { metadata = EvaluationMetadata .builder() - .putString("description", "This flag controls the title of the feature flag") - .putString("title", "Feature Flag Title") + .putString( + "description", + "This flag controls the title of the feature flag", + ).putString("title", "Feature Flag Title") .build(), ), ), @@ -96,16 +97,12 @@ class OfrepApiTest { @Test fun shouldThrowAnUnauthorizedError(): Unit = runBlocking { - mockWebServer.enqueue( - MockResponse() - .setBody("{}") - .setResponseCode(401) - .setHeader("Content-Type", "application/json"), - ) - val ofrepApi = - OfrepApi( - OfrepOptions(endpoint = mockWebServer.url("/").toString()), + val mockEngine = + mockEngineWithOneResponse( + content = "{}", + status = HttpStatusCode.fromValue(401), ) + val ofrepApi = createOfrepApi(mockEngine) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") assertThrows(OfrepError.ApiUnauthorizedError::class.java) { runBlocking { @@ -117,16 +114,12 @@ class OfrepApiTest { @Test fun shouldThrowAForbiddenError(): Unit = runBlocking { - mockWebServer.enqueue( - MockResponse() - .setBody("{}") - .setResponseCode(403) - .setHeader("Content-Type", "application/json"), - ) - val ofrepApi = - OfrepApi( - OfrepOptions(endpoint = mockWebServer.url("/").toString()), + val mockEngine = + mockEngineWithOneResponse( + content = "{}", + status = HttpStatusCode.fromValue(403), ) + val ofrepApi = createOfrepApi(mockEngine) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") assertThrows(OfrepError.ForbiddenError::class.java) { runBlocking { @@ -138,17 +131,13 @@ class OfrepApiTest { @Test fun shouldThrowTooManyRequest(): Unit = runBlocking { - mockWebServer.enqueue( - MockResponse() - .setBody("{}") - .setResponseCode(429) - .setHeader("Retry-After", "120") - .setHeader("Content-Type", "application/json"), - ) - val ofrepApi = - OfrepApi( - OfrepOptions(endpoint = mockWebServer.url("/").toString()), + val mockEngine = + mockEngineWithOneResponse( + content = "{}", + status = HttpStatusCode.fromValue(429), + additionalHeaders = headersOf(HttpHeaders.RetryAfter, "120"), ) + val ofrepApi = createOfrepApi(mockEngine) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") try { ofrepApi.postBulkEvaluateFlags(ctx) @@ -162,16 +151,12 @@ class OfrepApiTest { @Test fun shouldThrowUnexpectedError(): Unit = runBlocking { - mockWebServer.enqueue( - MockResponse() - .setBody("{}") - .setResponseCode(500) - .setHeader("Content-Type", "application/json"), - ) - val ofrepApi = - OfrepApi( - OfrepOptions(endpoint = mockWebServer.url("/").toString()), + val mockEngine = + mockEngineWithOneResponse( + content = "{}", + status = HttpStatusCode.fromValue(500), ) + val ofrepApi = createOfrepApi(mockEngine) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") assertThrows(OfrepError.UnexpectedResponseError::class.java) { @@ -184,21 +169,16 @@ class OfrepApiTest { @Test fun shouldReturnAnEvaluationResponseInError(): Unit = runBlocking { - mockWebServer - .enqueue( - MockResponse() - .setBody( - """ - {"errorCode": "INVALID_CONTEXT", "errorDetails":"explanation of the error"} - """.trimIndent(), - ).setResponseCode(400) - .setHeader("Content-Type", "application/json"), + val mockEngine = + mockEngineWithOneResponse( + content = + """ + {"errorCode": "INVALID_CONTEXT", "errorDetails":"explanation of the error"} + """.trimIndent(), + status = HttpStatusCode.fromValue(400), ) - val ofrepApi = - OfrepApi( - OfrepOptions(endpoint = mockWebServer.url("/").toString()), - ) + val ofrepApi = createOfrepApi(mockEngine) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") val resp = ofrepApi.postBulkEvaluateFlags(ctx) @@ -211,16 +191,14 @@ class OfrepApiTest { @Test fun shouldReturnaEvaluationResponseIfWeReceiveA304(): Unit = runBlocking { - mockWebServer.enqueue( - MockResponse() - .setResponseCode(304) - .setHeader("Content-Type", "application/json"), - ) - val ofrepApi = - OfrepApi( - OfrepOptions(endpoint = mockWebServer.url("/").toString()), + val mockEngine = + mockEngineWithOneResponse( + content = "{}", + status = HttpStatusCode.fromValue(304), ) + val ofrepApi = createOfrepApi(mockEngine) + val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") val resp = ofrepApi.postBulkEvaluateFlags(ctx) assertFalse(resp.isError()) @@ -230,16 +208,12 @@ class OfrepApiTest { @Test fun shouldThrowTargetingKeyMissingErrorWithNoTargetingKey(): Unit = runBlocking { - mockWebServer.enqueue( - MockResponse() - .setBody("{}") - .setResponseCode(304) - .setHeader("Content-Type", "application/json"), - ) - val ofrepApi = - OfrepApi( - OfrepOptions(endpoint = mockWebServer.url("/").toString()), + val mockEngine = + mockEngineWithOneResponse( + content = "{}", + status = HttpStatusCode.fromValue(304), ) + val ofrepApi = createOfrepApi(mockEngine) val ctx = ImmutableContext(targetingKey = "") assertThrows(OpenFeatureError.TargetingKeyMissingError::class.java) { @@ -254,16 +228,12 @@ class OfrepApiTest { runBlocking { val jsonString = getResourceAsString("ofrep/invalid_api_response.json") - mockWebServer.enqueue( - MockResponse() - .setBody(jsonString.trimIndent()) - .setResponseCode(400) - .setHeader("Content-Type", "application/json"), - ) - val ofrepApi = - OfrepApi( - OfrepOptions(endpoint = mockWebServer.url("/").toString()), + val mockEngine = + mockEngineWithOneResponse( + content = jsonString, + status = HttpStatusCode.fromValue(400), ) + val ofrepApi = createOfrepApi(mockEngine) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") assertThrows(OfrepError.UnmarshallError::class.java) { @@ -276,14 +246,6 @@ class OfrepApiTest { @Test fun shouldThrowWithInvalidOptions(): Unit = runBlocking { - val jsonString = getResourceAsString("ofrep/invalid_api_response.json") - - mockWebServer.enqueue( - MockResponse() - .setBody(jsonString.trimIndent()) - .setResponseCode(400) - .setHeader("Content-Type", "application/json"), - ) assertThrows(OfrepError.InvalidOptionsError::class.java) { runBlocking { OfrepApi(OfrepOptions(endpoint = "invalid_url")) @@ -295,62 +257,47 @@ class OfrepApiTest { fun shouldETagShouldNotMatch(): Unit = runBlocking { val jsonString = getResourceAsString("ofrep/valid_api_response.json") - - mockWebServer.enqueue( - MockResponse() - .setBody(jsonString.trimIndent()) - .setResponseCode(200) - .addHeader("ETag", "123") - .setHeader("Content-Type", "application/json"), - ) - mockWebServer.enqueue( - MockResponse() - .setResponseCode(304) - .addHeader("ETag", "123"), - ) - - val ofrepApi = - OfrepApi( - OfrepOptions(endpoint = mockWebServer.url("/").toString()), + val mockEngine = + mockEngineWithTwoResponses( + firstContent = jsonString, + firstStatus = HttpStatusCode.fromValue(200), + firstAdditionalHeaders = headersOf(HttpHeaders.ETag, "123"), + secondContent = "", + secondStatus = HttpStatusCode.fromValue(304), + secondAdditionalHeaders = headersOf(HttpHeaders.ETag, "123"), ) + val ofrepApi = createOfrepApi(mockEngine) + val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") val eval1 = ofrepApi.postBulkEvaluateFlags(ctx) val eval2 = ofrepApi.postBulkEvaluateFlags(ctx) assertEquals(eval1.httpResponse.status.value, 200) assertEquals(eval2.httpResponse.status.value, 304) - assertEquals(2, mockWebServer.requestCount) + assertEquals(2, mockEngine.requestHistory.size) } @Test fun shouldHaveIfNoneNullInTheHeaders(): Unit = runBlocking { val jsonString = getResourceAsString("ofrep/valid_api_response.json") - - mockWebServer.enqueue( - MockResponse() - .setBody(jsonString.trimIndent()) - .setResponseCode(200) - .addHeader("ETag", "123") - .setHeader("Content-Type", "application/json"), - ) - mockWebServer.enqueue( - MockResponse() - .setResponseCode(304) - .addHeader("ETag", "123") - .setHeader("Content-Type", "application/json"), - ) - - val ofrepApi = - OfrepApi( - OfrepOptions(endpoint = mockWebServer.url("/").toString()), + val mockEngine = + mockEngineWithTwoResponses( + firstContent = jsonString, + firstStatus = HttpStatusCode.fromValue(200), + firstAdditionalHeaders = headersOf(HttpHeaders.ETag, "123"), + secondContent = "", + secondStatus = HttpStatusCode.fromValue(304), + secondAdditionalHeaders = headersOf(HttpHeaders.ETag, "123"), ) + val ofrepApi = createOfrepApi(mockEngine) + val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") val eval1 = ofrepApi.postBulkEvaluateFlags(ctx) val eval2 = ofrepApi.postBulkEvaluateFlags(ctx) assertEquals(eval1.httpResponse.status.value, 200) assertEquals(eval2.httpResponse.status.value, 304) - assertEquals(2, mockWebServer.requestCount) + assertEquals(2, mockEngine.requestHistory.size) } } From bd5a65c3f05146d43537dba5caae30246aa9face Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Sat, 14 Jun 2025 18:54:36 +0200 Subject: [PATCH 15/21] Replace jUnit with kotlin-test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- .../providers/ofrep/OfrepProviderTest.kt | 8 +++--- .../ofrep/controller/OfrepApiTest.kt | 25 ++++++++++--------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt index 812812a..c21f27a 100644 --- a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt @@ -23,10 +23,10 @@ import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Test import java.util.UUID +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.time.Duration.Companion.milliseconds private fun createOfrepProvider(mockEngine: MockEngine) = @@ -38,7 +38,7 @@ class OfrepProviderTest { private val defaultEvalCtx: EvaluationContext = ImmutableContext(targetingKey = UUID.randomUUID().toString()) - @After + @AfterTest fun after() = runTest { OpenFeatureAPI.shutdown() diff --git a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt index 387b51e..c7375e5 100644 --- a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt @@ -17,12 +17,13 @@ import io.ktor.client.engine.mock.MockEngine import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf -import junit.framework.TestCase.assertFalse import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals -import org.junit.Assert.assertThrows -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.test.fail private fun createOfrepApi(mockEngine: MockEngine) = OfrepApi( @@ -104,7 +105,7 @@ class OfrepApiTest { ) val ofrepApi = createOfrepApi(mockEngine) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") - assertThrows(OfrepError.ApiUnauthorizedError::class.java) { + assertFailsWith { runBlocking { ofrepApi.postBulkEvaluateFlags(ctx) } @@ -121,7 +122,7 @@ class OfrepApiTest { ) val ofrepApi = createOfrepApi(mockEngine) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") - assertThrows(OfrepError.ForbiddenError::class.java) { + assertFailsWith { runBlocking { ofrepApi.postBulkEvaluateFlags(ctx) } @@ -141,7 +142,7 @@ class OfrepApiTest { val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") try { ofrepApi.postBulkEvaluateFlags(ctx) - assertTrue("we exited the try block without throwing an exception", false) + fail("we exited the try block without throwing an exception") } catch (e: OfrepError.ApiTooManyRequestsError) { assertEquals(429, e.response?.status?.value) assertEquals(e.response?.headers?.get("Retry-After"), "120") @@ -159,7 +160,7 @@ class OfrepApiTest { val ofrepApi = createOfrepApi(mockEngine) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") - assertThrows(OfrepError.UnexpectedResponseError::class.java) { + assertFailsWith { runBlocking { ofrepApi.postBulkEvaluateFlags(ctx) } @@ -216,7 +217,7 @@ class OfrepApiTest { val ofrepApi = createOfrepApi(mockEngine) val ctx = ImmutableContext(targetingKey = "") - assertThrows(OpenFeatureError.TargetingKeyMissingError::class.java) { + assertFailsWith { runBlocking { ofrepApi.postBulkEvaluateFlags(ctx) } @@ -236,7 +237,7 @@ class OfrepApiTest { val ofrepApi = createOfrepApi(mockEngine) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") - assertThrows(OfrepError.UnmarshallError::class.java) { + assertFailsWith { runBlocking { ofrepApi.postBulkEvaluateFlags(ctx) } @@ -246,7 +247,7 @@ class OfrepApiTest { @Test fun shouldThrowWithInvalidOptions(): Unit = runBlocking { - assertThrows(OfrepError.InvalidOptionsError::class.java) { + assertFailsWith { runBlocking { OfrepApi(OfrepOptions(endpoint = "invalid_url")) } From d0cecf00c98a1c4b487f927f40015550fc02cd13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Sat, 14 Jun 2025 22:27:00 +0200 Subject: [PATCH 16/21] Replace Java UUID with Kotlin Uuid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- .../kotlin/contrib/providers/ofrep/OfrepProviderTest.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt index c21f27a..e494685 100644 --- a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt @@ -23,20 +23,22 @@ import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import java.util.UUID import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.time.Duration.Companion.milliseconds +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid private fun createOfrepProvider(mockEngine: MockEngine) = OfrepProvider( OfrepOptions(endpoint = FAKE_ENDPOINT, httpClientEngine = mockEngine), ) +@OptIn(ExperimentalUuidApi::class) class OfrepProviderTest { private val defaultEvalCtx: EvaluationContext = - ImmutableContext(targetingKey = UUID.randomUUID().toString()) + ImmutableContext(targetingKey = Uuid.random().toHexString()) @AfterTest fun after() = @@ -311,7 +313,7 @@ class OfrepProviderTest { } runCurrent() Thread.sleep(1000) // waiting to be sure that setEvaluationContext has been processed - val newEvalCtx = ImmutableContext(targetingKey = UUID.randomUUID().toString()) + val newEvalCtx = ImmutableContext(targetingKey = Uuid.random().toHexString()) OpenFeatureAPI.setEvaluationContext(newEvalCtx) Thread.sleep(1000) // waiting to be sure that setEvaluationContext has been processed runCurrent() From 507678b527cd526e81f3a5c8f3bec44b03b7f659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Wed, 18 Jun 2025 15:16:36 +0200 Subject: [PATCH 17/21] Shut down OpenFeatureAPI after each test case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- .../providers/ofrep/OfrepProviderTest.kt | 658 +++++++++--------- 1 file changed, 343 insertions(+), 315 deletions(-) diff --git a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt index e494685..e2727c1 100644 --- a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt @@ -3,8 +3,10 @@ package dev.openfeature.kotlin.contrib.providers.ofrep import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepOptions +import dev.openfeature.kotlin.sdk.Client import dev.openfeature.kotlin.sdk.EvaluationContext import dev.openfeature.kotlin.sdk.EvaluationMetadata +import dev.openfeature.kotlin.sdk.FeatureProvider import dev.openfeature.kotlin.sdk.FlagEvaluationDetails import dev.openfeature.kotlin.sdk.ImmutableContext import dev.openfeature.kotlin.sdk.OpenFeatureAPI @@ -16,6 +18,7 @@ import io.ktor.client.engine.mock.MockEngine import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.filterIsInstance @@ -35,6 +38,21 @@ private fun createOfrepProvider(mockEngine: MockEngine) = OfrepOptions(endpoint = FAKE_ENDPOINT, httpClientEngine = mockEngine), ) +private suspend fun withClient( + provider: FeatureProvider, + initialContext: EvaluationContext, + dispatcher: CoroutineDispatcher, + body: (client: Client) -> Unit, +) { + OpenFeatureAPI.setProviderAndWait(provider, initialContext, dispatcher) + try { + val client = OpenFeatureAPI.getClient() + body(client) + } finally { + OpenFeatureAPI.shutdown() + } +} + @OptIn(ExperimentalUuidApi::class) class OfrepProviderTest { private val defaultEvalCtx: EvaluationContext = @@ -67,9 +85,10 @@ class OfrepProviderTest { } } runCurrent() - OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) - runCurrent() - assert(providerErrorReceived) { "ProviderError event was not received" } + withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + runCurrent() + assert(providerErrorReceived) { "ProviderError event was not received" } + } } @Test @@ -87,9 +106,10 @@ class OfrepProviderTest { } } runCurrent() - OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) - runCurrent() - assert(providerErrorReceived) { "ProviderError event was not received" } + withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + runCurrent() + assert(providerErrorReceived) { "ProviderError event was not received" } + } } @Test @@ -113,11 +133,12 @@ class OfrepProviderTest { } } runCurrent() - OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) - runCurrent() - assert(providerErrorReceived) { "ProviderError event was not received" } - assert(exceptionReceived is OpenFeatureError.GeneralError) { "The exception is not of type GeneralError" } - assert(exceptionReceived?.message == "Rate limited") { "The exception's message is not correct" } + withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + runCurrent() + assert(providerErrorReceived) { "ProviderError event was not received" } + assert(exceptionReceived is OpenFeatureError.GeneralError) { "The exception is not of type GeneralError" } + assert(exceptionReceived?.message == "Rate limited") { "The exception's message is not correct" } + } } @Test @@ -137,12 +158,13 @@ class OfrepProviderTest { } runCurrent() val evalCtx = ImmutableContext(targetingKey = "") - OpenFeatureAPI.setProviderAndWait(provider, evalCtx, Dispatchers.IO) - runCurrent() - assert(providerErrorReceived) { "ProviderError event was not received" } - assert( - exceptionReceived is OpenFeatureError.TargetingKeyMissingError, - ) { "The exception is not of type TargetingKeyMissingError" } + withClient(provider, evalCtx, Dispatchers.IO) { client -> + runCurrent() + assert(providerErrorReceived) { "ProviderError event was not received" } + assert( + exceptionReceived is OpenFeatureError.TargetingKeyMissingError, + ) { "The exception is not of type TargetingKeyMissingError" } + } } @Test @@ -162,12 +184,13 @@ class OfrepProviderTest { } runCurrent() val evalCtx = ImmutableContext() - OpenFeatureAPI.setProviderAndWait(provider, evalCtx, Dispatchers.IO) - runCurrent() - assert(providerErrorReceived) { "ProviderError event was not received" } - assert( - exceptionReceived is OpenFeatureError.TargetingKeyMissingError, - ) { "The exception is not of type TargetingKeyMissingError" } + withClient(provider, evalCtx, Dispatchers.IO) { client -> + runCurrent() + assert(providerErrorReceived) { "ProviderError event was not received" } + assert( + exceptionReceived is OpenFeatureError.TargetingKeyMissingError, + ) { "The exception is not of type TargetingKeyMissingError" } + } } @Test @@ -186,10 +209,11 @@ class OfrepProviderTest { } } runCurrent() - OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) - runCurrent() - assert(providerErrorReceived) { "ProviderError event was not received" } - assert(exceptionReceived is OpenFeatureError.InvalidContextError) { "The exception is not of type InvalidContextError" } + withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + runCurrent() + assert(providerErrorReceived) { "ProviderError event was not received" } + assert(exceptionReceived is OpenFeatureError.InvalidContextError) { "The exception is not of type InvalidContextError" } + } } @Test @@ -209,10 +233,11 @@ class OfrepProviderTest { } } runCurrent() - OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) - runCurrent() - assert(providerErrorReceived) { "ProviderError event was not received" } - assert(exceptionReceived is OpenFeatureError.ParseError) { "The exception is not of type ParseError" } + withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + runCurrent() + assert(providerErrorReceived) { "ProviderError event was not received" } + assert(exceptionReceived is OpenFeatureError.ParseError) { "The exception is not of type ParseError" } + } } @Test @@ -220,19 +245,19 @@ class OfrepProviderTest { runTest { val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) val provider = createOfrepProvider(mockEngine) - OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) - val client = OpenFeatureAPI.getClient() - val got = client.getBooleanDetails("non-existent-flag", false) - val want = - FlagEvaluationDetails( - flagKey = "non-existent-flag", - value = false, - variant = null, - reason = "ERROR", - errorCode = ErrorCode.FLAG_NOT_FOUND, - errorMessage = "Could not find flag named: non-existent-flag", - ) - assertEquals(want, got) + withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + val got = client.getBooleanDetails("non-existent-flag", false) + val want = + FlagEvaluationDetails( + flagKey = "non-existent-flag", + value = false, + variant = null, + reason = "ERROR", + errorCode = ErrorCode.FLAG_NOT_FOUND, + errorMessage = "Could not find flag named: non-existent-flag", + ) + assertEquals(want, got) + } } @Test @@ -243,25 +268,25 @@ class OfrepProviderTest { getResourceAsString("ofrep/valid_api_short_response.json"), ) val provider = createOfrepProvider(mockEngine) - OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) - val client = OpenFeatureAPI.getClient() - val got = client.getStringDetails("title-flag", "default") - val want = - FlagEvaluationDetails( - flagKey = "title-flag", - value = "GO Feature Flag", - variant = "default_title", - reason = "DEFAULT", - errorCode = null, - errorMessage = null, - metadata = - EvaluationMetadata - .builder() - .putString("description", "This flag controls the title of the feature flag") - .putString("title", "Feature Flag Title") - .build(), - ) - assertEquals(want, got) + withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + val got = client.getStringDetails("title-flag", "default") + val want = + FlagEvaluationDetails( + flagKey = "title-flag", + value = "GO Feature Flag", + variant = "default_title", + reason = "DEFAULT", + errorCode = null, + errorMessage = null, + metadata = + EvaluationMetadata + .builder() + .putString("description", "This flag controls the title of the feature flag") + .putString("title", "Feature Flag Title") + .build(), + ) + assertEquals(want, got) + } } @Test @@ -272,19 +297,19 @@ class OfrepProviderTest { getResourceAsString("ofrep/valid_1_flag_in_parse_error.json"), ) val provider = createOfrepProvider(mockEngine) - OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) - val client = OpenFeatureAPI.getClient() - val got = client.getStringDetails("my-other-flag", "default") - val want = - FlagEvaluationDetails( - flagKey = "my-other-flag", - value = "default", - variant = null, - reason = "ERROR", - errorCode = ErrorCode.PARSE_ERROR, - errorMessage = "Error details about PARSE_ERROR", - ) - assertEquals(want, got) + withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + val got = client.getStringDetails("my-other-flag", "default") + val want = + FlagEvaluationDetails( + flagKey = "my-other-flag", + value = "default", + variant = null, + reason = "ERROR", + errorCode = ErrorCode.PARSE_ERROR, + errorMessage = "Error details about PARSE_ERROR", + ) + assertEquals(want, got) + } } @Test @@ -296,29 +321,30 @@ class OfrepProviderTest { secondContent = getResourceAsString("ofrep/valid_api_response_2.json"), ) val provider = createOfrepProvider(mockEngine) - OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) - - // TODO: should change when we have a way to observe context changes event - // check issue https://github.com/open-feature/kotlin-sdk/issues/107 - var providerStaleEventReceived = false - var providerReadyEventReceived = false - - launch { - provider.observe().filterIsInstance().take(1).collect { - providerStaleEventReceived = true - } - provider.observe().filterIsInstance().take(1).collect { - providerReadyEventReceived = true + withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + + // TODO: should change when we have a way to observe context changes event + // check issue https://github.com/open-feature/kotlin-sdk/issues/107 + var providerStaleEventReceived = false + var providerReadyEventReceived = false + + launch { + provider.observe().filterIsInstance().take(1).collect { + providerStaleEventReceived = true + } + provider.observe().filterIsInstance().take(1).collect { + providerReadyEventReceived = true + } } + runCurrent() + Thread.sleep(1000) // waiting to be sure that setEvaluationContext has been processed + val newEvalCtx = ImmutableContext(targetingKey = Uuid.random().toHexString()) + OpenFeatureAPI.setEvaluationContext(newEvalCtx) + Thread.sleep(1000) // waiting to be sure that setEvaluationContext has been processed + runCurrent() + assert(providerStaleEventReceived) { "ProviderStale event was not received" } + assert(providerReadyEventReceived) { "ProviderReady event was not received" } } - runCurrent() - Thread.sleep(1000) // waiting to be sure that setEvaluationContext has been processed - val newEvalCtx = ImmutableContext(targetingKey = Uuid.random().toHexString()) - OpenFeatureAPI.setEvaluationContext(newEvalCtx) - Thread.sleep(1000) // waiting to be sure that setEvaluationContext has been processed - runCurrent() - assert(providerStaleEventReceived) { "ProviderStale event was not received" } - assert(providerReadyEventReceived) { "ProviderReady event was not received" } } @Test @@ -337,66 +363,68 @@ class OfrepProviderTest { httpClientEngine = mockEngine, ), ) - OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) - val client = OpenFeatureAPI.getClient() - client.getStringDetails("my-other-flag", "default") - client.getStringDetails("my-other-flag", "default") - Thread.sleep(2000) // we wait 2 seconds to let the polling loop run - assertEquals(1, mockEngine.requestHistory.size) + withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + client.getStringDetails("my-other-flag", "default") + client.getStringDetails("my-other-flag", "default") + Thread.sleep(2000) // we wait 2 seconds to let the polling loop run + assertEquals(1, mockEngine.requestHistory.size) + } } @Test fun `should return a valid evaluation for Boolean`(): Unit = runTest { - val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val mockEngine = + mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) val provider = createOfrepProvider(mockEngine) - OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) - val client = OpenFeatureAPI.getClient() - val got = client.getBooleanDetails("bool-flag", false) - val want = - FlagEvaluationDetails( - flagKey = "bool-flag", - value = true, - variant = "variantA", - reason = "TARGETING_MATCH", - errorCode = null, - errorMessage = null, - metadata = - EvaluationMetadata - .builder() - .putBoolean("additionalProp1", true) - .putString("additionalProp2", "value") - .putInt("additionalProp3", 123) - .build(), - ) - assertEquals(want, got) + withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + val got = client.getBooleanDetails("bool-flag", false) + val want = + FlagEvaluationDetails( + flagKey = "bool-flag", + value = true, + variant = "variantA", + reason = "TARGETING_MATCH", + errorCode = null, + errorMessage = null, + metadata = + EvaluationMetadata + .builder() + .putBoolean("additionalProp1", true) + .putString("additionalProp2", "value") + .putInt("additionalProp3", 123) + .build(), + ) + assertEquals(want, got) + } } @Test fun `should return a valid evaluation for Int`(): Unit = runTest { - val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val mockEngine = + mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) val provider = createOfrepProvider(mockEngine) - OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) - val client = OpenFeatureAPI.getClient() - val got = client.getIntegerDetails("int-flag", 1) - val want = - FlagEvaluationDetails( - flagKey = "int-flag", - value = 1234, - variant = "variantA", - reason = "TARGETING_MATCH", - errorCode = null, - errorMessage = null, - metadata = - EvaluationMetadata - .builder() - .putBoolean("additionalProp1", true) - .putString("additionalProp2", "value") - .putInt("additionalProp3", 123) - .build(), - ) - assertEquals(want, got) + withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + val got = client.getIntegerDetails("int-flag", 1) + val want = + FlagEvaluationDetails( + flagKey = "int-flag", + value = 1234, + variant = "variantA", + reason = "TARGETING_MATCH", + errorCode = null, + errorMessage = null, + metadata = + EvaluationMetadata + .builder() + .putBoolean("additionalProp1", true) + .putString("additionalProp2", "value") + .putInt("additionalProp3", 123) + .build(), + ) + assertEquals(want, got) + } } @Test @@ -404,26 +432,26 @@ class OfrepProviderTest { runTest { val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) val provider = createOfrepProvider(mockEngine) - OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) - val client = OpenFeatureAPI.getClient() - val got = client.getDoubleDetails("double-flag", 1.1) - val want = - FlagEvaluationDetails( - flagKey = "double-flag", - value = 12.34, - variant = "variantA", - reason = "TARGETING_MATCH", - errorCode = null, - errorMessage = null, - metadata = - EvaluationMetadata - .builder() - .putBoolean("additionalProp1", true) - .putString("additionalProp2", "value") - .putInt("additionalProp3", 123) - .build(), - ) - assertEquals(want, got) + withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + val got = client.getDoubleDetails("double-flag", 1.1) + val want = + FlagEvaluationDetails( + flagKey = "double-flag", + value = 12.34, + variant = "variantA", + reason = "TARGETING_MATCH", + errorCode = null, + errorMessage = null, + metadata = + EvaluationMetadata + .builder() + .putBoolean("additionalProp1", true) + .putString("additionalProp2", "value") + .putInt("additionalProp3", 123) + .build(), + ) + assertEquals(want, got) + } } @Test @@ -431,26 +459,26 @@ class OfrepProviderTest { runTest { val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) val provider = createOfrepProvider(mockEngine) - OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) - val client = OpenFeatureAPI.getClient() - val got = client.getStringDetails("string-flag", "default") - val want = - FlagEvaluationDetails( - flagKey = "string-flag", - value = "1234value", - variant = "variantA", - reason = "TARGETING_MATCH", - errorCode = null, - errorMessage = null, - metadata = - EvaluationMetadata - .builder() - .putBoolean("additionalProp1", true) - .putString("additionalProp2", "value") - .putInt("additionalProp3", 123) - .build(), - ) - assertEquals(want, got) + withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + val got = client.getStringDetails("string-flag", "default") + val want = + FlagEvaluationDetails( + flagKey = "string-flag", + value = "1234value", + variant = "variantA", + reason = "TARGETING_MATCH", + errorCode = null, + errorMessage = null, + metadata = + EvaluationMetadata + .builder() + .putBoolean("additionalProp1", true) + .putString("additionalProp2", "value") + .putInt("additionalProp3", 123) + .build(), + ) + assertEquals(want, got) + } } @Test @@ -458,31 +486,31 @@ class OfrepProviderTest { runTest { val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) val provider = createOfrepProvider(mockEngine) - OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) - val client = OpenFeatureAPI.getClient() - val got = - client.getObjectDetails( - "array-flag", - Value.List(MutableList(1) { Value.Integer(1234567890) }), - ) - - val want = - FlagEvaluationDetails( - flagKey = "array-flag", - value = Value.List(listOf(Value.Integer(1234), Value.Integer(5678))), - variant = "variantA", - reason = "TARGETING_MATCH", - errorCode = null, - errorMessage = null, - metadata = - EvaluationMetadata - .builder() - .putBoolean("additionalProp1", true) - .putString("additionalProp2", "value") - .putInt("additionalProp3", 123) - .build(), - ) - assertEquals(want, got) + withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + val got = + client.getObjectDetails( + "array-flag", + Value.List(MutableList(1) { Value.Integer(1234567890) }), + ) + + val want = + FlagEvaluationDetails( + flagKey = "array-flag", + value = Value.List(listOf(Value.Integer(1234), Value.Integer(5678))), + variant = "variantA", + reason = "TARGETING_MATCH", + errorCode = null, + errorMessage = null, + metadata = + EvaluationMetadata + .builder() + .putBoolean("additionalProp1", true) + .putString("additionalProp2", "value") + .putInt("additionalProp3", 123) + .build(), + ) + assertEquals(want, got) + } } @Test @@ -490,45 +518,45 @@ class OfrepProviderTest { runTest { val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) val provider = createOfrepProvider(mockEngine) - OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) - val client = OpenFeatureAPI.getClient() - val got = - client.getObjectDetails( - "object-flag", - Value.Structure( - mapOf( - "default" to Value.Boolean(true), - ), - ), - ) - - val want = - FlagEvaluationDetails( - flagKey = "object-flag", - value = + withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + val got = + client.getObjectDetails( + "object-flag", Value.Structure( mapOf( - "testValue" to - Value.Structure( - mapOf( - "toto" to Value.Integer(1234), - ), - ), + "default" to Value.Boolean(true), ), ), - variant = "variantA", - reason = "TARGETING_MATCH", - errorCode = null, - errorMessage = null, - metadata = - EvaluationMetadata - .builder() - .putBoolean("additionalProp1", true) - .putString("additionalProp2", "value") - .putInt("additionalProp3", 123) - .build(), - ) - assertEquals(want, got) + ) + + val want = + FlagEvaluationDetails( + flagKey = "object-flag", + value = + Value.Structure( + mapOf( + "testValue" to + Value.Structure( + mapOf( + "toto" to Value.Integer(1234), + ), + ), + ), + ), + variant = "variantA", + reason = "TARGETING_MATCH", + errorCode = null, + errorMessage = null, + metadata = + EvaluationMetadata + .builder() + .putBoolean("additionalProp1", true) + .putString("additionalProp2", "value") + .putInt("additionalProp3", 123) + .build(), + ) + assertEquals(want, got) + } } @Test @@ -536,21 +564,21 @@ class OfrepProviderTest { runTest { val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) val provider = createOfrepProvider(mockEngine) - OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) - val client = OpenFeatureAPI.getClient() - val got = client.getBooleanDetails("object-flag", false) - val want = - FlagEvaluationDetails( - flagKey = "object-flag", - value = false, - variant = null, - reason = "ERROR", - errorCode = ErrorCode.TYPE_MISMATCH, - errorMessage = - "Type mismatch: expect Boolean - Unsupported type for: " + - "Structure(structure={testValue=Structure(structure={toto=Integer(integer=1234)})})", - ) - assertEquals(want, got) + withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + val got = client.getBooleanDetails("object-flag", false) + val want = + FlagEvaluationDetails( + flagKey = "object-flag", + value = false, + variant = null, + reason = "ERROR", + errorCode = ErrorCode.TYPE_MISMATCH, + errorMessage = + "Type mismatch: expect Boolean - Unsupported type for: " + + "Structure(structure={testValue=Structure(structure={toto=Integer(integer=1234)})})", + ) + assertEquals(want, got) + } } @Test @@ -558,21 +586,21 @@ class OfrepProviderTest { runTest { val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) val provider = createOfrepProvider(mockEngine) - OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) - val client = OpenFeatureAPI.getClient() - val got = client.getStringDetails("object-flag", "default") - val want = - FlagEvaluationDetails( - flagKey = "object-flag", - value = "default", - variant = null, - reason = "ERROR", - errorCode = ErrorCode.TYPE_MISMATCH, - errorMessage = - "Type mismatch: expect String - Unsupported type for: " + - "Structure(structure={testValue=Structure(structure={toto=Integer(integer=1234)})})", - ) - assertEquals(want, got) + withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + val got = client.getStringDetails("object-flag", "default") + val want = + FlagEvaluationDetails( + flagKey = "object-flag", + value = "default", + variant = null, + reason = "ERROR", + errorCode = ErrorCode.TYPE_MISMATCH, + errorMessage = + "Type mismatch: expect String - Unsupported type for: " + + "Structure(structure={testValue=Structure(structure={toto=Integer(integer=1234)})})", + ) + assertEquals(want, got) + } } @Test @@ -580,21 +608,21 @@ class OfrepProviderTest { runTest { val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) val provider = createOfrepProvider(mockEngine) - OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) - val client = OpenFeatureAPI.getClient() - val got = client.getDoubleDetails("object-flag", 1.233) - val want = - FlagEvaluationDetails( - flagKey = "object-flag", - value = 1.233, - variant = null, - reason = "ERROR", - errorCode = ErrorCode.TYPE_MISMATCH, - errorMessage = - "Type mismatch: expect Double - Unsupported type for: " + - "Structure(structure={testValue=Structure(structure={toto=Integer(integer=1234)})})", - ) - assertEquals(want, got) + withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + val got = client.getDoubleDetails("object-flag", 1.233) + val want = + FlagEvaluationDetails( + flagKey = "object-flag", + value = 1.233, + variant = null, + reason = "ERROR", + errorCode = ErrorCode.TYPE_MISMATCH, + errorMessage = + "Type mismatch: expect Double - Unsupported type for: " + + "Structure(structure={testValue=Structure(structure={toto=Integer(integer=1234)})})", + ) + assertEquals(want, got) + } } @Test @@ -614,30 +642,30 @@ class OfrepProviderTest { httpClientEngine = mockEngine, ), ) - OpenFeatureAPI.setProviderAndWait(provider, defaultEvalCtx, Dispatchers.IO) - val client = OpenFeatureAPI.getClient() - val got = client.getStringDetails("badge-class2", "default") - val want = - FlagEvaluationDetails( - flagKey = "badge-class2", - value = "green", - variant = "nocolor", - reason = "DEFAULT", - errorCode = null, - errorMessage = null, - ) - assertEquals(want, got) - Thread.sleep(1000) - val got2 = client.getStringDetails("badge-class2", "default") - val want2 = - FlagEvaluationDetails( - flagKey = "badge-class2", - value = "blue", - variant = "xxxx", - reason = "TARGETING_MATCH", - errorCode = null, - errorMessage = null, - ) - assertEquals(want2, got2) + withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + val got = client.getStringDetails("badge-class2", "default") + val want = + FlagEvaluationDetails( + flagKey = "badge-class2", + value = "green", + variant = "nocolor", + reason = "DEFAULT", + errorCode = null, + errorMessage = null, + ) + assertEquals(want, got) + Thread.sleep(1000) + val got2 = client.getStringDetails("badge-class2", "default") + val want2 = + FlagEvaluationDetails( + flagKey = "badge-class2", + value = "blue", + variant = "xxxx", + reason = "TARGETING_MATCH", + errorCode = null, + errorMessage = null, + ) + assertEquals(want2, got2) + } } } From dd91e583acc8ef7512fb67ff0887ac0c84f48fc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Thu, 19 Jun 2025 10:14:24 +0200 Subject: [PATCH 18/21] Remove/replace runBlocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- .../ofrep/controller/OfrepApiTest.kt | 50 +++++++------------ 1 file changed, 19 insertions(+), 31 deletions(-) diff --git a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt index c7375e5..385866e 100644 --- a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt @@ -17,7 +17,7 @@ import io.ktor.client.engine.mock.MockEngine import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -33,7 +33,7 @@ private fun createOfrepApi(mockEngine: MockEngine) = class OfrepApiTest { @Test fun shouldReturnAValidEvaluationResponse() = - runBlocking { + runTest { val jsonString = getResourceAsString("ofrep/valid_api_short_response.json") val mockEngine = mockEngineWithOneResponse(content = jsonString) @@ -97,7 +97,7 @@ class OfrepApiTest { @Test fun shouldThrowAnUnauthorizedError(): Unit = - runBlocking { + runTest { val mockEngine = mockEngineWithOneResponse( content = "{}", @@ -106,15 +106,13 @@ class OfrepApiTest { val ofrepApi = createOfrepApi(mockEngine) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") assertFailsWith { - runBlocking { - ofrepApi.postBulkEvaluateFlags(ctx) - } + ofrepApi.postBulkEvaluateFlags(ctx) } } @Test fun shouldThrowAForbiddenError(): Unit = - runBlocking { + runTest { val mockEngine = mockEngineWithOneResponse( content = "{}", @@ -123,15 +121,13 @@ class OfrepApiTest { val ofrepApi = createOfrepApi(mockEngine) val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") assertFailsWith { - runBlocking { - ofrepApi.postBulkEvaluateFlags(ctx) - } + ofrepApi.postBulkEvaluateFlags(ctx) } } @Test fun shouldThrowTooManyRequest(): Unit = - runBlocking { + runTest { val mockEngine = mockEngineWithOneResponse( content = "{}", @@ -151,7 +147,7 @@ class OfrepApiTest { @Test fun shouldThrowUnexpectedError(): Unit = - runBlocking { + runTest { val mockEngine = mockEngineWithOneResponse( content = "{}", @@ -161,15 +157,13 @@ class OfrepApiTest { val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") assertFailsWith { - runBlocking { - ofrepApi.postBulkEvaluateFlags(ctx) - } + ofrepApi.postBulkEvaluateFlags(ctx) } } @Test fun shouldReturnAnEvaluationResponseInError(): Unit = - runBlocking { + runTest { val mockEngine = mockEngineWithOneResponse( content = @@ -191,7 +185,7 @@ class OfrepApiTest { @Test fun shouldReturnaEvaluationResponseIfWeReceiveA304(): Unit = - runBlocking { + runTest { val mockEngine = mockEngineWithOneResponse( content = "{}", @@ -208,7 +202,7 @@ class OfrepApiTest { @Test fun shouldThrowTargetingKeyMissingErrorWithNoTargetingKey(): Unit = - runBlocking { + runTest { val mockEngine = mockEngineWithOneResponse( content = "{}", @@ -218,15 +212,13 @@ class OfrepApiTest { val ctx = ImmutableContext(targetingKey = "") assertFailsWith { - runBlocking { - ofrepApi.postBulkEvaluateFlags(ctx) - } + ofrepApi.postBulkEvaluateFlags(ctx) } } @Test fun shouldThrowUnmarshallErrorWithInvalidJson(): Unit = - runBlocking { + runTest { val jsonString = getResourceAsString("ofrep/invalid_api_response.json") val mockEngine = @@ -238,25 +230,21 @@ class OfrepApiTest { val ctx = ImmutableContext(targetingKey = "68cf565d-15cd-4e8b-95a6-9399987164cd") assertFailsWith { - runBlocking { - ofrepApi.postBulkEvaluateFlags(ctx) - } + ofrepApi.postBulkEvaluateFlags(ctx) } } @Test fun shouldThrowWithInvalidOptions(): Unit = - runBlocking { + runTest { assertFailsWith { - runBlocking { - OfrepApi(OfrepOptions(endpoint = "invalid_url")) - } + OfrepApi(OfrepOptions(endpoint = "invalid_url")) } } @Test fun shouldETagShouldNotMatch(): Unit = - runBlocking { + runTest { val jsonString = getResourceAsString("ofrep/valid_api_response.json") val mockEngine = mockEngineWithTwoResponses( @@ -280,7 +268,7 @@ class OfrepApiTest { @Test fun shouldHaveIfNoneNullInTheHeaders(): Unit = - runBlocking { + runTest { val jsonString = getResourceAsString("ofrep/valid_api_response.json") val mockEngine = mockEngineWithTwoResponses( From 93810f3bea679f630168f299a5efcae9b749bfa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Thu, 19 Jun 2025 10:17:05 +0200 Subject: [PATCH 19/21] Replace Java scheduling with Kotlin one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- .../contrib/providers/ofrep/OfrepProvider.kt | 116 +++++----- .../providers/ofrep/bean/OfrepOptions.kt | 6 + .../providers/ofrep/OfrepProviderTest.kt | 199 ++++++++++++------ .../contrib/providers/ofrep/TestUtils.kt | 47 +++-- 4 files changed, 223 insertions(+), 145 deletions(-) diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProvider.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProvider.kt index 3a71371..bc025d5 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProvider.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProvider.kt @@ -17,17 +17,20 @@ import dev.openfeature.kotlin.sdk.Value import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents import dev.openfeature.kotlin.sdk.exceptions.ErrorCode import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.runBlocking -import java.text.SimpleDateFormat -import java.util.Calendar -import java.util.Date -import java.util.Locale -import java.util.TimeZone -import java.util.Timer -import java.util.TimerTask - +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@OptIn(ExperimentalTime::class) class OfrepProvider( private val ofrepOptions: OfrepOptions, ) : FeatureProvider { @@ -39,9 +42,12 @@ class OfrepProvider( get() = OfrepProviderMetadata() private var evaluationContext: EvaluationContext? = null + + @Volatile private var inMemoryCache: Map = emptyMap() - private var retryAfter: Date? = null - private var pollingTimer: Timer? = null + private var retryAfter: Instant? = null + private val pollingScope: CoroutineScope = CoroutineScope(ofrepOptions.pollingDispatcher) + private var pollingJob: Job? = null private val statusFlow = MutableSharedFlow(replay = 1) @@ -72,44 +78,48 @@ class OfrepProvider( * Start polling for flag updates */ private fun startPolling() { - val task: TimerTask = - object : TimerTask() { - override fun run() { - runBlocking { - try { - val resp = - this@OfrepProvider.evaluateFlags(this@OfrepProvider.evaluationContext!!) - - when (resp) { - BulkEvaluationStatus.RATE_LIMITED, BulkEvaluationStatus.SUCCESS_NO_CHANGE -> { - // Nothing to do ! - // - // if rate limited: the provider should already be in stale status and - // we don't need to emit an event or call again the API - // - // if no change: the provider should already be in ready status and - // we don't need to emit an event if nothing has changed - } - - BulkEvaluationStatus.SUCCESS_UPDATED -> { - // TODO: we should migrate to configuration change event when it's available - // in the kotlin SDK - statusFlow.emit(OpenFeatureProviderEvents.ProviderReady) - } + pollingJob = + pollingScope.launch { + while (isActive) { + try { + delay(ofrepOptions.pollingInterval) + val resp = + evaluateFlags(evaluationContext!!) + + when (resp) { + BulkEvaluationStatus.RATE_LIMITED, BulkEvaluationStatus.SUCCESS_NO_CHANGE -> { + // Nothing to do ! + // + // if rate limited: the provider should already be in stale status and + // we don't need to emit an event or call again the API + // + // if no change: the provider should already be in ready status and + // we don't need to emit an event if nothing has changed + } + + BulkEvaluationStatus.SUCCESS_UPDATED -> { + // TODO: we should migrate to configuration change event when it's available + // in the kotlin SDK + statusFlow.emit(OpenFeatureProviderEvents.ProviderReady) } - } catch (e: OfrepError.ApiTooManyRequestsError) { - // in that case the provider is just stale because we were not able to - statusFlow.emit(OpenFeatureProviderEvents.ProviderStale) - } catch (e: Throwable) { - statusFlow.emit(OpenFeatureProviderEvents.ProviderError(OpenFeatureError.GeneralError(e.message ?: ""))) } + } catch (e: CancellationException) { + // expected to happen when the job is cancelled, no need to report it via the + // statusFlow + } catch (e: OfrepError.ApiTooManyRequestsError) { + // in that case the provider is just stale because we were not able to + statusFlow.emit(OpenFeatureProviderEvents.ProviderStale) + } catch (e: Throwable) { + statusFlow.emit( + OpenFeatureProviderEvents.ProviderError( + OpenFeatureError.GeneralError( + e.message ?: "", + ), + ), + ) } } } - val timer = Timer() - val pollingIntervalInMillis = ofrepOptions.pollingInterval.inWholeMilliseconds - timer.schedule(task, pollingIntervalInMillis, pollingIntervalInMillis) - pollingTimer = timer } override fun getBooleanEvaluation( @@ -162,7 +172,7 @@ class OfrepProvider( } override fun shutdown() { - this.pollingTimer?.cancel() + pollingJob?.cancel() } private inline fun genericEvaluation( @@ -192,7 +202,7 @@ class OfrepProvider( * It will store the flags in the in-memory cache, if any error occurs it will throw an exception. */ private suspend fun evaluateFlags(context: EvaluationContext): BulkEvaluationStatus { - if (this.retryAfter != null && this.retryAfter!! > Date()) { + if (this.retryAfter != null && this.retryAfter!! > Clock.System.now()) { return BulkEvaluationStatus.RATE_LIMITED } @@ -227,22 +237,18 @@ class OfrepProvider( } } - private fun calculateRetryDate(retryAfter: String): Date? { + private fun calculateRetryDate(retryAfter: String): Instant? { if (retryAfter.isEmpty()) { return null } - val retryDate: Calendar = Calendar.getInstance() - try { + return try { // If retryAfter is a number, it represents seconds to wait. - val delayInSeconds = retryAfter.toInt() - retryDate.add(Calendar.SECOND, delayInSeconds) + val delayInSeconds = retryAfter.toInt().seconds + Clock.System.now() + delayInSeconds } catch (e: NumberFormatException) { // If retryAfter is not a number, it's an HTTP-date. - val dateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US) - dateFormat.timeZone = TimeZone.getTimeZone("GMT") - retryDate.time = dateFormat.parse(retryAfter) ?: return null + Instant.parse(retryAfter) } - return retryDate.time } } diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepOptions.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepOptions.kt index 89b03a5..d193f38 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepOptions.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/bean/OfrepOptions.kt @@ -2,6 +2,8 @@ package dev.openfeature.kotlin.contrib.providers.ofrep.bean import io.ktor.client.HttpClient import io.ktor.client.engine.HttpClientEngine +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlin.time.Duration import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes @@ -47,4 +49,8 @@ data class OfrepOptions( * requests. */ val httpClientEngine: HttpClientEngine? = null, + /** + * The [CoroutineDispatcher] to be used for polling the OFREP backend + */ + val pollingDispatcher: CoroutineDispatcher = Dispatchers.Default ) diff --git a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt index e2727c1..03a144c 100644 --- a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt @@ -24,18 +24,29 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid -private fun createOfrepProvider(mockEngine: MockEngine) = +private val POLLING_INTERVAL = 10.minutes + +private fun TestScope.createOfrepProvider(mockEngine: MockEngine) = OfrepProvider( - OfrepOptions(endpoint = FAKE_ENDPOINT, httpClientEngine = mockEngine), + OfrepOptions( + endpoint = FAKE_ENDPOINT, + httpClientEngine = mockEngine, + pollingInterval = POLLING_INTERVAL, + pollingDispatcher = StandardTestDispatcher(testScheduler), + ), ) private suspend fun withClient( @@ -74,15 +85,22 @@ class OfrepProviderTest { fun `should be in Fatal status if 401 error during initialise`(): Unit = runTest { val mockEngine = - mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json"), status = HttpStatusCode.fromValue(401)) + mockEngineWithOneResponse( + getResourceAsString("ofrep/valid_api_response.json"), + status = HttpStatusCode.fromValue(401), + ) val provider = createOfrepProvider(mockEngine) var providerErrorReceived = false launch { - provider.observe().filterIsInstance().take(1).collect { - providerErrorReceived = true - } + provider + .observe() + .filterIsInstance() + .take(1) + .collect { + providerErrorReceived = true + } } runCurrent() withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> @@ -95,15 +113,22 @@ class OfrepProviderTest { fun `should be in Fatal status if 403 error during initialise`(): Unit = runTest { val mockEngine = - mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json"), status = HttpStatusCode.fromValue(403)) + mockEngineWithOneResponse( + getResourceAsString("ofrep/valid_api_response.json"), + status = HttpStatusCode.fromValue(403), + ) val provider = createOfrepProvider(mockEngine) var providerErrorReceived = false launch { - provider.observe().filterIsInstance().take(1).collect { - providerErrorReceived = true - } + provider + .observe() + .filterIsInstance() + .take(1) + .collect { + providerErrorReceived = true + } } runCurrent() withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> @@ -127,10 +152,14 @@ class OfrepProviderTest { var exceptionReceived: Throwable? = null launch { - provider.observe().filterIsInstance().take(1).collect { - providerErrorReceived = true - exceptionReceived = it.error - } + provider + .observe() + .filterIsInstance() + .take(1) + .collect { + providerErrorReceived = true + exceptionReceived = it.error + } } runCurrent() withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> @@ -144,17 +173,22 @@ class OfrepProviderTest { @Test fun `should be in Error status if error targeting key is empty`(): Unit = runTest { - val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val mockEngine = + mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) val provider = createOfrepProvider(mockEngine) var providerErrorReceived = false var exceptionReceived: Throwable? = null launch { - provider.observe().filterIsInstance().take(1).collect { - providerErrorReceived = true - exceptionReceived = it.error - } + provider + .observe() + .filterIsInstance() + .take(1) + .collect { + providerErrorReceived = true + exceptionReceived = it.error + } } runCurrent() val evalCtx = ImmutableContext(targetingKey = "") @@ -170,17 +204,22 @@ class OfrepProviderTest { @Test fun `should be in Error status if error targeting key is missing`(): Unit = runTest { - val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val mockEngine = + mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) val provider = createOfrepProvider(mockEngine) var providerErrorReceived = false var exceptionReceived: Throwable? = null launch { - provider.observe().filterIsInstance().take(1).collect { - providerErrorReceived = true - exceptionReceived = it.error - } + provider + .observe() + .filterIsInstance() + .take(1) + .collect { + providerErrorReceived = true + exceptionReceived = it.error + } } runCurrent() val evalCtx = ImmutableContext() @@ -197,16 +236,23 @@ class OfrepProviderTest { fun `should be in error status if error invalid context`(): Unit = runTest { val mockEngine = - mockEngineWithOneResponse(getResourceAsString("ofrep/invalid_context.json"), status = HttpStatusCode.fromValue(400)) + mockEngineWithOneResponse( + getResourceAsString("ofrep/invalid_context.json"), + status = HttpStatusCode.fromValue(400), + ) val provider = createOfrepProvider(mockEngine) var providerErrorReceived = false var exceptionReceived: Throwable? = null launch { - provider.observe().filterIsInstance().take(1).collect { - providerErrorReceived = true - exceptionReceived = it.error - } + provider + .observe() + .filterIsInstance() + .take(1) + .collect { + providerErrorReceived = true + exceptionReceived = it.error + } } runCurrent() withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> @@ -220,17 +266,24 @@ class OfrepProviderTest { fun `should be in error status if error parse error`(): Unit = runTest { val mockEngine = - mockEngineWithOneResponse(getResourceAsString("ofrep/parse_error.json"), status = HttpStatusCode.fromValue(400)) + mockEngineWithOneResponse( + getResourceAsString("ofrep/parse_error.json"), + status = HttpStatusCode.fromValue(400), + ) val provider = createOfrepProvider(mockEngine) var providerErrorReceived = false var exceptionReceived: Throwable? = null launch { - provider.observe().filterIsInstance().take(1).collect { - providerErrorReceived = true - exceptionReceived = it.error - } + provider + .observe() + .filterIsInstance() + .take(1) + .collect { + providerErrorReceived = true + exceptionReceived = it.error + } } runCurrent() withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> @@ -243,7 +296,8 @@ class OfrepProviderTest { @Test fun `should return a flag not found error if the flag does not exist`(): Unit = runTest { - val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val mockEngine = + mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> val got = client.getBooleanDetails("non-existent-flag", false) @@ -329,18 +383,27 @@ class OfrepProviderTest { var providerReadyEventReceived = false launch { - provider.observe().filterIsInstance().take(1).collect { - providerStaleEventReceived = true - } - provider.observe().filterIsInstance().take(1).collect { - providerReadyEventReceived = true - } + provider + .observe() + .filterIsInstance() + .take(1) + .collect { + providerStaleEventReceived = true + } + provider + .observe() + .filterIsInstance() + .take(1) + .collect { + providerReadyEventReceived = true + } } runCurrent() - Thread.sleep(1000) // waiting to be sure that setEvaluationContext has been processed val newEvalCtx = ImmutableContext(targetingKey = Uuid.random().toHexString()) - OpenFeatureAPI.setEvaluationContext(newEvalCtx) - Thread.sleep(1000) // waiting to be sure that setEvaluationContext has been processed + OpenFeatureAPI.setEvaluationContext( + newEvalCtx, + dispatcher = StandardTestDispatcher(testScheduler), + ) runCurrent() assert(providerStaleEventReceived) { "ProviderStale event was not received" } assert(providerReadyEventReceived) { "ProviderReady event was not received" } @@ -356,17 +419,11 @@ class OfrepProviderTest { additionalHeaders = headersOf("Retry-After", "3"), ) val provider = - OfrepProvider( - OfrepOptions( - pollingInterval = 100.milliseconds, - endpoint = FAKE_ENDPOINT, - httpClientEngine = mockEngine, - ), - ) + createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> client.getStringDetails("my-other-flag", "default") client.getStringDetails("my-other-flag", "default") - Thread.sleep(2000) // we wait 2 seconds to let the polling loop run + advanceTimeBy(POLLING_INTERVAL) assertEquals(1, mockEngine.requestHistory.size) } } @@ -430,7 +487,8 @@ class OfrepProviderTest { @Test fun `should return a valid evaluation for Double`(): Unit = runTest { - val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val mockEngine = + mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> val got = client.getDoubleDetails("double-flag", 1.1) @@ -457,7 +515,8 @@ class OfrepProviderTest { @Test fun `should return a valid evaluation for String`(): Unit = runTest { - val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val mockEngine = + mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> val got = client.getStringDetails("string-flag", "default") @@ -484,7 +543,8 @@ class OfrepProviderTest { @Test fun `should return a valid evaluation for List`(): Unit = runTest { - val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val mockEngine = + mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> val got = @@ -516,7 +576,8 @@ class OfrepProviderTest { @Test fun `should return a valid evaluation for Map`(): Unit = runTest { - val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val mockEngine = + mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> val got = @@ -562,7 +623,8 @@ class OfrepProviderTest { @Test fun `should return TypeMismatch Bool`(): Unit = runTest { - val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val mockEngine = + mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> val got = client.getBooleanDetails("object-flag", false) @@ -584,7 +646,8 @@ class OfrepProviderTest { @Test fun `should return TypeMismatch String`(): Unit = runTest { - val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val mockEngine = + mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> val got = client.getStringDetails("object-flag", "default") @@ -606,7 +669,8 @@ class OfrepProviderTest { @Test fun `should return TypeMismatch Double`(): Unit = runTest { - val mockEngine = mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + val mockEngine = + mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> val got = client.getDoubleDetails("object-flag", 1.233) @@ -627,22 +691,19 @@ class OfrepProviderTest { @Test fun `should have different result if waiting for next polling interval`(): Unit = - runTest { + runTest( + // TODO: remove + timeout = 10.minutes, + ) { val mockEngine = mockEngineWithTwoResponses( firstContent = getResourceAsString("ofrep/valid_api_short_response.json"), secondContent = getResourceAsString("ofrep/valid_api_response_2.json"), ) - val provider = - OfrepProvider( - OfrepOptions( - pollingInterval = 100.milliseconds, - endpoint = FAKE_ENDPOINT, - httpClientEngine = mockEngine, - ), - ) + val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + runCurrent() val got = client.getStringDetails("badge-class2", "default") val want = FlagEvaluationDetails( @@ -654,7 +715,7 @@ class OfrepProviderTest { errorMessage = null, ) assertEquals(want, got) - Thread.sleep(1000) + advanceTimeBy(POLLING_INTERVAL + 1.milliseconds) val got2 = client.getStringDetails("badge-class2", "default") val want2 = FlagEvaluationDetails( diff --git a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/TestUtils.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/TestUtils.kt index baa524c..5e9663c 100644 --- a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/TestUtils.kt +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/TestUtils.kt @@ -8,6 +8,8 @@ import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headers import io.ktor.http.headersOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope internal const val FAKE_ENDPOINT = "http://localhost/" @@ -30,7 +32,7 @@ internal fun mockEngineWithOneResponse( ) } -internal fun mockEngineWithTwoResponses( +internal fun TestScope.mockEngineWithTwoResponses( firstContent: String, firstStatus: HttpStatusCode = HttpStatusCode.OK, firstAdditionalHeaders: Headers = headersOf(), @@ -39,24 +41,27 @@ internal fun mockEngineWithTwoResponses( secondAdditionalHeaders: Headers = headersOf(), ): MockEngine { var counter = 0 - return MockEngine { - val (content, status, additionalHeaders) = - when (counter++) { - 0 -> arrayOf(firstContent, firstStatus, firstAdditionalHeaders) - 1 -> arrayOf(secondContent, secondStatus, secondAdditionalHeaders) - else -> error("Only two calls expected") - } - respond( - content = content as String, - status = status as HttpStatusCode, - headers = - headers { - appendAll(additionalHeaders as Headers) - append( - HttpHeaders.ContentType, - ContentType.Application.Json.toString(), - ) - }, - ) - } + return MockEngine.create { + dispatcher = StandardTestDispatcher(testScheduler) + addHandler { + val (content, status, additionalHeaders) = + when (counter++) { + 0 -> arrayOf(firstContent, firstStatus, firstAdditionalHeaders) + 1 -> arrayOf(secondContent, secondStatus, secondAdditionalHeaders) + else -> error("Only two calls expected") + } + respond( + content = content as String, + status = status as HttpStatusCode, + headers = + headers { + appendAll(additionalHeaders as Headers) + append( + HttpHeaders.ContentType, + ContentType.Application.Json.toString(), + ) + }, + ) + } + } as MockEngine } From c2d1afa81388376ab8f555b0d54544fa60a9549e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Fri, 20 Jun 2025 16:39:42 +0200 Subject: [PATCH 20/21] Convert resource files into const val fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- gradle/libs.versions.toml | 1 - providers/ofrep/build.gradle.kts | 1 - .../providers/ofrep/GetResourceAsString.kt | 9 ---- .../providers/ofrep/OfrepProviderTest.kt | 52 +++++++++++-------- .../ofrep/controller/OfrepApiTest.kt | 17 +++--- .../payloads/InvalidApiResponsePayload.kt} | 4 +- .../ofrep/payloads/InvalidContextPayload.kt} | 4 +- .../ofrep/payloads/ParseErrorPayload.kt} | 4 +- .../Valid1FlagInParseErrorPayload.kt} | 4 +- .../payloads/ValidApiResponse2Payload.kt} | 4 +- .../payloads/ValidApiResponsePayload.kt} | 4 +- .../payloads/ValidApiShortResponsePayload.kt} | 4 +- 12 files changed, 57 insertions(+), 51 deletions(-) delete mode 100644 providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/GetResourceAsString.kt rename providers/ofrep/src/commonTest/{resources/ofrep/invalid_api_response.json => kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/InvalidApiResponsePayload.kt} (89%) rename providers/ofrep/src/commonTest/{resources/ofrep/invalid_context.json => kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/InvalidContextPayload.kt} (62%) rename providers/ofrep/src/commonTest/{resources/ofrep/parse_error.json => kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/ParseErrorPayload.kt} (62%) rename providers/ofrep/src/commonTest/{resources/ofrep/valid_1_flag_in_parse_error.json => kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/Valid1FlagInParseErrorPayload.kt} (85%) rename providers/ofrep/src/commonTest/{resources/ofrep/valid_api_response_2.json => kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/ValidApiResponse2Payload.kt} (97%) rename providers/ofrep/src/commonTest/{resources/ofrep/valid_api_response.json => kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/ValidApiResponsePayload.kt} (97%) rename providers/ofrep/src/commonTest/{resources/ofrep/valid_api_short_response.json => kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/ValidApiShortResponsePayload.kt} (89%) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 534e2f5..c58a331 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,7 +15,6 @@ openfeature-kotlin-sdk = { group="dev.openfeature", name="kotlin-sdk", version.r kotlin-test = { group="org.jetbrains.kotlin", name="kotlin-test", version.ref="kotlin" } kotlinx-coroutines-core = { group="org.jetbrains.kotlinx", name="kotlinx-coroutines-core", version.ref="kotlinx-coroutines" } kotlinx-coroutines-test = { group="org.jetbrains.kotlinx", name="kotlinx-coroutines-test", version.ref="kotlinx-coroutines" } -okio = { module="com.squareup.okio:okio", version="3.13.0" } [plugins] kotlin-multiplatform = { id="org.jetbrains.kotlin.multiplatform", version.ref="kotlin" } diff --git a/providers/ofrep/build.gradle.kts b/providers/ofrep/build.gradle.kts index 1d154f9..f5cfda4 100644 --- a/providers/ofrep/build.gradle.kts +++ b/providers/ofrep/build.gradle.kts @@ -27,7 +27,6 @@ kotlin { implementation(libs.ktor.cio) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) - implementation(libs.okio) } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/GetResourceAsString.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/GetResourceAsString.kt deleted file mode 100644 index 302062b..0000000 --- a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/GetResourceAsString.kt +++ /dev/null @@ -1,9 +0,0 @@ -package dev.openfeature.kotlin.contrib.providers.ofrep - -import okio.FileSystem -import okio.Path.Companion.toPath - -fun getResourceAsString(resourceName: String): String = - FileSystem.SYSTEM.read("src/commonTest/resources".toPath() / resourceName) { - readUtf8() - } diff --git a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt index 03a144c..1b45398 100644 --- a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt @@ -2,6 +2,12 @@ package dev.openfeature.kotlin.contrib.providers.ofrep +import INVALID_CONTEXT_PAYLOAD +import PARSE_ERROR_PAYLOAD +import VALID_1_FLAG_IN_PARSE_ERROR_PAYLOAD +import VALID_API_RESPONSE2_PAYLOAD +import VALID_API_RESPONSE_PAYLOAD +import VALID_API_SHORT_RESPONSE_PAYLOAD import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepOptions import dev.openfeature.kotlin.sdk.Client import dev.openfeature.kotlin.sdk.EvaluationContext @@ -86,7 +92,7 @@ class OfrepProviderTest { runTest { val mockEngine = mockEngineWithOneResponse( - getResourceAsString("ofrep/valid_api_response.json"), + VALID_API_RESPONSE_PAYLOAD, status = HttpStatusCode.fromValue(401), ) @@ -114,7 +120,7 @@ class OfrepProviderTest { runTest { val mockEngine = mockEngineWithOneResponse( - getResourceAsString("ofrep/valid_api_response.json"), + VALID_API_RESPONSE_PAYLOAD, status = HttpStatusCode.fromValue(403), ) @@ -142,7 +148,7 @@ class OfrepProviderTest { runTest { val mockEngine = mockEngineWithOneResponse( - getResourceAsString("ofrep/valid_api_response.json"), + VALID_API_RESPONSE_PAYLOAD, status = HttpStatusCode.fromValue(429), additionalHeaders = headersOf(HttpHeaders.RetryAfter, "3"), ) @@ -174,7 +180,7 @@ class OfrepProviderTest { fun `should be in Error status if error targeting key is empty`(): Unit = runTest { val mockEngine = - mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) var providerErrorReceived = false @@ -205,7 +211,7 @@ class OfrepProviderTest { fun `should be in Error status if error targeting key is missing`(): Unit = runTest { val mockEngine = - mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) var providerErrorReceived = false @@ -237,7 +243,7 @@ class OfrepProviderTest { runTest { val mockEngine = mockEngineWithOneResponse( - getResourceAsString("ofrep/invalid_context.json"), + INVALID_CONTEXT_PAYLOAD, status = HttpStatusCode.fromValue(400), ) val provider = createOfrepProvider(mockEngine) @@ -267,7 +273,7 @@ class OfrepProviderTest { runTest { val mockEngine = mockEngineWithOneResponse( - getResourceAsString("ofrep/parse_error.json"), + PARSE_ERROR_PAYLOAD, status = HttpStatusCode.fromValue(400), ) @@ -297,7 +303,7 @@ class OfrepProviderTest { fun `should return a flag not found error if the flag does not exist`(): Unit = runTest { val mockEngine = - mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> val got = client.getBooleanDetails("non-existent-flag", false) @@ -319,7 +325,7 @@ class OfrepProviderTest { runTest { val mockEngine = mockEngineWithOneResponse( - getResourceAsString("ofrep/valid_api_short_response.json"), + VALID_API_SHORT_RESPONSE_PAYLOAD, ) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> @@ -348,7 +354,7 @@ class OfrepProviderTest { runTest { val mockEngine = mockEngineWithOneResponse( - getResourceAsString("ofrep/valid_1_flag_in_parse_error.json"), + VALID_1_FLAG_IN_PARSE_ERROR_PAYLOAD, ) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> @@ -371,8 +377,8 @@ class OfrepProviderTest { runTest { val mockEngine = mockEngineWithTwoResponses( - firstContent = getResourceAsString("ofrep/valid_api_response.json"), - secondContent = getResourceAsString("ofrep/valid_api_response_2.json"), + firstContent = VALID_API_RESPONSE_PAYLOAD, + secondContent = VALID_API_RESPONSE2_PAYLOAD, ) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> @@ -432,7 +438,7 @@ class OfrepProviderTest { fun `should return a valid evaluation for Boolean`(): Unit = runTest { val mockEngine = - mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> val got = client.getBooleanDetails("bool-flag", false) @@ -460,7 +466,7 @@ class OfrepProviderTest { fun `should return a valid evaluation for Int`(): Unit = runTest { val mockEngine = - mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> val got = client.getIntegerDetails("int-flag", 1) @@ -488,7 +494,7 @@ class OfrepProviderTest { fun `should return a valid evaluation for Double`(): Unit = runTest { val mockEngine = - mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> val got = client.getDoubleDetails("double-flag", 1.1) @@ -516,7 +522,7 @@ class OfrepProviderTest { fun `should return a valid evaluation for String`(): Unit = runTest { val mockEngine = - mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> val got = client.getStringDetails("string-flag", "default") @@ -544,7 +550,7 @@ class OfrepProviderTest { fun `should return a valid evaluation for List`(): Unit = runTest { val mockEngine = - mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> val got = @@ -577,7 +583,7 @@ class OfrepProviderTest { fun `should return a valid evaluation for Map`(): Unit = runTest { val mockEngine = - mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> val got = @@ -624,7 +630,7 @@ class OfrepProviderTest { fun `should return TypeMismatch Bool`(): Unit = runTest { val mockEngine = - mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> val got = client.getBooleanDetails("object-flag", false) @@ -647,7 +653,7 @@ class OfrepProviderTest { fun `should return TypeMismatch String`(): Unit = runTest { val mockEngine = - mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> val got = client.getStringDetails("object-flag", "default") @@ -670,7 +676,7 @@ class OfrepProviderTest { fun `should return TypeMismatch Double`(): Unit = runTest { val mockEngine = - mockEngineWithOneResponse(getResourceAsString("ofrep/valid_api_response.json")) + mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> val got = client.getDoubleDetails("object-flag", 1.233) @@ -697,8 +703,8 @@ class OfrepProviderTest { ) { val mockEngine = mockEngineWithTwoResponses( - firstContent = getResourceAsString("ofrep/valid_api_short_response.json"), - secondContent = getResourceAsString("ofrep/valid_api_response_2.json"), + firstContent = VALID_API_SHORT_RESPONSE_PAYLOAD, + secondContent = VALID_API_RESPONSE2_PAYLOAD, ) val provider = createOfrepProvider(mockEngine) diff --git a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt index 385866e..1353187 100644 --- a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt @@ -1,11 +1,13 @@ package dev.openfeature.kotlin.contrib.providers.ofrep.controller +import INVALID_API_RESPONSE_PAYLOAD +import VALID_API_RESPONSE_PAYLOAD +import VALID_API_SHORT_RESPONSE_PAYLOAD import dev.openfeature.kotlin.contrib.providers.ofrep.FAKE_ENDPOINT import dev.openfeature.kotlin.contrib.providers.ofrep.bean.FlagDto import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepApiResponse import dev.openfeature.kotlin.contrib.providers.ofrep.bean.OfrepOptions import dev.openfeature.kotlin.contrib.providers.ofrep.error.OfrepError -import dev.openfeature.kotlin.contrib.providers.ofrep.getResourceAsString import dev.openfeature.kotlin.contrib.providers.ofrep.mockEngineWithOneResponse import dev.openfeature.kotlin.contrib.providers.ofrep.mockEngineWithTwoResponses import dev.openfeature.kotlin.sdk.EvaluationMetadata @@ -34,8 +36,7 @@ class OfrepApiTest { @Test fun shouldReturnAValidEvaluationResponse() = runTest { - val jsonString = getResourceAsString("ofrep/valid_api_short_response.json") - val mockEngine = mockEngineWithOneResponse(content = jsonString) + val mockEngine = mockEngineWithOneResponse(content = VALID_API_SHORT_RESPONSE_PAYLOAD) val ofrepApi = createOfrepApi(mockEngine) @@ -219,11 +220,9 @@ class OfrepApiTest { @Test fun shouldThrowUnmarshallErrorWithInvalidJson(): Unit = runTest { - val jsonString = getResourceAsString("ofrep/invalid_api_response.json") - val mockEngine = mockEngineWithOneResponse( - content = jsonString, + content = INVALID_API_RESPONSE_PAYLOAD, status = HttpStatusCode.fromValue(400), ) val ofrepApi = createOfrepApi(mockEngine) @@ -245,10 +244,9 @@ class OfrepApiTest { @Test fun shouldETagShouldNotMatch(): Unit = runTest { - val jsonString = getResourceAsString("ofrep/valid_api_response.json") val mockEngine = mockEngineWithTwoResponses( - firstContent = jsonString, + firstContent = VALID_API_RESPONSE_PAYLOAD, firstStatus = HttpStatusCode.fromValue(200), firstAdditionalHeaders = headersOf(HttpHeaders.ETag, "123"), secondContent = "", @@ -269,10 +267,9 @@ class OfrepApiTest { @Test fun shouldHaveIfNoneNullInTheHeaders(): Unit = runTest { - val jsonString = getResourceAsString("ofrep/valid_api_response.json") val mockEngine = mockEngineWithTwoResponses( - firstContent = jsonString, + firstContent = VALID_API_RESPONSE_PAYLOAD, firstStatus = HttpStatusCode.fromValue(200), firstAdditionalHeaders = headersOf(HttpHeaders.ETag, "123"), secondContent = "", diff --git a/providers/ofrep/src/commonTest/resources/ofrep/invalid_api_response.json b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/InvalidApiResponsePayload.kt similarity index 89% rename from providers/ofrep/src/commonTest/resources/ofrep/invalid_api_response.json rename to providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/InvalidApiResponsePayload.kt index 33608b9..a2a31b6 100644 --- a/providers/ofrep/src/commonTest/resources/ofrep/invalid_api_response.json +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/InvalidApiResponsePayload.kt @@ -1,3 +1,4 @@ +internal const val INVALID_API_RESPONSE_PAYLOAD = """ { "flags": [ { @@ -22,4 +23,5 @@ "title": "Feature Flag Title" } } -} \ No newline at end of file +} +""" diff --git a/providers/ofrep/src/commonTest/resources/ofrep/invalid_context.json b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/InvalidContextPayload.kt similarity index 62% rename from providers/ofrep/src/commonTest/resources/ofrep/invalid_context.json rename to providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/InvalidContextPayload.kt index c48d7cd..69bb602 100644 --- a/providers/ofrep/src/commonTest/resources/ofrep/invalid_context.json +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/InvalidContextPayload.kt @@ -1,4 +1,6 @@ +internal const val INVALID_CONTEXT_PAYLOAD = """ { "errorCode": "INVALID_CONTEXT", "errorDetails": "Error details about INVALID_CONTEXT" -} \ No newline at end of file +} +""" diff --git a/providers/ofrep/src/commonTest/resources/ofrep/parse_error.json b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/ParseErrorPayload.kt similarity index 62% rename from providers/ofrep/src/commonTest/resources/ofrep/parse_error.json rename to providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/ParseErrorPayload.kt index 5c82ae4..0084dd3 100644 --- a/providers/ofrep/src/commonTest/resources/ofrep/parse_error.json +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/ParseErrorPayload.kt @@ -1,4 +1,6 @@ +internal const val PARSE_ERROR_PAYLOAD = """ { "errorCode": "PARSE_ERROR", "errorDetails": "Error details about PARSE_ERROR" -} \ No newline at end of file +} +""" diff --git a/providers/ofrep/src/commonTest/resources/ofrep/valid_1_flag_in_parse_error.json b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/Valid1FlagInParseErrorPayload.kt similarity index 85% rename from providers/ofrep/src/commonTest/resources/ofrep/valid_1_flag_in_parse_error.json rename to providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/Valid1FlagInParseErrorPayload.kt index 2c06120..e3a4e83 100644 --- a/providers/ofrep/src/commonTest/resources/ofrep/valid_1_flag_in_parse_error.json +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/Valid1FlagInParseErrorPayload.kt @@ -1,3 +1,4 @@ +internal const val VALID_1_FLAG_IN_PARSE_ERROR_PAYLOAD = """ { "flags": [ { @@ -17,4 +18,5 @@ "errorDetails": "Error details about PARSE_ERROR" } ] -} \ No newline at end of file +} +""" diff --git a/providers/ofrep/src/commonTest/resources/ofrep/valid_api_response_2.json b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/ValidApiResponse2Payload.kt similarity index 97% rename from providers/ofrep/src/commonTest/resources/ofrep/valid_api_response_2.json rename to providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/ValidApiResponse2Payload.kt index 87089dd..b662bdb 100644 --- a/providers/ofrep/src/commonTest/resources/ofrep/valid_api_response_2.json +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/ValidApiResponse2Payload.kt @@ -1,3 +1,4 @@ +internal const val VALID_API_RESPONSE2_PAYLOAD = """ { "flags": [ { @@ -107,4 +108,5 @@ } } ] -} \ No newline at end of file +} +""" diff --git a/providers/ofrep/src/commonTest/resources/ofrep/valid_api_response.json b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/ValidApiResponsePayload.kt similarity index 97% rename from providers/ofrep/src/commonTest/resources/ofrep/valid_api_response.json rename to providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/ValidApiResponsePayload.kt index 52d48f2..72a205c 100644 --- a/providers/ofrep/src/commonTest/resources/ofrep/valid_api_response.json +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/ValidApiResponsePayload.kt @@ -1,3 +1,4 @@ +internal const val VALID_API_RESPONSE_PAYLOAD = """ { "flags": [ { @@ -107,4 +108,5 @@ } } ] -} \ No newline at end of file +} +""" diff --git a/providers/ofrep/src/commonTest/resources/ofrep/valid_api_short_response.json b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/ValidApiShortResponsePayload.kt similarity index 89% rename from providers/ofrep/src/commonTest/resources/ofrep/valid_api_short_response.json rename to providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/ValidApiShortResponsePayload.kt index 316ced0..406e9cc 100644 --- a/providers/ofrep/src/commonTest/resources/ofrep/valid_api_short_response.json +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/payloads/ValidApiShortResponsePayload.kt @@ -1,3 +1,4 @@ +internal const val VALID_API_SHORT_RESPONSE_PAYLOAD = """ { "flags": [ { @@ -23,4 +24,5 @@ } } ] -} \ No newline at end of file +} +""" From b1db37720895a2f0712548a3171fb7aad980cd6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Horn=C3=A1k?= Date: Mon, 23 Jun 2025 09:10:48 +0200 Subject: [PATCH 21/21] Target multiple platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bence Hornák --- kotlin-js-store/yarn.lock | 1569 ++++++++++++++++- providers/ofrep/README.md | 17 +- providers/ofrep/build.gradle.kts | 13 + .../contrib/providers/ofrep/OfrepProvider.kt | 1 + .../providers/ofrep/OfrepProviderTest.kt | 139 +- .../ofrep/controller/OfrepApiTest.kt | 22 +- 6 files changed, 1666 insertions(+), 95 deletions(-) diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock index f4373ae..109f4ed 100644 --- a/kotlin-js-store/yarn.lock +++ b/kotlin-js-store/yarn.lock @@ -2,6 +2,288 @@ # yarn lockfile v1 +"@colors/colors@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" + integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== + +"@discoveryjs/json-ext@^0.5.0": + version "0.5.7" + resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" + integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== + +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.8" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz#4f0e06362e01362f823d348f1872b08f666d8142" + integrity sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA== + dependencies: + "@jridgewell/set-array" "^1.2.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/set-array@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" + integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== + +"@jridgewell/source-map@^0.3.3": + version "0.3.6" + resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.6.tgz#9d71ca886e32502eb9362c9a74a46787c36df81a" + integrity sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": + version "0.3.25" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@socket.io/component-emitter@~3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2" + integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA== + +"@types/cors@^2.8.12": + version "2.8.19" + resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.19.tgz#d93ea2673fd8c9f697367f5eeefc2bbfa94f0342" + integrity sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg== + dependencies: + "@types/node" "*" + +"@types/estree@^1.0.5": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +"@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/node@*", "@types/node@>=10.0.0": + version "24.0.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-24.0.3.tgz#f935910f3eece3a3a2f8be86b96ba833dc286cab" + integrity sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg== + dependencies: + undici-types "~7.8.0" + +"@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.12.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.14.1.tgz#a9f6a07f2b03c95c8d38c4536a1fdfb521ff55b6" + integrity sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ== + dependencies: + "@webassemblyjs/helper-numbers" "1.13.2" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + +"@webassemblyjs/floating-point-hex-parser@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz#fcca1eeddb1cc4e7b6eed4fc7956d6813b21b9fb" + integrity sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA== + +"@webassemblyjs/helper-api-error@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz#e0a16152248bc38daee76dd7e21f15c5ef3ab1e7" + integrity sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ== + +"@webassemblyjs/helper-buffer@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz#822a9bc603166531f7d5df84e67b5bf99b72b96b" + integrity sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA== + +"@webassemblyjs/helper-numbers@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz#dbd932548e7119f4b8a7877fd5a8d20e63490b2d" + integrity sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA== + dependencies: + "@webassemblyjs/floating-point-hex-parser" "1.13.2" + "@webassemblyjs/helper-api-error" "1.13.2" + "@xtuc/long" "4.2.2" + +"@webassemblyjs/helper-wasm-bytecode@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz#e556108758f448aae84c850e593ce18a0eb31e0b" + integrity sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA== + +"@webassemblyjs/helper-wasm-section@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz#9629dda9c4430eab54b591053d6dc6f3ba050348" + integrity sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/wasm-gen" "1.14.1" + +"@webassemblyjs/ieee754@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz#1c5eaace1d606ada2c7fd7045ea9356c59ee0dba" + integrity sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw== + dependencies: + "@xtuc/ieee754" "^1.2.0" + +"@webassemblyjs/leb128@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.13.2.tgz#57c5c3deb0105d02ce25fa3fd74f4ebc9fd0bbb0" + integrity sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw== + dependencies: + "@xtuc/long" "4.2.2" + +"@webassemblyjs/utf8@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.13.2.tgz#917a20e93f71ad5602966c2d685ae0c6c21f60f1" + integrity sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ== + +"@webassemblyjs/wasm-edit@^1.12.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz#ac6689f502219b59198ddec42dcd496b1004d597" + integrity sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/helper-wasm-section" "1.14.1" + "@webassemblyjs/wasm-gen" "1.14.1" + "@webassemblyjs/wasm-opt" "1.14.1" + "@webassemblyjs/wasm-parser" "1.14.1" + "@webassemblyjs/wast-printer" "1.14.1" + +"@webassemblyjs/wasm-gen@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz#991e7f0c090cb0bb62bbac882076e3d219da9570" + integrity sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/ieee754" "1.13.2" + "@webassemblyjs/leb128" "1.13.2" + "@webassemblyjs/utf8" "1.13.2" + +"@webassemblyjs/wasm-opt@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz#e6f71ed7ccae46781c206017d3c14c50efa8106b" + integrity sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/wasm-gen" "1.14.1" + "@webassemblyjs/wasm-parser" "1.14.1" + +"@webassemblyjs/wasm-parser@1.14.1", "@webassemblyjs/wasm-parser@^1.12.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz#b3e13f1893605ca78b52c68e54cf6a865f90b9fb" + integrity sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-api-error" "1.13.2" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/ieee754" "1.13.2" + "@webassemblyjs/leb128" "1.13.2" + "@webassemblyjs/utf8" "1.13.2" + +"@webassemblyjs/wast-printer@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz#3bb3e9638a8ae5fdaf9610e7a06b4d9f9aa6fe07" + integrity sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@xtuc/long" "4.2.2" + +"@webpack-cli/configtest@^2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-2.1.1.tgz#3b2f852e91dac6e3b85fb2a314fb8bef46d94646" + integrity sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw== + +"@webpack-cli/info@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@webpack-cli/info/-/info-2.0.2.tgz#cc3fbf22efeb88ff62310cf885c5b09f44ae0fdd" + integrity sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A== + +"@webpack-cli/serve@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-2.0.5.tgz#325db42395cd49fe6c14057f9a900e427df8810e" + integrity sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ== + +"@xtuc/ieee754@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" + integrity sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA== + +"@xtuc/long@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" + integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== + +accepts@~1.3.4: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + +acorn-import-attributes@^1.9.5: + version "1.9.5" + resolved "https://registry.yarnpkg.com/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz#7eb1557b1ba05ef18b5ed0ec67591bfab04688ef" + integrity sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ== + +acorn@^8.14.0, acorn@^8.7.1: + version "8.15.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== + +ajv-formats@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" + integrity sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA== + dependencies: + ajv "^8.0.0" + +ajv-keywords@^3.5.2: + version "3.5.2" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" + integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== + +ajv-keywords@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-5.1.0.tgz#69d4d385a4733cdbeab44964a1170a88f87f0e16" + integrity sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw== + dependencies: + fast-deep-equal "^3.1.3" + +ajv@^6.12.5: + version "6.12.6" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ajv@^8.0.0, ajv@^8.9.0: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" + integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + ansi-colors@^4.1.3: version "4.1.3" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" @@ -37,11 +319,42 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +base64id@2.0.0, base64id@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6" + integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog== + binary-extensions@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== +body-parser@^1.19.0: + version "1.20.3" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.3.tgz#1953431221c6fb5cd63c4b36d53fab0928e548c6" + integrity sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g== + dependencies: + bytes "3.1.2" + content-type "~1.0.5" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.13.0" + raw-body "2.5.2" + type-is "~1.6.18" + unpipe "1.0.0" + +brace-expansion@^1.1.7: + version "1.1.12" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" + integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + brace-expansion@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" @@ -49,7 +362,7 @@ brace-expansion@^2.0.1: dependencies: balanced-match "^1.0.0" -braces@~3.0.2: +braces@^3.0.2, braces@~3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== @@ -61,16 +374,52 @@ browser-stdout@^1.3.1: resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== +browserslist@^4.21.10: + version "4.25.0" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.25.0.tgz#986aa9c6d87916885da2b50d8eb577ac8d133b2c" + integrity sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA== + dependencies: + caniuse-lite "^1.0.30001718" + electron-to-chromium "^1.5.160" + node-releases "^2.0.19" + update-browserslist-db "^1.1.3" + buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +call-bound@^1.0.2, call-bound@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + camelcase@^6.0.0: version "6.3.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== +caniuse-lite@^1.0.30001718: + version "1.0.30001724" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001724.tgz#312e163553dd70d2c0fb603d74810c85d8ed94a0" + integrity sha512-WqJo7p0TbHDOythNTqYujmaJTvtYRZrjpP8TCvH6Vb9CYJerJNKamKzIWOM4BkQatWj9H2lYulpdAQNBe7QhNA== + chalk@^4.1.0: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" @@ -79,7 +428,7 @@ chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -chokidar@^3.5.3: +chokidar@^3.5.1, chokidar@^3.5.3: version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== @@ -94,6 +443,11 @@ chokidar@^3.5.3: optionalDependencies: fsevents "~2.3.2" +chrome-trace-event@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz#05bffd7ff928465093314708c93bdfa9bd1f0f5b" + integrity sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ== + cliui@^7.0.2: version "7.0.4" resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" @@ -103,6 +457,15 @@ cliui@^7.0.2: strip-ansi "^6.0.0" wrap-ansi "^7.0.0" +clone-deep@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" + integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ== + dependencies: + is-plain-object "^2.0.4" + kind-of "^6.0.2" + shallow-clone "^3.0.0" + color-convert@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" @@ -115,38 +478,298 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -debug@^4.3.5: +colorette@^2.0.14: + version "2.0.20" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" + integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== + +commander@^10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" + integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== + +commander@^2.20.0: + version "2.20.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +connect@^3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/connect/-/connect-3.7.0.tgz#5d49348910caa5e07a01800b030d0c35f20484f8" + integrity sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ== + dependencies: + debug "2.6.9" + finalhandler "1.1.2" + parseurl "~1.3.3" + utils-merge "1.0.1" + +content-type@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +cookie@~0.7.2: + version "0.7.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== + +cors@~2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + +cross-spawn@^7.0.3: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +custom-event@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425" + integrity sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg== + +date-format@^4.0.14: + version "4.0.14" + resolved "https://registry.yarnpkg.com/date-format/-/date-format-4.0.14.tgz#7a8e584434fb169a521c8b7aa481f355810d9400" + integrity sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg== + +debug@2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@^4.3.4, debug@^4.3.5: version "4.4.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== dependencies: ms "^2.1.3" +debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + decamelize@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== +depd@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +destroy@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + +di@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/di/-/di-0.0.1.tgz#806649326ceaa7caa3306d75d985ea2748ba913c" + integrity sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA== + diff@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== +dom-serialize@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/dom-serialize/-/dom-serialize-2.2.1.tgz#562ae8999f44be5ea3076f5419dcd59eb43ac95b" + integrity sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ== + dependencies: + custom-event "~1.0.0" + ent "~2.2.0" + extend "^3.0.0" + void-elements "^2.0.0" + +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + +electron-to-chromium@^1.5.160: + version "1.5.171" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.171.tgz#e552b4fd73d4dd941ee4c70ae288a8a39f818726" + integrity sha512-scWpzXEJEMrGJa4Y6m/tVotb0WuvNmasv3wWVzUAeCgKU0ToFOhUW6Z+xWnRQANMYGxN4ngJXIThgBJOqzVPCQ== + emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== -escalade@^3.1.1: +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== + +engine.io-parser@~5.2.1: + version "5.2.3" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz#00dc5b97b1f233a23c9398d0209504cf5f94d92f" + integrity sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q== + +engine.io@~6.6.0: + version "6.6.4" + resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.6.4.tgz#0a89a3e6b6c1d4b0c2a2a637495e7c149ec8d8ee" + integrity sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g== + dependencies: + "@types/cors" "^2.8.12" + "@types/node" ">=10.0.0" + accepts "~1.3.4" + base64id "2.0.0" + cookie "~0.7.2" + cors "~2.8.5" + debug "~4.3.1" + engine.io-parser "~5.2.1" + ws "~8.17.1" + +enhanced-resolve@^5.17.1: + version "5.18.1" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz#728ab082f8b7b6836de51f1637aab5d3b9568faf" + integrity sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.2.0" + +ent@~2.2.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.2.tgz#22a5ed2fd7ce0cbcff1d1474cf4909a44bdb6e85" + integrity sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + punycode "^1.4.1" + safe-regex-test "^1.1.0" + +envinfo@^7.7.3: + version "7.14.0" + resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.14.0.tgz#26dac5db54418f2a4c1159153a0b2ae980838aae" + integrity sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg== + +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-module-lexer@^1.2.1: + version "1.7.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" + integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== + +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + +escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== +escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + escape-string-regexp@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== +eslint-scope@5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" + integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== + dependencies: + esrecurse "^4.3.0" + estraverse "^4.1.1" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^4.1.1: + version "4.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" + integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== + +estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +eventemitter3@^4.0.0: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + +events@^3.2.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + +extend@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-uri@^3.0.1: + version "3.0.6" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.6.tgz#88f130b77cfaea2378d56bf970dea21257a68748" + integrity sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw== + +fastest-levenshtein@^1.0.12: + version "1.0.16" + resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" + integrity sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg== + fill-range@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" @@ -154,6 +777,27 @@ fill-range@^7.1.1: dependencies: to-regex-range "^5.0.1" +finalhandler@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" + integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "~2.3.0" + parseurl "~1.3.3" + statuses "~1.5.0" + unpipe "~1.0.0" + +find-up@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + find-up@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" @@ -167,11 +811,30 @@ flat@^5.0.2: resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== +flatted@^3.2.7: + version "3.3.3" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" + integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== + +follow-redirects@^1.0.0: + version "1.15.9" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" + integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== + format-util@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/format-util/-/format-util-1.0.5.tgz#1ffb450c8a03e7bccffe40643180918cc297d271" integrity sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg== +fs-extra@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" + integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^4.0.0" + universalify "^0.1.0" + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -182,11 +845,40 @@ fsevents@~2.3.2: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + get-caller-file@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== +get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" @@ -194,6 +886,23 @@ glob-parent@~5.1.2: dependencies: is-glob "^4.0.1" +glob-to-regexp@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" + integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== + +glob@^7.1.3, glob@^7.1.7: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + glob@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" @@ -205,16 +914,87 @@ glob@^8.1.0: minimatch "^5.0.1" once "^1.3.0" +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + +graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.10, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.6: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + has-flag@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== +has-symbols@^1.0.3, has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + he@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + +http-proxy@^1.18.1: + version "1.18.1" + resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" + integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== + dependencies: + eventemitter3 "^4.0.0" + follow-redirects "^1.0.0" + requires-port "^1.0.0" + +iconv-lite@0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +iconv-lite@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +import-local@^3.0.2: + version "3.2.0" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.2.0.tgz#c3d5c745798c02a6f8b897726aba5100186ee260" + integrity sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA== + dependencies: + pkg-dir "^4.2.0" + resolve-cwd "^3.0.0" + inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" @@ -223,11 +1003,16 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2: +inherits@2, inherits@2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +interpret@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-3.1.1.tgz#5be0ceed67ca79c6c4bc5cf0d7ee843dcea110c4" + integrity sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ== + is-binary-path@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" @@ -235,6 +1020,13 @@ is-binary-path@~2.1.0: dependencies: binary-extensions "^2.0.0" +is-core-module@^2.16.0: + version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + dependencies: + hasown "^2.0.2" + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -262,11 +1054,52 @@ is-plain-obj@^2.1.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== +is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== + dependencies: + isobject "^3.0.1" + +is-regex@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" + integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== + dependencies: + call-bound "^1.0.2" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + is-unicode-supported@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== +isbinaryfile@^4.0.8: + version "4.0.10" + resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz#0c5b5e30c2557a2f06febd37b7322946aaee42b3" + integrity sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== + +jest-worker@^27.4.5: + version "27.5.1" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.5.1.tgz#8d146f0900e8973b106b6f73cc1e9a8cb86f8db0" + integrity sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg== + dependencies: + "@types/node" "*" + merge-stream "^2.0.0" + supports-color "^8.0.0" + js-yaml@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" @@ -274,6 +1107,93 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +json-parse-even-better-errors@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + +jsonfile@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" + integrity sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg== + optionalDependencies: + graceful-fs "^4.1.6" + +karma-chrome-launcher@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-3.2.0.tgz#eb9c95024f2d6dfbb3748d3415ac9b381906b9a9" + integrity sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q== + dependencies: + which "^1.2.1" + +karma-mocha@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/karma-mocha/-/karma-mocha-2.0.1.tgz#4b0254a18dfee71bdbe6188d9a6861bf86b0cd7d" + integrity sha512-Tzd5HBjm8his2OA4bouAsATYEpZrp9vC7z5E5j4C5Of5Rrs1jY67RAwXNcVmd/Bnk1wgvQRou0zGVLey44G4tQ== + dependencies: + minimist "^1.2.3" + +karma-sourcemap-loader@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/karma-sourcemap-loader/-/karma-sourcemap-loader-0.4.0.tgz#b01d73f8f688f533bcc8f5d273d43458e13b5488" + integrity sha512-xCRL3/pmhAYF3I6qOrcn0uhbQevitc2DERMPH82FMnG+4WReoGcGFZb1pURf2a5apyrOHRdvD+O6K7NljqKHyA== + dependencies: + graceful-fs "^4.2.10" + +karma-webpack@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/karma-webpack/-/karma-webpack-5.0.1.tgz#4eafd31bbe684a747a6e8f3e4ad373e53979ced4" + integrity sha512-oo38O+P3W2mSPCSUrQdySSPv1LvPpXP+f+bBimNomS5sW+1V4SuhCuW8TfJzV+rDv921w2fDSDw0xJbPe6U+kQ== + dependencies: + glob "^7.1.3" + minimatch "^9.0.3" + webpack-merge "^4.1.5" + +karma@6.4.4: + version "6.4.4" + resolved "https://registry.yarnpkg.com/karma/-/karma-6.4.4.tgz#dfa5a426cf5a8b53b43cd54ef0d0d09742351492" + integrity sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w== + dependencies: + "@colors/colors" "1.5.0" + body-parser "^1.19.0" + braces "^3.0.2" + chokidar "^3.5.1" + connect "^3.7.0" + di "^0.0.1" + dom-serialize "^2.2.1" + glob "^7.1.7" + graceful-fs "^4.2.6" + http-proxy "^1.18.1" + isbinaryfile "^4.0.8" + lodash "^4.17.21" + log4js "^6.4.1" + mime "^2.5.2" + minimatch "^3.0.4" + mkdirp "^0.5.5" + qjobs "^1.2.0" + range-parser "^1.2.1" + rimraf "^3.0.2" + socket.io "^4.7.2" + source-map "^0.6.1" + tmp "^0.2.1" + ua-parser-js "^0.7.30" + yargs "^16.1.1" + +kind-of@^6.0.2: + version "6.0.3" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" + integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== + kotlin-web-helpers@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/kotlin-web-helpers/-/kotlin-web-helpers-2.0.0.tgz#b112096b273c1e733e0b86560998235c09a19286" @@ -281,6 +1201,18 @@ kotlin-web-helpers@2.0.0: dependencies: format-util "^1.0.5" +loader-runner@^4.2.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" + integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== + +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + locate-path@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" @@ -288,6 +1220,11 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash@^4.17.15, lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + log-symbols@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" @@ -296,6 +1233,56 @@ log-symbols@^4.1.0: chalk "^4.1.0" is-unicode-supported "^0.1.0" +log4js@^6.4.1: + version "6.9.1" + resolved "https://registry.yarnpkg.com/log4js/-/log4js-6.9.1.tgz#aba5a3ff4e7872ae34f8b4c533706753709e38b6" + integrity sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g== + dependencies: + date-format "^4.0.14" + debug "^4.3.4" + flatted "^3.2.7" + rfdc "^1.3.0" + streamroller "^3.1.5" + +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.27, mime-types@~2.1.24, mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mime@^2.5.2: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" + integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + +minimatch@^3.0.4, minimatch@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + minimatch@^5.0.1, minimatch@^5.1.6: version "5.1.6" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" @@ -303,6 +1290,25 @@ minimatch@^5.0.1, minimatch@^5.1.6: dependencies: brace-expansion "^2.0.1" +minimatch@^9.0.3: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +minimist@^1.2.3, minimist@^1.2.6: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +mkdirp@^0.5.5: + version "0.5.6" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" + integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== + dependencies: + minimist "^1.2.6" + mocha@10.7.3: version "10.7.3" resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.7.3.tgz#ae32003cabbd52b59aece17846056a68eb4b0752" @@ -329,16 +1335,60 @@ mocha@10.7.3: yargs-parser "^20.2.9" yargs-unparser "^2.0.0" +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +neo-async@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + +node-releases@^2.0.19: + version "2.0.19" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314" + integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== + normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== +object-assign@^4: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-inspect@^1.13.3: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + +on-finished@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +on-finished@~2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" + integrity sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww== + dependencies: + ee-first "1.1.1" + once@^1.3.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -346,6 +1396,13 @@ once@^1.3.0: dependencies: wrappy "1" +p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + p-limit@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" @@ -353,6 +1410,13 @@ p-limit@^3.0.2: dependencies: yocto-queue "^0.1.0" +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + p-locate@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" @@ -360,16 +1424,75 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + picomatch@^2.0.4, picomatch@^2.2.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +pkg-dir@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + dependencies: + find-up "^4.0.0" + +punycode@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + integrity sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ== + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +qjobs@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.2.0.tgz#c45e9c61800bd087ef88d7e256423bdd49e5d071" + integrity sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg== + +qs@6.13.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" + integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== + dependencies: + side-channel "^1.0.6" + randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -377,6 +1500,21 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" +range-parser@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" + integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -384,16 +1522,99 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +rechoir@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.8.0.tgz#49f866e0d32146142da3ad8f0eff352b3215ff22" + integrity sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ== + dependencies: + resolve "^1.20.0" + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + +resolve-cwd@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" + integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== + dependencies: + resolve-from "^5.0.0" + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +resolve@^1.20.0: + version "1.22.10" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" + integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== + dependencies: + is-core-module "^2.16.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +rfdc@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca" + integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== + +rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + safe-buffer@^5.1.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +safe-regex-test@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" + integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-regex "^1.2.1" + +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +schema-utils@^3.2.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe" + integrity sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg== + dependencies: + "@types/json-schema" "^7.0.8" + ajv "^6.12.5" + ajv-keywords "^3.5.2" + +schema-utils@^4.3.0: + version "4.3.2" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.3.2.tgz#0c10878bf4a73fd2b1dfd14b9462b26788c806ae" + integrity sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ== + dependencies: + "@types/json-schema" "^7.0.9" + ajv "^8.9.0" + ajv-formats "^2.1.1" + ajv-keywords "^5.1.0" + serialize-javascript@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" @@ -401,7 +1622,113 @@ serialize-javascript@^6.0.2: dependencies: randombytes "^2.1.0" -source-map-support@0.5.21: +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +shallow-clone@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" + integrity sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA== + dependencies: + kind-of "^6.0.2" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + +side-channel@^1.0.6: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + +socket.io-adapter@~2.5.2: + version "2.5.5" + resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz#c7a1f9c703d7756844751b6ff9abfc1780664082" + integrity sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg== + dependencies: + debug "~4.3.4" + ws "~8.17.1" + +socket.io-parser@~4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz#c806966cf7270601e47469ddeec30fbdfda44c83" + integrity sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + +socket.io@^4.7.2: + version "4.8.1" + resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.8.1.tgz#fa0eaff965cc97fdf4245e8d4794618459f7558a" + integrity sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg== + dependencies: + accepts "~1.3.4" + base64id "~2.0.0" + cors "~2.8.5" + debug "~4.3.2" + engine.io "~6.6.0" + socket.io-adapter "~2.5.2" + socket.io-parser "~4.2.4" + +source-map-js@^1.0.2: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +source-map-loader@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/source-map-loader/-/source-map-loader-5.0.0.tgz#f593a916e1cc54471cfc8851b905c8a845fc7e38" + integrity sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA== + dependencies: + iconv-lite "^0.6.3" + source-map-js "^1.0.2" + +source-map-support@0.5.21, source-map-support@~0.5.20: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== @@ -409,11 +1736,30 @@ source-map-support@0.5.21: buffer-from "^1.0.0" source-map "^0.6.0" -source-map@^0.6.0: +source-map@^0.6.0, source-map@^0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + +statuses@~1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" + integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== + +streamroller@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-3.1.5.tgz#1263182329a45def1ffaef58d31b15d13d2ee7ff" + integrity sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw== + dependencies: + date-format "^4.0.14" + debug "^4.3.4" + fs-extra "^8.1.0" + string-width@^4.1.0, string-width@^4.2.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -442,13 +1788,49 @@ supports-color@^7.1.0: dependencies: has-flag "^4.0.0" -supports-color@^8.1.1: +supports-color@^8.0.0, supports-color@^8.1.1: version "8.1.1" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== dependencies: has-flag "^4.0.0" +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +tapable@^2.1.1, tapable@^2.2.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.2.tgz#ab4984340d30cb9989a490032f086dbb8b56d872" + integrity sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg== + +terser-webpack-plugin@^5.3.10: + version "5.3.14" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz#9031d48e57ab27567f02ace85c7d690db66c3e06" + integrity sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw== + dependencies: + "@jridgewell/trace-mapping" "^0.3.25" + jest-worker "^27.4.5" + schema-utils "^4.3.0" + serialize-javascript "^6.0.2" + terser "^5.31.1" + +terser@^5.31.1: + version "5.43.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.43.1.tgz#88387f4f9794ff1a29e7ad61fb2932e25b4fdb6d" + integrity sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg== + dependencies: + "@jridgewell/source-map" "^0.3.3" + acorn "^8.14.0" + commander "^2.20.0" + source-map-support "~0.5.20" + +tmp@^0.2.1: + version "0.2.3" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae" + integrity sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w== + to-regex-range@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" @@ -456,11 +1838,170 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + typescript@5.5.4: version "5.5.4" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba" integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q== +ua-parser-js@^0.7.30: + version "0.7.40" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.40.tgz#c87d83b7bb25822ecfa6397a0da5903934ea1562" + integrity sha512-us1E3K+3jJppDBa3Tl0L3MOJiGhe1C6P0+nIvQAFYbxlMAx0h81eOwLmU57xgqToduDDPx3y5QsdjPfDu+FgOQ== + +undici-types@~7.8.0: + version "7.8.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.8.0.tgz#de00b85b710c54122e44fbfd911f8d70174cd294" + integrity sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw== + +universalify@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" + integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== + +unpipe@1.0.0, unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +update-browserslist-db@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz#348377dd245216f9e7060ff50b15a1b740b75420" + integrity sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +utils-merge@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + +vary@^1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +void-elements@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec" + integrity sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung== + +watchpack@^2.4.1: + version "2.4.4" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.4.tgz#473bda72f0850453da6425081ea46fc0d7602947" + integrity sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA== + dependencies: + glob-to-regexp "^0.4.1" + graceful-fs "^4.1.2" + +webpack-cli@5.1.4: + version "5.1.4" + resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-5.1.4.tgz#c8e046ba7eaae4911d7e71e2b25b776fcc35759b" + integrity sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg== + dependencies: + "@discoveryjs/json-ext" "^0.5.0" + "@webpack-cli/configtest" "^2.1.1" + "@webpack-cli/info" "^2.0.2" + "@webpack-cli/serve" "^2.0.5" + colorette "^2.0.14" + commander "^10.0.1" + cross-spawn "^7.0.3" + envinfo "^7.7.3" + fastest-levenshtein "^1.0.12" + import-local "^3.0.2" + interpret "^3.1.1" + rechoir "^0.8.0" + webpack-merge "^5.7.3" + +webpack-merge@^4.1.5: + version "4.2.2" + resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-4.2.2.tgz#a27c52ea783d1398afd2087f547d7b9d2f43634d" + integrity sha512-TUE1UGoTX2Cd42j3krGYqObZbOD+xF7u28WB7tfUordytSjbWTIjK/8V0amkBfTYN4/pB/GIDlJZZ657BGG19g== + dependencies: + lodash "^4.17.15" + +webpack-merge@^5.7.3: + version "5.10.0" + resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-5.10.0.tgz#a3ad5d773241e9c682803abf628d4cd62b8a4177" + integrity sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA== + dependencies: + clone-deep "^4.0.1" + flat "^5.0.2" + wildcard "^2.0.0" + +webpack-sources@^3.2.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.3.tgz#d4bf7f9909675d7a070ff14d0ef2a4f3c982c723" + integrity sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg== + +webpack@5.94.0: + version "5.94.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.94.0.tgz#77a6089c716e7ab90c1c67574a28da518a20970f" + integrity sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg== + dependencies: + "@types/estree" "^1.0.5" + "@webassemblyjs/ast" "^1.12.1" + "@webassemblyjs/wasm-edit" "^1.12.1" + "@webassemblyjs/wasm-parser" "^1.12.1" + acorn "^8.7.1" + acorn-import-attributes "^1.9.5" + browserslist "^4.21.10" + chrome-trace-event "^1.0.2" + enhanced-resolve "^5.17.1" + es-module-lexer "^1.2.1" + eslint-scope "5.1.1" + events "^3.2.0" + glob-to-regexp "^0.4.1" + graceful-fs "^4.2.11" + json-parse-even-better-errors "^2.3.1" + loader-runner "^4.2.0" + mime-types "^2.1.27" + neo-async "^2.6.2" + schema-utils "^3.2.0" + tapable "^2.1.1" + terser-webpack-plugin "^5.3.10" + watchpack "^2.4.1" + webpack-sources "^3.2.3" + +which@^1.2.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +wildcard@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.1.tgz#5ab10d02487198954836b6349f74fff961e10f67" + integrity sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ== + workerpool@^6.5.1: version "6.5.1" resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544" @@ -480,6 +2021,16 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== +ws@8.18.0: + version "8.18.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" + integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== + +ws@~8.17.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== + y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" @@ -500,7 +2051,7 @@ yargs-unparser@^2.0.0: flat "^5.0.2" is-plain-obj "^2.1.0" -yargs@^16.2.0: +yargs@^16.1.1, yargs@^16.2.0: version "16.2.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== diff --git a/providers/ofrep/README.md b/providers/ofrep/README.md index aff159d..e83abf9 100644 --- a/providers/ofrep/README.md +++ b/providers/ofrep/README.md @@ -4,14 +4,15 @@ This provider is designed to use the [OpenFeature Remote Evaluation Protocol (OF ## Supported platforms -| Supported | Platform | Supported versions | -|-----------|----------------------|--------------------| -| ✅ | Android | SDK 21+ | -| ✅ | JVM | JDK 11+ | -| ❌ | Native | | -| ❌ | Javascript (Node.js) | | -| ❌ | Javascript (Browser) | | -| ❌ | Wasm | | +| Supported | Platform | Supported versions | +|-----------|----------------------|--------------------------------------------------------------------------------| +| ✅ | Android | | +| ✅ | JVM | JDK 11+ | +| ✅ | Native | Linux x64 | +| ❌ | Native | [Other native targets](https://kotlinlang.org/docs/native-target-support.html) | +| ✅ | Javascript (Node.js) | | +| ✅ | Javascript (Browser) | | +| ❌ | Wasm | | ## Installation diff --git a/providers/ofrep/build.gradle.kts b/providers/ofrep/build.gradle.kts index f5cfda4..2da0e27 100644 --- a/providers/ofrep/build.gradle.kts +++ b/providers/ofrep/build.gradle.kts @@ -4,6 +4,8 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlinx.serialization) alias(libs.plugins.android.library) + // Needed for the JS coroutine support for the tests + alias(libs.plugins.kotlinx.atomicfu) } kotlin { @@ -17,6 +19,17 @@ kotlin { } } } + linuxX64 {} + js { + nodejs {} + browser { + testTask { + useKarma { + useChromeHeadless() + } + } + } + } sourceSets { commonMain.dependencies { diff --git a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProvider.kt b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProvider.kt index bc025d5..96958ee 100644 --- a/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProvider.kt +++ b/providers/ofrep/src/commonMain/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProvider.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlin.concurrent.Volatile import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime diff --git a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt index 1b45398..8a358d6 100644 --- a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/OfrepProviderTest.kt @@ -24,8 +24,6 @@ import io.ktor.client.engine.mock.MockEngine import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.take @@ -38,6 +36,8 @@ import kotlinx.coroutines.test.runTest import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.uuid.ExperimentalUuidApi @@ -58,10 +58,9 @@ private fun TestScope.createOfrepProvider(mockEngine: MockEngine) = private suspend fun withClient( provider: FeatureProvider, initialContext: EvaluationContext, - dispatcher: CoroutineDispatcher, body: (client: Client) -> Unit, ) { - OpenFeatureAPI.setProviderAndWait(provider, initialContext, dispatcher) + OpenFeatureAPI.setProviderAndWait(provider, initialContext, StandardTestDispatcher()) try { val client = OpenFeatureAPI.getClient() body(client) @@ -88,7 +87,7 @@ class OfrepProviderTest { } @Test - fun `should be in Fatal status if 401 error during initialise`(): Unit = + fun `should be in Fatal status if 401 error during initialise`() = runTest { val mockEngine = mockEngineWithOneResponse( @@ -109,14 +108,14 @@ class OfrepProviderTest { } } runCurrent() - withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + withClient(provider, defaultEvalCtx) { client -> runCurrent() - assert(providerErrorReceived) { "ProviderError event was not received" } + assertTrue(providerErrorReceived, "ProviderError event was not received") } } @Test - fun `should be in Fatal status if 403 error during initialise`(): Unit = + fun `should be in Fatal status if 403 error during initialise`() = runTest { val mockEngine = mockEngineWithOneResponse( @@ -137,14 +136,14 @@ class OfrepProviderTest { } } runCurrent() - withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + withClient(provider, defaultEvalCtx) { client -> runCurrent() - assert(providerErrorReceived) { "ProviderError event was not received" } + assertTrue(providerErrorReceived, "ProviderError event was not received") } } @Test - fun `should be in Error status if 429 error during initialise`(): Unit = + fun `should be in Error status if 429 error during initialise`() = runTest { val mockEngine = mockEngineWithOneResponse( @@ -168,16 +167,20 @@ class OfrepProviderTest { } } runCurrent() - withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + withClient(provider, defaultEvalCtx) { client -> runCurrent() - assert(providerErrorReceived) { "ProviderError event was not received" } - assert(exceptionReceived is OpenFeatureError.GeneralError) { "The exception is not of type GeneralError" } - assert(exceptionReceived?.message == "Rate limited") { "The exception's message is not correct" } + assertTrue(providerErrorReceived, "ProviderError event was not received") + assertIs(exceptionReceived, "The exception is not of type GeneralError") + assertEquals( + "Rate limited", + (exceptionReceived as OpenFeatureError.GeneralError).message, + "The exception's message is not correct", + ) } } @Test - fun `should be in Error status if error targeting key is empty`(): Unit = + fun `should be in Error status if error targeting key is empty`() = runTest { val mockEngine = mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) @@ -198,17 +201,18 @@ class OfrepProviderTest { } runCurrent() val evalCtx = ImmutableContext(targetingKey = "") - withClient(provider, evalCtx, Dispatchers.IO) { client -> + withClient(provider, evalCtx) { client -> runCurrent() - assert(providerErrorReceived) { "ProviderError event was not received" } - assert( - exceptionReceived is OpenFeatureError.TargetingKeyMissingError, - ) { "The exception is not of type TargetingKeyMissingError" } + assertTrue(providerErrorReceived, "ProviderError event was not received") + assertIs( + exceptionReceived, + "The exception is not of type TargetingKeyMissingError", + ) } } @Test - fun `should be in Error status if error targeting key is missing`(): Unit = + fun `should be in Error status if error targeting key is missing`() = runTest { val mockEngine = mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) @@ -229,17 +233,18 @@ class OfrepProviderTest { } runCurrent() val evalCtx = ImmutableContext() - withClient(provider, evalCtx, Dispatchers.IO) { client -> + withClient(provider, evalCtx) { client -> runCurrent() - assert(providerErrorReceived) { "ProviderError event was not received" } - assert( - exceptionReceived is OpenFeatureError.TargetingKeyMissingError, - ) { "The exception is not of type TargetingKeyMissingError" } + assertTrue(providerErrorReceived, "ProviderError event was not received") + assertIs( + exceptionReceived, + "The exception is not of type TargetingKeyMissingError", + ) } } @Test - fun `should be in error status if error invalid context`(): Unit = + fun `should be in error status if error invalid context`() = runTest { val mockEngine = mockEngineWithOneResponse( @@ -261,15 +266,15 @@ class OfrepProviderTest { } } runCurrent() - withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + withClient(provider, defaultEvalCtx) { client -> runCurrent() - assert(providerErrorReceived) { "ProviderError event was not received" } - assert(exceptionReceived is OpenFeatureError.InvalidContextError) { "The exception is not of type InvalidContextError" } + assertTrue(providerErrorReceived, "ProviderError event was not received") + assertIs(exceptionReceived, "The exception is not of type InvalidContextError") } } @Test - fun `should be in error status if error parse error`(): Unit = + fun `should be in error status if error parse error`() = runTest { val mockEngine = mockEngineWithOneResponse( @@ -292,20 +297,20 @@ class OfrepProviderTest { } } runCurrent() - withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + withClient(provider, defaultEvalCtx) { client -> runCurrent() - assert(providerErrorReceived) { "ProviderError event was not received" } - assert(exceptionReceived is OpenFeatureError.ParseError) { "The exception is not of type ParseError" } + assertTrue(providerErrorReceived, "ProviderError event was not received") + assertIs(exceptionReceived, "The exception is not of type ParseError") } } @Test - fun `should return a flag not found error if the flag does not exist`(): Unit = + fun `should return a flag not found error if the flag does not exist`() = runTest { val mockEngine = mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) - withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + withClient(provider, defaultEvalCtx) { client -> val got = client.getBooleanDetails("non-existent-flag", false) val want = FlagEvaluationDetails( @@ -321,14 +326,14 @@ class OfrepProviderTest { } @Test - fun `should return evaluation details if the flag exists`(): Unit = + fun `should return evaluation details if the flag exists`() = runTest { val mockEngine = mockEngineWithOneResponse( VALID_API_SHORT_RESPONSE_PAYLOAD, ) val provider = createOfrepProvider(mockEngine) - withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + withClient(provider, defaultEvalCtx) { client -> val got = client.getStringDetails("title-flag", "default") val want = FlagEvaluationDetails( @@ -350,14 +355,14 @@ class OfrepProviderTest { } @Test - fun `should return parse error if the API returns the error`(): Unit = + fun `should return parse error if the API returns the error`() = runTest { val mockEngine = mockEngineWithOneResponse( VALID_1_FLAG_IN_PARSE_ERROR_PAYLOAD, ) val provider = createOfrepProvider(mockEngine) - withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + withClient(provider, defaultEvalCtx) { client -> val got = client.getStringDetails("my-other-flag", "default") val want = FlagEvaluationDetails( @@ -373,7 +378,7 @@ class OfrepProviderTest { } @Test - fun `should send a context changed event if context has changed`(): Unit = + fun `should send a context changed event if context has changed`() = runTest { val mockEngine = mockEngineWithTwoResponses( @@ -381,7 +386,7 @@ class OfrepProviderTest { secondContent = VALID_API_RESPONSE2_PAYLOAD, ) val provider = createOfrepProvider(mockEngine) - withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + withClient(provider, defaultEvalCtx) { client -> // TODO: should change when we have a way to observe context changes event // check issue https://github.com/open-feature/kotlin-sdk/issues/107 @@ -411,13 +416,13 @@ class OfrepProviderTest { dispatcher = StandardTestDispatcher(testScheduler), ) runCurrent() - assert(providerStaleEventReceived) { "ProviderStale event was not received" } - assert(providerReadyEventReceived) { "ProviderReady event was not received" } + assertTrue(providerStaleEventReceived, "ProviderStale event was not received") + assertTrue(providerReadyEventReceived, "ProviderReady event was not received") } } @Test - fun `should not try to call the API before Retry-After header`(): Unit = + fun `should not try to call the API before Retry-After header`() = runTest { val mockEngine = mockEngineWithOneResponse( @@ -426,7 +431,7 @@ class OfrepProviderTest { ) val provider = createOfrepProvider(mockEngine) - withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + withClient(provider, defaultEvalCtx) { client -> client.getStringDetails("my-other-flag", "default") client.getStringDetails("my-other-flag", "default") advanceTimeBy(POLLING_INTERVAL) @@ -435,12 +440,12 @@ class OfrepProviderTest { } @Test - fun `should return a valid evaluation for Boolean`(): Unit = + fun `should return a valid evaluation for Boolean`() = runTest { val mockEngine = mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) - withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + withClient(provider, defaultEvalCtx) { client -> val got = client.getBooleanDetails("bool-flag", false) val want = FlagEvaluationDetails( @@ -463,12 +468,12 @@ class OfrepProviderTest { } @Test - fun `should return a valid evaluation for Int`(): Unit = + fun `should return a valid evaluation for Int`() = runTest { val mockEngine = mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) - withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + withClient(provider, defaultEvalCtx) { client -> val got = client.getIntegerDetails("int-flag", 1) val want = FlagEvaluationDetails( @@ -491,12 +496,12 @@ class OfrepProviderTest { } @Test - fun `should return a valid evaluation for Double`(): Unit = + fun `should return a valid evaluation for Double`() = runTest { val mockEngine = mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) - withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + withClient(provider, defaultEvalCtx) { client -> val got = client.getDoubleDetails("double-flag", 1.1) val want = FlagEvaluationDetails( @@ -519,12 +524,12 @@ class OfrepProviderTest { } @Test - fun `should return a valid evaluation for String`(): Unit = + fun `should return a valid evaluation for String`() = runTest { val mockEngine = mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) - withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + withClient(provider, defaultEvalCtx) { client -> val got = client.getStringDetails("string-flag", "default") val want = FlagEvaluationDetails( @@ -547,12 +552,12 @@ class OfrepProviderTest { } @Test - fun `should return a valid evaluation for List`(): Unit = + fun `should return a valid evaluation for List`() = runTest { val mockEngine = mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) - withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + withClient(provider, defaultEvalCtx) { client -> val got = client.getObjectDetails( "array-flag", @@ -580,12 +585,12 @@ class OfrepProviderTest { } @Test - fun `should return a valid evaluation for Map`(): Unit = + fun `should return a valid evaluation for Map`() = runTest { val mockEngine = mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) - withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + withClient(provider, defaultEvalCtx) { client -> val got = client.getObjectDetails( "object-flag", @@ -627,12 +632,12 @@ class OfrepProviderTest { } @Test - fun `should return TypeMismatch Bool`(): Unit = + fun `should return TypeMismatch Bool`() = runTest { val mockEngine = mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) - withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + withClient(provider, defaultEvalCtx) { client -> val got = client.getBooleanDetails("object-flag", false) val want = FlagEvaluationDetails( @@ -650,12 +655,12 @@ class OfrepProviderTest { } @Test - fun `should return TypeMismatch String`(): Unit = + fun `should return TypeMismatch String`() = runTest { val mockEngine = mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) - withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + withClient(provider, defaultEvalCtx) { client -> val got = client.getStringDetails("object-flag", "default") val want = FlagEvaluationDetails( @@ -673,12 +678,12 @@ class OfrepProviderTest { } @Test - fun `should return TypeMismatch Double`(): Unit = + fun `should return TypeMismatch Double`() = runTest { val mockEngine = mockEngineWithOneResponse(VALID_API_RESPONSE_PAYLOAD) val provider = createOfrepProvider(mockEngine) - withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + withClient(provider, defaultEvalCtx) { client -> val got = client.getDoubleDetails("object-flag", 1.233) val want = FlagEvaluationDetails( @@ -696,7 +701,7 @@ class OfrepProviderTest { } @Test - fun `should have different result if waiting for next polling interval`(): Unit = + fun `should have different result if waiting for next polling interval`() = runTest( // TODO: remove timeout = 10.minutes, @@ -708,7 +713,7 @@ class OfrepProviderTest { ) val provider = createOfrepProvider(mockEngine) - withClient(provider, defaultEvalCtx, Dispatchers.IO) { client -> + withClient(provider, defaultEvalCtx) { client -> runCurrent() val got = client.getStringDetails("badge-class2", "default") val want = diff --git a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt index 1353187..78a20b9 100644 --- a/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt +++ b/providers/ofrep/src/commonTest/kotlin/dev/openfeature/kotlin/contrib/providers/ofrep/controller/OfrepApiTest.kt @@ -97,7 +97,7 @@ class OfrepApiTest { } @Test - fun shouldThrowAnUnauthorizedError(): Unit = + fun shouldThrowAnUnauthorizedError() = runTest { val mockEngine = mockEngineWithOneResponse( @@ -112,7 +112,7 @@ class OfrepApiTest { } @Test - fun shouldThrowAForbiddenError(): Unit = + fun shouldThrowAForbiddenError() = runTest { val mockEngine = mockEngineWithOneResponse( @@ -127,7 +127,7 @@ class OfrepApiTest { } @Test - fun shouldThrowTooManyRequest(): Unit = + fun shouldThrowTooManyRequest() = runTest { val mockEngine = mockEngineWithOneResponse( @@ -147,7 +147,7 @@ class OfrepApiTest { } @Test - fun shouldThrowUnexpectedError(): Unit = + fun shouldThrowUnexpectedError() = runTest { val mockEngine = mockEngineWithOneResponse( @@ -163,7 +163,7 @@ class OfrepApiTest { } @Test - fun shouldReturnAnEvaluationResponseInError(): Unit = + fun shouldReturnAnEvaluationResponseInError() = runTest { val mockEngine = mockEngineWithOneResponse( @@ -185,7 +185,7 @@ class OfrepApiTest { } @Test - fun shouldReturnaEvaluationResponseIfWeReceiveA304(): Unit = + fun shouldReturnaEvaluationResponseIfWeReceiveA304() = runTest { val mockEngine = mockEngineWithOneResponse( @@ -202,7 +202,7 @@ class OfrepApiTest { } @Test - fun shouldThrowTargetingKeyMissingErrorWithNoTargetingKey(): Unit = + fun shouldThrowTargetingKeyMissingErrorWithNoTargetingKey() = runTest { val mockEngine = mockEngineWithOneResponse( @@ -218,7 +218,7 @@ class OfrepApiTest { } @Test - fun shouldThrowUnmarshallErrorWithInvalidJson(): Unit = + fun shouldThrowUnmarshallErrorWithInvalidJson() = runTest { val mockEngine = mockEngineWithOneResponse( @@ -234,7 +234,7 @@ class OfrepApiTest { } @Test - fun shouldThrowWithInvalidOptions(): Unit = + fun shouldThrowWithInvalidOptions() = runTest { assertFailsWith { OfrepApi(OfrepOptions(endpoint = "invalid_url")) @@ -242,7 +242,7 @@ class OfrepApiTest { } @Test - fun shouldETagShouldNotMatch(): Unit = + fun shouldETagShouldNotMatch() = runTest { val mockEngine = mockEngineWithTwoResponses( @@ -265,7 +265,7 @@ class OfrepApiTest { } @Test - fun shouldHaveIfNoneNullInTheHeaders(): Unit = + fun shouldHaveIfNoneNullInTheHeaders() = runTest { val mockEngine = mockEngineWithTwoResponses(