diff --git a/docs/multiprovider/README.md b/docs/multiprovider/README.md new file mode 100644 index 0000000..eb35431 --- /dev/null +++ b/docs/multiprovider/README.md @@ -0,0 +1,126 @@ +## MultiProvider (OpenFeature Kotlin SDK) + +Combine multiple `FeatureProvider`s into a single provider with deterministic ordering, pluggable evaluation strategies, and unified status/event handling. + +### Why use MultiProvider? +- **Layer providers**: fall back from an in-memory or experiment provider to a remote provider. +- **Migrate safely**: put the new provider first, retain the old as fallback. +- **Handle errors predictably**: choose whether errors should short-circuit or be skipped. + +This implementation is adapted for Kotlin coroutines, flows, and OpenFeature error types. + +### Quick start +```kotlin +import dev.openfeature.kotlin.sdk.OpenFeatureAPI +import dev.openfeature.kotlin.sdk.multiprovider.MultiProvider +import dev.openfeature.kotlin.sdk.multiprovider.FirstMatchStrategy +// import dev.openfeature.kotlin.sdk.multiprovider.FirstSuccessfulStrategy + +// 1) Construct your providers (examples) +val experiments = MyExperimentProvider() // e.g., local overrides/experiments +val remote = MyRemoteProvider() // e.g., network-backed + +// 2) Wrap them with MultiProvider in the desired order +val multi = MultiProvider( + providers = listOf(experiments, remote), + strategy = FirstMatchStrategy() // default; FirstSuccessfulStrategy() also available +) + +// 3) Set the SDK provider and wait until ready +OpenFeatureAPI.setProviderAndWait() + +// 4) Use the client as usual +val client = OpenFeatureAPI.getClient("my-app") +val enabled = client.getBooleanValue("new-ui", defaultValue = false) +``` + +### How it works (at a glance) +- The `MultiProvider` delegates each evaluation to its child providers in the order you supply. +- A pluggable `Strategy` decides which child result to return. +- Provider events are observed and converted into a single aggregate SDK status. +- Context changes are forwarded to all children concurrently. + +### Strategies + +- **FirstMatchStrategy (default)** + - Returns the first child result that is not "flag not found". + - If a child returns an error other than `FLAG_NOT_FOUND`, that error is returned immediately. + - If all children report `FLAG_NOT_FOUND`, the default value is returned with reason `DEFAULT`. + +- **FirstSuccessfulStrategy** + - Skips over errors from children and continues to the next provider. + - Returns the first successful evaluation (no error code). + - If no provider succeeds, the default value is returned with `FLAG_NOT_FOUND`. + +Pick the strategy that best matches your failure-policy: +- Prefer early, explicit error surfacing: use `FirstMatchStrategy`. +- Prefer resilience and best-effort success: use `FirstSuccessfulStrategy`. + +### Evaluation order matters +Children are evaluated in the order provided. Put the most authoritative or fastest provider first. For example, place a small in-memory override provider before a remote provider to reduce latency. + +### Events and status aggregation +`MultiProvider` listens to child provider events and emits a single, aggregate status via `OpenFeatureAPI.statusFlow`. The highest-precedence status among children wins: + +1. Fatal +2. NotReady +3. Error +4. Reconciling / Stale +5. Ready + +`ProviderConfigurationChanged` is re-emitted as-is. When the aggregate status changes due to a child event, the original triggering event is also emitted. + +### Context propagation +When the evaluation context changes, `MultiProvider` calls `onContextSet` on all child providers concurrently. Aggregate status transitions to Reconciling and then back to Ready (or Error) in line with SDK behavior. + +### Provider metadata +`MultiProvider.metadata` exposes: +- `name = "multiprovider"` +- `originalMetadata`: a map of child-name → child `ProviderMetadata` + +Child names are derived from each provider’s `metadata.name`. If duplicates occur, stable suffixes are applied (e.g., `myProvider_1`, `myProvider_2`). + +Example: inspect provider metadata +```kotlin +val meta = OpenFeatureAPI.getProviderMetadata() +println(meta?.name) // "multiprovider" +println(meta?.originalMetadata) // map of child names to their metadata +``` + +### Shutdown behavior +`shutdown()` is invoked on all children. If any child fails to shut down, an aggregated error is thrown that includes all individual failures. Resources should be released in child providers even if peers fail. + +### Custom strategies +You can provide your own composition policy by implementing `MultiProvider.Strategy`: +```kotlin +import dev.openfeature.kotlin.sdk.* +import dev.openfeature.kotlin.sdk.multiprovider.MultiProvider + +class MyStrategy : MultiProvider.Strategy { + override fun evaluate( + providers: List, + key: String, + defaultValue: T, + evaluationContext: EvaluationContext?, + flagEval: FeatureProvider.(String, T, EvaluationContext?) -> ProviderEvaluation + ): ProviderEvaluation { + // Example: try all, prefer the highest integer value (demo only) + var best: ProviderEvaluation? = null + for (p in providers) { + val e = p.flagEval(key, defaultValue, evaluationContext) + // ... decide whether to keep e as best ... + best = best ?: e + } + return best ?: ProviderEvaluation(defaultValue) + } +} + +val multi = MultiProvider(listOf(experiments, remote), strategy = MyStrategy()) +``` + +### Notes and limitations +- Hooks on `MultiProvider` are currently not applied. +- Ensure each child’s `metadata.name` is set for clearer diagnostics in `originalMetadata`. + + + diff --git a/kotlin-sdk/api/android/kotlin-sdk.api b/kotlin-sdk/api/android/kotlin-sdk.api index 74f1847..cafdfce 100644 --- a/kotlin-sdk/api/android/kotlin-sdk.api +++ b/kotlin-sdk/api/android/kotlin-sdk.api @@ -261,6 +261,7 @@ public final class dev/openfeature/kotlin/sdk/NoOpProvider$NoOpProviderMetadata public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/NoOpProvider$NoOpProviderMetadata;Ljava/lang/String;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/NoOpProvider$NoOpProviderMetadata; public fun equals (Ljava/lang/Object;)Z public fun getName ()Ljava/lang/String; + public fun getOriginalMetadata ()Ljava/util/Map; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -382,6 +383,11 @@ public final class dev/openfeature/kotlin/sdk/ProviderEvaluation { public abstract interface class dev/openfeature/kotlin/sdk/ProviderMetadata { public abstract fun getName ()Ljava/lang/String; + public fun getOriginalMetadata ()Ljava/util/Map; +} + +public final class dev/openfeature/kotlin/sdk/ProviderMetadata$DefaultImpls { + public static fun getOriginalMetadata (Ldev/openfeature/kotlin/sdk/ProviderMetadata;)Ljava/util/Map; } public final class dev/openfeature/kotlin/sdk/Reason : java/lang/Enum { @@ -739,3 +745,56 @@ public final class dev/openfeature/kotlin/sdk/exceptions/OpenFeatureError$TypeMi public fun getMessage ()Ljava/lang/String; } +public final class dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy : dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy { + public fun ()V + public fun evaluate (Ljava/util/List;Ljava/lang/String;Ljava/lang/Object;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/jvm/functions/Function4;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; +} + +public final class dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy : dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy { + public fun ()V + public fun evaluate (Ljava/util/List;Ljava/lang/String;Ljava/lang/Object;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/jvm/functions/Function4;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; +} + +public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider : dev/openfeature/kotlin/sdk/FeatureProvider { + public static final field Companion Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Companion; + public fun (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy;)V + public synthetic fun (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getBooleanEvaluation (Ljava/lang/String;ZLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getDoubleEvaluation (Ljava/lang/String;DLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getHooks ()Ljava/util/List; + public fun getIntegerEvaluation (Ljava/lang/String;ILdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getMetadata ()Ldev/openfeature/kotlin/sdk/ProviderMetadata; + public fun getObjectEvaluation (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public final fun getStatusFlow ()Lkotlinx/coroutines/flow/StateFlow; + public fun getStringEvaluation (Ljava/lang/String;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun initialize (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun observe ()Lkotlinx/coroutines/flow/Flow; + public fun onContextSet (Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun shutdown ()V + public fun track (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/TrackingEventDetails;)V +} + +public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$ChildFeatureProvider : dev/openfeature/kotlin/sdk/FeatureProvider { + public fun (Ldev/openfeature/kotlin/sdk/FeatureProvider;Ljava/lang/String;)V + public fun getBooleanEvaluation (Ljava/lang/String;ZLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getDoubleEvaluation (Ljava/lang/String;DLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getHooks ()Ljava/util/List; + public fun getIntegerEvaluation (Ljava/lang/String;ILdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getMetadata ()Ldev/openfeature/kotlin/sdk/ProviderMetadata; + public final fun getName ()Ljava/lang/String; + public fun getObjectEvaluation (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getStringEvaluation (Ljava/lang/String;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun initialize (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun observe ()Lkotlinx/coroutines/flow/Flow; + public fun onContextSet (Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun shutdown ()V + public fun track (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/TrackingEventDetails;)V +} + +public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Companion { +} + +public abstract interface class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy { + public abstract fun evaluate (Ljava/util/List;Ljava/lang/String;Ljava/lang/Object;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/jvm/functions/Function4;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; +} + diff --git a/kotlin-sdk/api/jvm/kotlin-sdk.api b/kotlin-sdk/api/jvm/kotlin-sdk.api index 74f1847..cafdfce 100644 --- a/kotlin-sdk/api/jvm/kotlin-sdk.api +++ b/kotlin-sdk/api/jvm/kotlin-sdk.api @@ -261,6 +261,7 @@ public final class dev/openfeature/kotlin/sdk/NoOpProvider$NoOpProviderMetadata public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/NoOpProvider$NoOpProviderMetadata;Ljava/lang/String;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/NoOpProvider$NoOpProviderMetadata; public fun equals (Ljava/lang/Object;)Z public fun getName ()Ljava/lang/String; + public fun getOriginalMetadata ()Ljava/util/Map; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -382,6 +383,11 @@ public final class dev/openfeature/kotlin/sdk/ProviderEvaluation { public abstract interface class dev/openfeature/kotlin/sdk/ProviderMetadata { public abstract fun getName ()Ljava/lang/String; + public fun getOriginalMetadata ()Ljava/util/Map; +} + +public final class dev/openfeature/kotlin/sdk/ProviderMetadata$DefaultImpls { + public static fun getOriginalMetadata (Ldev/openfeature/kotlin/sdk/ProviderMetadata;)Ljava/util/Map; } public final class dev/openfeature/kotlin/sdk/Reason : java/lang/Enum { @@ -739,3 +745,56 @@ public final class dev/openfeature/kotlin/sdk/exceptions/OpenFeatureError$TypeMi public fun getMessage ()Ljava/lang/String; } +public final class dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy : dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy { + public fun ()V + public fun evaluate (Ljava/util/List;Ljava/lang/String;Ljava/lang/Object;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/jvm/functions/Function4;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; +} + +public final class dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy : dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy { + public fun ()V + public fun evaluate (Ljava/util/List;Ljava/lang/String;Ljava/lang/Object;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/jvm/functions/Function4;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; +} + +public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider : dev/openfeature/kotlin/sdk/FeatureProvider { + public static final field Companion Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Companion; + public fun (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy;)V + public synthetic fun (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getBooleanEvaluation (Ljava/lang/String;ZLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getDoubleEvaluation (Ljava/lang/String;DLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getHooks ()Ljava/util/List; + public fun getIntegerEvaluation (Ljava/lang/String;ILdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getMetadata ()Ldev/openfeature/kotlin/sdk/ProviderMetadata; + public fun getObjectEvaluation (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public final fun getStatusFlow ()Lkotlinx/coroutines/flow/StateFlow; + public fun getStringEvaluation (Ljava/lang/String;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun initialize (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun observe ()Lkotlinx/coroutines/flow/Flow; + public fun onContextSet (Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun shutdown ()V + public fun track (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/TrackingEventDetails;)V +} + +public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$ChildFeatureProvider : dev/openfeature/kotlin/sdk/FeatureProvider { + public fun (Ldev/openfeature/kotlin/sdk/FeatureProvider;Ljava/lang/String;)V + public fun getBooleanEvaluation (Ljava/lang/String;ZLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getDoubleEvaluation (Ljava/lang/String;DLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getHooks ()Ljava/util/List; + public fun getIntegerEvaluation (Ljava/lang/String;ILdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getMetadata ()Ldev/openfeature/kotlin/sdk/ProviderMetadata; + public final fun getName ()Ljava/lang/String; + public fun getObjectEvaluation (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getStringEvaluation (Ljava/lang/String;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun initialize (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun observe ()Lkotlinx/coroutines/flow/Flow; + public fun onContextSet (Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun shutdown ()V + public fun track (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/TrackingEventDetails;)V +} + +public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Companion { +} + +public abstract interface class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy { + public abstract fun evaluate (Ljava/util/List;Ljava/lang/String;Ljava/lang/Object;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/jvm/functions/Function4;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; +} + diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ProviderMetadata.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ProviderMetadata.kt index 056516c..ccee3cb 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ProviderMetadata.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ProviderMetadata.kt @@ -1,5 +1,38 @@ package dev.openfeature.kotlin.sdk +/** + * Provider metadata as defined by the OpenFeature specification. + * + * In a single provider, `name` identifies the provider. In a Multi-Provider, the outer provider + * exposes its own `name` and surfaces the metadata of its managed providers via `originalMetadata`, + * keyed by each provider's resolved unique name. + * + * See: https://openfeature.dev/specification/appendix-a/#metadata + */ interface ProviderMetadata { + /** + * Human-readable provider name. + * + * - Used in logs, events, and error reporting. + * - In a Multi-Provider, names must be unique. + */ val name: String? + + /** + * For Multi-Provider: a map of child provider names to their metadata. + * + * - For normal providers this MUST be an empty map. + * - For the Multi-Provider, this contains each inner provider's `ProviderMetadata`, keyed by + * that provider's resolved unique name. + * + * Example shape: + * { + * "providerA": {...}, + * "providerB": {...} + * } + * + * See: https://openfeature.dev/specification/appendix-a/#metadata + */ + val originalMetadata: Map + get() = emptyMap() } \ No newline at end of file diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt new file mode 100644 index 0000000..671fb85 --- /dev/null +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt @@ -0,0 +1,58 @@ +package dev.openfeature.kotlin.sdk.multiprovider + +import dev.openfeature.kotlin.sdk.EvaluationContext +import dev.openfeature.kotlin.sdk.FeatureProvider +import dev.openfeature.kotlin.sdk.ProviderEvaluation +import dev.openfeature.kotlin.sdk.Reason +import dev.openfeature.kotlin.sdk.exceptions.ErrorCode +import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError + +/** + * A [MultiProvider.Strategy] that returns the first result returned by a [FeatureProvider]. + * + * It skips providers that indicate they had no value due to [ErrorCode.FLAG_NOT_FOUND]. + * In all other cases, it uses the value returned by the provider. + * + * If any provider returns an error result other than [ErrorCode.FLAG_NOT_FOUND], the whole evaluation + * returns the provider's error. + */ +class FirstMatchStrategy : MultiProvider.Strategy { + override fun evaluate( + providers: List, + key: String, + defaultValue: T, + evaluationContext: EvaluationContext?, + flagEval: FlagEval + ): ProviderEvaluation { + // Iterate through each provider in the provided order + for (provider in providers) { + try { + // Call the flag evaluation method on the current provider + val eval = provider.flagEval(key, defaultValue, evaluationContext) + + // If the provider knows about this flag (any result except FLAG_NOT_FOUND), + // return this result immediately, even if it's an error + if (eval.errorCode != ErrorCode.FLAG_NOT_FOUND) { + return eval + } + } catch (_: OpenFeatureError.FlagNotFoundError) { + // Handle FLAG_NOT_FOUND exception - continue to next provider + continue + } catch (error: OpenFeatureError) { + return ProviderEvaluation( + defaultValue, + reason = Reason.ERROR.toString(), + errorCode = error.errorCode(), + errorMessage = error.message + ) + } + } + + // No provider knew about the flag, return default value with DEFAULT reason + return ProviderEvaluation( + defaultValue, + reason = Reason.DEFAULT.toString(), + errorCode = ErrorCode.FLAG_NOT_FOUND + ) + } +} \ No newline at end of file diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt new file mode 100644 index 0000000..514ba92 --- /dev/null +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt @@ -0,0 +1,52 @@ +package dev.openfeature.kotlin.sdk.multiprovider + +import dev.openfeature.kotlin.sdk.EvaluationContext +import dev.openfeature.kotlin.sdk.FeatureProvider +import dev.openfeature.kotlin.sdk.ProviderEvaluation +import dev.openfeature.kotlin.sdk.Reason +import dev.openfeature.kotlin.sdk.exceptions.ErrorCode +import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError + +/** + * A [MultiProvider.Strategy] similar to the [FirstMatchStrategy], except that errors from evaluated + * providers do not halt execution. + * + * If no provider responds successfully, it returns an error result. + */ +class FirstSuccessfulStrategy : MultiProvider.Strategy { + override fun evaluate( + providers: List, + key: String, + defaultValue: T, + evaluationContext: EvaluationContext?, + flagEval: FlagEval + ): ProviderEvaluation { + // Iterate through each provider in the provided order + for (provider in providers) { + try { + // Call the flag evaluation method on the current provider + val eval = provider.flagEval(key, defaultValue, evaluationContext) + + // If the provider returned a successful result (no error), + // return this result immediately + if (eval.errorCode == null) { + return eval + } + // Continue to next provider if this one had an error + } catch (_: OpenFeatureError) { + // Handle any OpenFeature exceptions - continue to next provider + // FirstSuccessful strategy skips errors and continues + continue + } + } + + // No provider returned a successful result, throw an error + // This indicates that all providers either failed or had errors + return ProviderEvaluation( + value = defaultValue, + reason = Reason.DEFAULT.toString(), + errorCode = ErrorCode.FLAG_NOT_FOUND, + errorMessage = "No provider returned a successful evaluation for the requested flag." + ) + } +} \ No newline at end of file diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt new file mode 100644 index 0000000..30af01c --- /dev/null +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt @@ -0,0 +1,343 @@ +package dev.openfeature.kotlin.sdk.multiprovider + +import dev.openfeature.kotlin.sdk.EvaluationContext +import dev.openfeature.kotlin.sdk.FeatureProvider +import dev.openfeature.kotlin.sdk.Hook +import dev.openfeature.kotlin.sdk.OpenFeatureStatus +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.OpenFeatureError +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update + +/** + * Type alias for a function that evaluates a feature flag using a FeatureProvider. + * This represents an extension function on FeatureProvider that takes: + * - key: The feature flag key to evaluate + * - defaultValue: The default value to return if evaluation fails + * - evaluationContext: Optional context for the evaluation + * Returns a ProviderEvaluation containing the result + */ +typealias FlagEval = + FeatureProvider.(key: String, defaultValue: T, evaluationContext: EvaluationContext?) -> ProviderEvaluation + +/** + * MultiProvider is a FeatureProvider implementation that delegates flag evaluations + * to multiple underlying providers using a configurable strategy. + * + * This class acts as a composite provider that can: + * - Combine multiple feature providers into a single interface + * - Apply different evaluation strategies (FirstMatch, FirstSuccessful, etc.) + * - Manage lifecycle events for all underlying providers + * - Forward context changes to all providers + * + * @param providers List of FeatureProvider instances to delegate to + * @param strategy Strategy to use for combining provider results (defaults to FirstMatchStrategy) + */ +class MultiProvider( + providers: List, + private val strategy: Strategy = FirstMatchStrategy() +) : FeatureProvider { + private class ProviderShutdownException( + providerName: String, + cause: Throwable + ) : RuntimeException("Provider '$providerName' shutdown failed: ${cause.message}", cause) + + /** + * @property name The unique name of the [FeatureProvider] according to this MultiProvider + */ + class ChildFeatureProvider( + implementation: FeatureProvider, + val name: String // Maybe there's a better variable name for this? + ) : FeatureProvider by implementation + + /** + * Strategy interface defines how multiple feature providers should be evaluated + * to determine the final result for a feature flag evaluation. + * Different strategies can implement different logic for combining or selecting + * results from multiple providers. + */ + interface Strategy { + /** + * Evaluates a feature flag across multiple providers using the strategy's logic. + * @param providers List of FeatureProvider instances to evaluate against + * @param key The feature flag key to evaluate + * @param defaultValue The default value to use if evaluation fails or no providers match + * @param evaluationContext Optional context containing additional data for evaluation + * @param flagEval Function reference to the specific evaluation method to call on each provider + * @return ProviderEvaluation containing the final evaluation result + */ + fun evaluate( + providers: List, + key: String, + defaultValue: T, + evaluationContext: EvaluationContext?, + flagEval: FlagEval + ): ProviderEvaluation + } + + private val OpenFeatureStatus.precedence: Int + get() = when (this) { + is OpenFeatureStatus.Fatal -> 5 + is OpenFeatureStatus.NotReady -> 4 + is OpenFeatureStatus.Error -> 3 + is OpenFeatureStatus.Reconciling -> 2 // Not specified in precedence; treat similar to Stale + is OpenFeatureStatus.Stale -> 2 + is OpenFeatureStatus.Ready -> 1 + } + + // TODO: Support hooks + override val hooks: List> = emptyList() + private val childFeatureProviders: List by lazy { + providers.toChildFeatureProviders() + } + + // Metadata identifying this as a multiprovider + override val metadata: ProviderMetadata = object : ProviderMetadata { + override val name: String? = MULTIPROVIDER_NAME + override val originalMetadata: Map by lazy { + childFeatureProviders.associate { it.name to it.metadata } + } + + override fun toString(): String { + return mapOf( + "name" to name, + "originalMetadata" to originalMetadata + ).toString() + } + } + + private val _statusFlow = MutableStateFlow(OpenFeatureStatus.NotReady) + val statusFlow = _statusFlow.asStateFlow() + + private val eventFlow = MutableSharedFlow(replay = 1, extraBufferCapacity = 5) + + // Track individual provider statuses, initial state of all providers is NotReady + private val childProviderStatuses: MutableMap = + childFeatureProviders.associateWithTo(mutableMapOf()) { OpenFeatureStatus.NotReady } + + private fun List.toChildFeatureProviders(): List { + // Extract a stable base name per provider, falling back for unnamed providers + val providerBaseNames: List = this.map { it.metadata.name ?: UNDEFINED_PROVIDER_NAME } + + // How many times each base name occurs in the inputs + val baseNameToTotalCount: Map = providerBaseNames.groupingBy { it }.eachCount() + + // Running index per base name used to generate suffixed unique names in order + val baseNameToNextIndex = mutableMapOf() + + return this.mapIndexed { providerIndex, provider -> + val baseName = providerBaseNames[providerIndex] + val occurrencesForBase = baseNameToTotalCount[baseName] ?: 0 + + val uniqueChildName = if (occurrencesForBase > 1) { + val nextIndex = (baseNameToNextIndex[baseName] ?: 0) + 1 + baseNameToNextIndex[baseName] = nextIndex + "${baseName}_$nextIndex" + } else { + baseName + } + + ChildFeatureProvider(provider, uniqueChildName) + } + } + + /** + * @return Number of unique providers + */ + internal fun getProviderCount(): Int = childFeatureProviders.size + + // TODO Add distinctUntilChanged operator once EventDetails have been added + override fun observe(): Flow = eventFlow.asSharedFlow() + + /** + * Initializes all underlying providers with the given context. + * This ensures all providers are ready before any evaluations occur. + * + * @param initialContext Optional evaluation context to initialize providers with + */ + override suspend fun initialize(initialContext: EvaluationContext?) { + coroutineScope { + // Listen to events emitted by providers to emit our own set of events + // according to https://openfeature.dev/specification/appendix-a/#status-and-event-handling + childFeatureProviders.forEach { provider -> + provider.observe() + .onEach { event -> + handleProviderEvent(provider, event) + } + .launchIn(this) + } + + // State updates captured by observing individual Feature Flag providers + childFeatureProviders + .map { async { it.initialize(initialContext) } } + .awaitAll() + } + } + + private suspend fun handleProviderEvent(provider: ChildFeatureProvider, event: OpenFeatureProviderEvents) { + val newChildStatus = when (event) { + // ProviderConfigurationChanged events should always re-emit + is OpenFeatureProviderEvents.ProviderConfigurationChanged -> { + eventFlow.emit(event) + return + } + + is OpenFeatureProviderEvents.ProviderReady -> OpenFeatureStatus.Ready + is OpenFeatureProviderEvents.ProviderNotReady -> OpenFeatureStatus.NotReady + is OpenFeatureProviderEvents.ProviderStale -> OpenFeatureStatus.Stale + is OpenFeatureProviderEvents.ProviderError -> + if (event.error is OpenFeatureError.ProviderFatalError) { + OpenFeatureStatus.Fatal(event.error) + } else { + OpenFeatureStatus.Error(event.error) + } + } + + val previousStatus = _statusFlow.value + childProviderStatuses[provider] = newChildStatus + val newStatus = calculateAggregateStatus() + + if (previousStatus != newStatus) { + _statusFlow.update { newStatus } + // Re-emit the original event that triggered the aggregate status change + eventFlow.emit(event) + } + } + + private fun calculateAggregateStatus(): OpenFeatureStatus { + val highestPrecedenceStatus = childProviderStatuses.values.maxBy { it.precedence } + return highestPrecedenceStatus + } + + /** + * Shuts down all underlying providers. + * This allows providers to clean up resources and complete any pending operations. + */ + override fun shutdown() { + val shutdownErrors = mutableListOf>() + childFeatureProviders.forEach { provider -> + try { + provider.shutdown() + } catch (t: Throwable) { + shutdownErrors += provider.name to t + } + } + + if (shutdownErrors.isNotEmpty()) { + val message = buildString { + append("One or more providers failed to shutdown: ") + append( + shutdownErrors.joinToString(separator = "\n") { (name, err) -> + "$name: ${err.message}" + } + ) + } + + val aggregate = OpenFeatureError.GeneralError(message) + shutdownErrors.forEach { (name, err) -> + aggregate.addSuppressed(ProviderShutdownException(name, err)) + } + throw aggregate + } + } + + override suspend fun onContextSet( + oldContext: EvaluationContext?, + newContext: EvaluationContext + ) { + coroutineScope { + // If any of these fail, they should individually bubble up their fail + // event and that is handled by handleProviderEvent() + childFeatureProviders + .map { async { it.onContextSet(oldContext, newContext) } } + .awaitAll() + } + } + + override fun getBooleanEvaluation( + key: String, + defaultValue: Boolean, + context: EvaluationContext? + ): ProviderEvaluation { + return strategy.evaluate( + childFeatureProviders, + key, + defaultValue, + context, + FeatureProvider::getBooleanEvaluation + ) + } + + override fun getStringEvaluation( + key: String, + defaultValue: String, + context: EvaluationContext? + ): ProviderEvaluation { + return strategy.evaluate( + childFeatureProviders, + key, + defaultValue, + context, + FeatureProvider::getStringEvaluation + ) + } + + override fun getIntegerEvaluation( + key: String, + defaultValue: Int, + context: EvaluationContext? + ): ProviderEvaluation { + return strategy.evaluate( + childFeatureProviders, + key, + defaultValue, + context, + FeatureProvider::getIntegerEvaluation + ) + } + + override fun getDoubleEvaluation( + key: String, + defaultValue: Double, + context: EvaluationContext? + ): ProviderEvaluation { + return strategy.evaluate( + childFeatureProviders, + key, + defaultValue, + context, + FeatureProvider::getDoubleEvaluation + ) + } + + override fun getObjectEvaluation( + key: String, + defaultValue: Value, + context: EvaluationContext? + ): ProviderEvaluation { + return strategy.evaluate( + childFeatureProviders, + key, + defaultValue, + context, + FeatureProvider::getObjectEvaluation + ) + } + + companion object { + private const val MULTIPROVIDER_NAME = "multiprovider" + private const val UNDEFINED_PROVIDER_NAME = "" + } +} \ No newline at end of file diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/RecordingBooleanProvider.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/RecordingBooleanProvider.kt new file mode 100644 index 0000000..5a53a5f --- /dev/null +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/RecordingBooleanProvider.kt @@ -0,0 +1,74 @@ +package dev.openfeature.kotlin.sdk.helpers + +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.Value + +class RecordingBooleanProvider( + private val name: String, + private val behavior: () -> ProviderEvaluation +) : FeatureProvider { + override val hooks: List> = emptyList() + override val metadata: ProviderMetadata = object : ProviderMetadata { + override val name: String? = this@RecordingBooleanProvider.name + } + + var booleanEvalCalls: Int = 0 + private set + + override suspend fun initialize(initialContext: EvaluationContext?) { + // no-op + } + + override fun shutdown() { + // no-op + } + + override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { + // no-op + } + + override fun getBooleanEvaluation( + key: String, + defaultValue: Boolean, + context: EvaluationContext? + ): ProviderEvaluation { + booleanEvalCalls += 1 + return behavior() + } + + override fun getStringEvaluation( + key: String, + defaultValue: String, + context: EvaluationContext? + ): ProviderEvaluation { + throw UnsupportedOperationException() + } + + override fun getIntegerEvaluation( + key: String, + defaultValue: Int, + context: EvaluationContext? + ): ProviderEvaluation { + throw UnsupportedOperationException() + } + + override fun getDoubleEvaluation( + key: String, + defaultValue: Double, + context: EvaluationContext? + ): ProviderEvaluation { + throw UnsupportedOperationException() + } + + override fun getObjectEvaluation( + key: String, + defaultValue: Value, + context: EvaluationContext? + ): ProviderEvaluation { + throw UnsupportedOperationException() + } +} \ No newline at end of file diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategyTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategyTests.kt new file mode 100644 index 0000000..f034ccb --- /dev/null +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategyTests.kt @@ -0,0 +1,166 @@ +package dev.openfeature.kotlin.sdk.multiprovider + +import dev.openfeature.kotlin.sdk.FeatureProvider +import dev.openfeature.kotlin.sdk.ProviderEvaluation +import dev.openfeature.kotlin.sdk.exceptions.ErrorCode +import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError +import dev.openfeature.kotlin.sdk.helpers.RecordingBooleanProvider +import kotlin.test.Test +import kotlin.test.assertEquals + +class FirstMatchStrategyTests { + + @Test + fun returnsFirstSuccessWithoutCallingNextProviders() { + val strategy = FirstMatchStrategy() + val first = RecordingBooleanProvider( + name = "first", + behavior = { ProviderEvaluation(true) } + ) + val second = RecordingBooleanProvider( + name = "second", + behavior = { ProviderEvaluation(false) } + ) + + val result = strategy.evaluate( + listOf(first, second), + key = "flag", + defaultValue = false, + evaluationContext = null, + flagEval = FeatureProvider::getBooleanEvaluation + ) + + assertEquals(true, result.value) + assertEquals(1, first.booleanEvalCalls) + // Short-circuits after first provider + assertEquals(0, second.booleanEvalCalls) + } + + @Test + fun skipsFlagNotFoundAndReturnsNextMatch() { + val strategy = FirstMatchStrategy() + val notFoundProvider = RecordingBooleanProvider( + name = "not-found", + behavior = { ProviderEvaluation(false, errorCode = ErrorCode.FLAG_NOT_FOUND) } + ) + val matchingProvider = RecordingBooleanProvider( + name = "match", + behavior = { ProviderEvaluation(true) } + ) + val neverCalled = RecordingBooleanProvider( + name = "never", + behavior = { ProviderEvaluation(false) } + ) + + val result = strategy.evaluate( + listOf(notFoundProvider, matchingProvider, neverCalled), + key = "flag", + defaultValue = false, + evaluationContext = null, + flagEval = FeatureProvider::getBooleanEvaluation + ) + + assertEquals(true, result.value) + // First provider called but skipped + assertEquals(1, notFoundProvider.booleanEvalCalls) + // Second provider returned and short-circuited + assertEquals(1, matchingProvider.booleanEvalCalls) + assertEquals(0, neverCalled.booleanEvalCalls) + } + + @Test + fun treatsFlagNotFoundExceptionAsNotFoundAndContinues() { + val strategy = FirstMatchStrategy() + val throwsNotFound = RecordingBooleanProvider( + name = "throws-not-found", + behavior = { throw OpenFeatureError.FlagNotFoundError("flag") } + ) + val matchingProvider = RecordingBooleanProvider( + name = "match", + behavior = { ProviderEvaluation(true) } + ) + + val result = strategy.evaluate( + listOf(throwsNotFound, matchingProvider), + key = "flag", + defaultValue = false, + evaluationContext = null, + flagEval = FeatureProvider::getBooleanEvaluation + ) + + assertEquals(true, result.value) + assertEquals(1, throwsNotFound.booleanEvalCalls) + assertEquals(1, matchingProvider.booleanEvalCalls) + } + + @Test + fun returnsErrorResultOtherThanNotFoundAndShortCircuits() { + val strategy = FirstMatchStrategy() + val errorProvider = RecordingBooleanProvider( + name = "error", + behavior = { ProviderEvaluation(false, errorCode = ErrorCode.GENERAL, errorMessage = "boom") } + ) + val neverCalled = RecordingBooleanProvider( + name = "never", + behavior = { ProviderEvaluation(true) } + ) + + val result = strategy.evaluate( + listOf(errorProvider, neverCalled), + key = "flag", + defaultValue = true, + evaluationContext = null, + flagEval = FeatureProvider::getBooleanEvaluation + ) + + assertEquals(false, result.value) + assertEquals(ErrorCode.GENERAL, result.errorCode) + assertEquals(1, errorProvider.booleanEvalCalls) + assertEquals(0, neverCalled.booleanEvalCalls) + } + + @Test + fun returnsErrorResultForNonNotFoundExceptions() { + val strategy = FirstMatchStrategy() + val throwsGeneral = RecordingBooleanProvider( + name = "throws-general", + behavior = { throw OpenFeatureError.GeneralError("fail") } + ) + val result = strategy.evaluate( + listOf(throwsGeneral), + key = "flag", + defaultValue = false, + evaluationContext = null, + flagEval = FeatureProvider::getBooleanEvaluation + ) + assertEquals(false, result.value) + assertEquals(ErrorCode.GENERAL, result.errorCode) + assertEquals(1, throwsGeneral.booleanEvalCalls) + } + + @Test + fun returnsDefaultWithNotFoundWhenNoProviderMatches() { + val strategy = FirstMatchStrategy() + val p1 = RecordingBooleanProvider( + name = "p1", + behavior = { ProviderEvaluation(false, errorCode = ErrorCode.FLAG_NOT_FOUND) } + ) + val p2 = RecordingBooleanProvider( + name = "p2", + behavior = { throw OpenFeatureError.FlagNotFoundError("flag") } + ) + + val result = strategy.evaluate( + listOf(p1, p2), + key = "flag", + defaultValue = true, + evaluationContext = null, + flagEval = FeatureProvider::getBooleanEvaluation + ) + + assertEquals(true, result.value) + assertEquals(ErrorCode.FLAG_NOT_FOUND, result.errorCode) + assertEquals(1, p1.booleanEvalCalls) + assertEquals(1, p2.booleanEvalCalls) + } +} \ No newline at end of file diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategyTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategyTests.kt new file mode 100644 index 0000000..b9058d1 --- /dev/null +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategyTests.kt @@ -0,0 +1,96 @@ +package dev.openfeature.kotlin.sdk.multiprovider + +import dev.openfeature.kotlin.sdk.FeatureProvider +import dev.openfeature.kotlin.sdk.exceptions.ErrorCode +import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError +import dev.openfeature.kotlin.sdk.helpers.RecordingBooleanProvider +import kotlin.test.Test +import kotlin.test.assertEquals + +class FirstSuccessfulStrategyTests { + + @Test + fun returnsFirstSuccessIgnoringPriorErrors() { + val strategy = FirstSuccessfulStrategy() + val error1 = RecordingBooleanProvider("e1") { + throw OpenFeatureError.GeneralError("boom1") + } + val error2 = RecordingBooleanProvider("e2") { + // Simulate provider returning error result (not success) + dev.openfeature.kotlin.sdk.ProviderEvaluation(false, errorCode = ErrorCode.GENERAL) + } + val success = RecordingBooleanProvider("ok") { + dev.openfeature.kotlin.sdk.ProviderEvaluation(true) + } + val never = RecordingBooleanProvider("never") { + dev.openfeature.kotlin.sdk.ProviderEvaluation(false) + } + + val result = strategy.evaluate( + listOf(error1, error2, success, never), + key = "flag", + defaultValue = false, + evaluationContext = null, + flagEval = FeatureProvider::getBooleanEvaluation + ) + + assertEquals(true, result.value) + assertEquals(1, error1.booleanEvalCalls) + assertEquals(1, error2.booleanEvalCalls) + assertEquals(1, success.booleanEvalCalls) + assertEquals(0, never.booleanEvalCalls) + } + + @Test + fun skipsFlagNotFoundErrorAndResultUntilSuccess() { + val strategy = FirstSuccessfulStrategy() + val notFoundThrow = RecordingBooleanProvider("nf-throw") { + throw OpenFeatureError.FlagNotFoundError("flag") + } + val notFoundResult = RecordingBooleanProvider("nf-result") { + dev.openfeature.kotlin.sdk.ProviderEvaluation(false, errorCode = ErrorCode.FLAG_NOT_FOUND) + } + val success = RecordingBooleanProvider("ok") { + dev.openfeature.kotlin.sdk.ProviderEvaluation(true) + } + + val result = strategy.evaluate( + listOf(notFoundThrow, notFoundResult, success), + key = "flag", + defaultValue = false, + evaluationContext = null, + flagEval = FeatureProvider::getBooleanEvaluation + ) + + assertEquals(true, result.value) + assertEquals(1, notFoundThrow.booleanEvalCalls) + assertEquals(1, notFoundResult.booleanEvalCalls) + assertEquals(1, success.booleanEvalCalls) + } + + @Test + fun returnsErrorWhenNoProviderReturnsSuccess() { + val strategy = FirstSuccessfulStrategy() + val error1 = RecordingBooleanProvider("e1") { + throw OpenFeatureError.GeneralError("boom1") + } + val error2 = RecordingBooleanProvider("e2") { + dev.openfeature.kotlin.sdk.ProviderEvaluation(false, errorCode = ErrorCode.GENERAL) + } + val notFound = RecordingBooleanProvider("nf") { + dev.openfeature.kotlin.sdk.ProviderEvaluation(false, errorCode = ErrorCode.FLAG_NOT_FOUND) + } + val result = strategy.evaluate( + listOf(error1, error2, notFound), + key = "flag", + defaultValue = false, + evaluationContext = null, + flagEval = FeatureProvider::getBooleanEvaluation + ) + assertEquals(false, result.value) + assertEquals(ErrorCode.FLAG_NOT_FOUND, result.errorCode) + assertEquals(1, error1.booleanEvalCalls) + assertEquals(1, error2.booleanEvalCalls) + assertEquals(1, notFound.booleanEvalCalls) + } +} \ No newline at end of file diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt new file mode 100644 index 0000000..60eec8d --- /dev/null +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt @@ -0,0 +1,452 @@ +package dev.openfeature.kotlin.sdk.multiprovider + +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.OpenFeatureStatus +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.OpenFeatureError +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class MultiProviderTests { + + @Test + fun uniqueChildNamesAreAssignedForDuplicates() { + val p1 = FakeEventProvider(name = "Provider") + val p2 = FakeEventProvider(name = "Provider") + val p3 = FakeEventProvider(name = "ProviderNew") + + val multi = MultiProvider(listOf(p1, p2, p3)) + + // All providers should be present as children + assertEquals(3, multi.getProviderCount()) + + // Original metadata should be keyed by unique child names + val keys = multi.metadata.originalMetadata.keys + assertTrue(keys.contains("Provider_1")) + assertTrue(keys.contains("Provider_2")) + assertTrue(keys.contains("ProviderNew")) + } + + @Test + fun metadataIncludesOriginalMetadataAndHandlesUnnamedProviders() { + val named = FakeEventProvider(name = "A") + val unnamed = FakeEventProvider(name = null) + + val multi = MultiProvider(listOf(named, unnamed)) + + val original = multi.metadata.originalMetadata + + // Contains the named provider key mapping to some metadata + assertTrue(original.containsKey("A")) + assertNotNull(original["A"], "Original metadata should include entry for named provider") + + // Contains at least one generated key for unnamed providers + val unnamedKey = original.keys.firstOrNull { it.startsWith("") } + assertNotNull(original[unnamedKey], "Original metadata should include entry for unnamed provider") + } + + @Test + fun childProviderNamingIsStableAndSuffixedPerBaseNameInOrder() { + val unnamed1 = FakeEventProvider(name = null) + val x1 = FakeEventProvider(name = "X") + val unnamed2 = FakeEventProvider(name = null) + val x2 = FakeEventProvider(name = "X") + val y = FakeEventProvider(name = "Y") + + val multi = MultiProvider(listOf(unnamed1, x1, unnamed2, x2, y)) + + val keysInOrder = multi.metadata.originalMetadata.keys.toList() + + // Unnamed providers get "_1", "_2" in order of appearance + // Duplicate named providers get suffixed per base name in order + // Singletons keep their base name without suffix + assertEquals(listOf("_1", "X_1", "_2", "X_2", "Y"), keysInOrder) + } + + @Test + fun forwardsLifecycleCallsToUnderlyingProviders() = runTest { + val provider = FakeEventProvider(name = "p") + val multi = MultiProvider(listOf(provider)) + + val initJob = launch { multi.initialize(null) } + advanceUntilIdle() + assertEquals(1, provider.initializeCalls) + + val ctx = ImmutableContext("user-123") + multi.onContextSet(null, ctx) + assertEquals(1, provider.onContextSetCalls) + + multi.shutdown() + assertEquals(1, provider.shutdownCalls) + initJob.cancelAndJoin() + } + + @Test + fun observesEventsAndAppliesPrecedenceAfterConfigurationChange() = runTest { + // Including ProviderConfigurationChanged first allows subsequent lower-precedence READY to emit + val provider = FakeEventProvider( + name = "p", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderReady, + OpenFeatureProviderEvents.ProviderStale + ) + ) + val multi = MultiProvider(listOf(provider)) + + val initJob = launch { multi.initialize(null) } + advanceUntilIdle() + + // The last emitted event should be STALE given the sequence above + val last = multi.observe().first() + assertEquals(OpenFeatureProviderEvents.ProviderStale, last) + initJob.cancelAndJoin() + } + + @Test + fun usesStrategyForEvaluationsAndPreservesOrderIncludingDuplicates() { + val p1 = FakeEventProvider(name = "A") + val dup = FakeEventProvider(name = "A") + val p2 = FakeEventProvider(name = "B") + + val recorder = RecordingStrategy(returnValue = ProviderEvaluation(true)) + val multi = MultiProvider(listOf(p1, dup, p2), strategy = recorder) + + val eval = multi.getBooleanEvaluation("flag", false, null) + + assertEquals(true, eval.value) + // The strategy receives all providers in order; duplicates are preserved + assertEquals(listOf("A", "A", "B"), recorder.lastProviderNames) + } + + @Test + fun aggregatesEventPrecedenceAcrossMultipleProviders() = runTest { + val a = FakeEventProvider( + name = "A", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderReady + ) + ) + val b = FakeEventProvider( + name = "B", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderStale + ) + ) + val c = FakeEventProvider( + name = "C", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderNotReady, + OpenFeatureProviderEvents.ProviderError( + OpenFeatureError.GeneralError("boom") + ) + ) + ) + val multi = MultiProvider(listOf(a, b, c)) + + val initJob = launch { multi.initialize(null) } + advanceUntilIdle() + + // Final aggregate status should be ERROR (no providers remain NOT_READY) + val finalStatus = multi.statusFlow.value + assertIs(finalStatus) + initJob.cancelAndJoin() + } + + @Test + fun emitsProviderErrorWhenFatalOverridesAll() = runTest { + val a = FakeEventProvider( + name = "A", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderReady + ) + ) + val b = FakeEventProvider( + name = "B", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderError( + OpenFeatureError.ProviderFatalError("fatal") + ) + ) + ) + val multi = MultiProvider(listOf(a, b)) + + val initJob = launch { multi.initialize(null) } + advanceUntilIdle() + + val finalStatus = multi.statusFlow.value + val errStatus = assertIs(finalStatus) + assertIs(errStatus.error) + initJob.cancelAndJoin() + } + + @Test + fun errorOverridesReadyButStaleDoesNotOverrideError() = runTest { + val a = FakeEventProvider( + name = "A", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderReady + ) + ) + val b = FakeEventProvider( + name = "B", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderError( + OpenFeatureError.GeneralError("oops") + ) + ) + ) + val c = FakeEventProvider( + name = "C", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderStale + ) + ) + + val multi = MultiProvider(listOf(a, b, c)) + + val initJob = launch { multi.initialize(null) } + advanceUntilIdle() + + val finalStatus = multi.statusFlow.value + assertIs(finalStatus) + initJob.cancelAndJoin() + } + + @Test + fun notReadyOutRanksErrorAndStale() = runTest { + val a = FakeEventProvider( + name = "A", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderNotReady + ) + ) + val b = FakeEventProvider( + name = "B", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderError(OpenFeatureError.GeneralError("e")) + ) + ) + val c = FakeEventProvider( + name = "C", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderStale + ) + ) + val multi = MultiProvider(listOf(a, b, c)) + + val initJob = launch { multi.initialize(null) } + advanceUntilIdle() + + val finalStatus = multi.statusFlow.value + assertIs(finalStatus) + initJob.cancelAndJoin() + } + + @Test + fun emitsEventsOnlyOnStatusChange() = runTest { + val provider = FakeEventProvider( + name = "A", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderReady, + OpenFeatureProviderEvents.ProviderReady, + OpenFeatureProviderEvents.ProviderStale + ) + ) + val multi = MultiProvider(listOf(provider)) + + val collected = mutableListOf() + val collectJob = launch { multi.observe().collect { collected.add(it) } } + + val initJob = launch { multi.initialize(null) } + advanceUntilIdle() + + collectJob.cancelAndJoin() + initJob.cancelAndJoin() + + val nonConfig = collected.filter { it !is OpenFeatureProviderEvents.ProviderConfigurationChanged } + // Should only emit Ready once (transition) and Stale once (transition) + assertEquals( + listOf(OpenFeatureProviderEvents.ProviderReady, OpenFeatureProviderEvents.ProviderStale), + nonConfig + ) + } + + @Test + fun configurationChangedIsAlwaysEmitted() = runTest { + val provider = FakeEventProvider( + name = "A", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderConfigurationChanged + ) + ) + val multi = MultiProvider(listOf(provider)) + + val collected = mutableListOf() + val collectJob = launch { multi.observe().collect { collected.add(it) } } + + val initJob = launch { multi.initialize(null) } + advanceUntilIdle() + + collectJob.cancelAndJoin() + initJob.cancelAndJoin() + + // Only configuration changed events should have been emitted + assertEquals(2, collected.size) + assertTrue(collected.all { it is OpenFeatureProviderEvents.ProviderConfigurationChanged }) + } + + @Test + fun shutdownAggregatesErrorsAndReportsProviderNames() { + val ok = FakeEventProvider(name = "ok") + val bad1 = FakeEventProvider(name = "bad1", shutdownThrowable = IllegalStateException("oops1")) + val bad2 = FakeEventProvider(name = null, shutdownThrowable = RuntimeException("oops2")) + + val multi = MultiProvider(listOf(ok, bad1, bad2)) + + val error = assertFailsWith { + multi.shutdown() + } + + // Message contains each provider and message on separate lines + val msg = error.message + assertTrue(msg.contains("bad1: oops1")) + // unnamed should be rendered as "" + assertTrue(msg.contains(": oops2")) + + // Suppressed should include one per failure + assertEquals(2, error.suppressedExceptions.size) + val suppressedMessages = error.suppressedExceptions.map { it.message ?: "" } + assertTrue(suppressedMessages.any { it.contains("Provider 'bad1' shutdown failed: oops1") }) + assertTrue(suppressedMessages.any { it.contains("Provider '' shutdown failed: oops2") }) + } +} + +// Helpers + +private class FakeEventProvider( + private val name: String?, + private val eventsToEmitOnInit: List = emptyList(), + private val shutdownThrowable: Throwable? = null +) : FeatureProvider { + override val hooks: List> = emptyList() + override val metadata: ProviderMetadata = object : ProviderMetadata { + override val name: String? = this@FakeEventProvider.name + } + + private val events = MutableSharedFlow(replay = 1, extraBufferCapacity = 16) + + var initializeCalls: Int = 0 + private set + var shutdownCalls: Int = 0 + private set + var onContextSetCalls: Int = 0 + private set + + override suspend fun initialize(initialContext: EvaluationContext?) { + initializeCalls += 1 + // Emit any preconfigured events during initialize so MultiProvider observers receive them + eventsToEmitOnInit.forEach { events.emit(it) } + } + + override fun shutdown() { + shutdownCalls += 1 + shutdownThrowable?.let { throw it } + } + + override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { + onContextSetCalls += 1 + } + + override fun getBooleanEvaluation( + key: String, + defaultValue: Boolean, + context: EvaluationContext? + ): ProviderEvaluation { + return ProviderEvaluation(defaultValue) + } + + override fun getStringEvaluation( + key: String, + defaultValue: String, + context: EvaluationContext? + ): ProviderEvaluation { + return ProviderEvaluation(defaultValue) + } + + override fun getIntegerEvaluation( + key: String, + defaultValue: Int, + context: EvaluationContext? + ): ProviderEvaluation { + return ProviderEvaluation(defaultValue) + } + + override fun getDoubleEvaluation( + key: String, + defaultValue: Double, + context: EvaluationContext? + ): ProviderEvaluation { + return ProviderEvaluation(defaultValue) + } + + override fun getObjectEvaluation( + key: String, + defaultValue: Value, + context: EvaluationContext? + ): ProviderEvaluation { + return ProviderEvaluation(defaultValue) + } + + override fun observe(): Flow = events +} + +private class RecordingStrategy( + private val returnValue: ProviderEvaluation +) : MultiProvider.Strategy { + var lastProviderNames: List = emptyList() + private set + + override fun evaluate( + providers: List, + key: String, + defaultValue: T, + evaluationContext: EvaluationContext?, + flagEval: FlagEval + ): ProviderEvaluation { + lastProviderNames = providers.map { it.metadata.name.orEmpty() } + @Suppress("UNCHECKED_CAST") + return returnValue as ProviderEvaluation + } +} \ No newline at end of file