From 33d2c60755cbc1f42d20b05182d85ad146b0b3f4 Mon Sep 17 00:00:00 2001 From: vishalup29 Date: Sun, 7 Dec 2025 19:58:15 -0600 Subject: [PATCH] Issue open-feature#1486 Move multi-provider into SDK, mark as experimental, and deprecate contrib implementation. Signed-off-by: vishalup29 --- README.md | 36 ++- .../sdk/multiprovider/FirstMatchStrategy.java | 67 ++++++ .../FirstSuccessfulStrategy.java | 51 +++++ .../sdk/multiprovider/MultiProvider.java | 186 +++++++++++++++ .../multiprovider/MultiProviderMetadata.java | 20 ++ .../sdk/multiprovider/Strategy.java | 38 ++++ .../sdk/multiprovider/BaseStrategyTest.java | 214 ++++++++++++++++++ .../multiprovider/FirstMatchStrategyTest.java | 91 ++++++++ .../FirstSuccessfulStrategyTest.java | 78 +++++++ .../sdk/multiprovider/MultiProviderTest.java | 163 +++++++++++++ 10 files changed, 943 insertions(+), 1 deletion(-) create mode 100644 src/main/java/dev/openfeature/sdk/multiprovider/FirstMatchStrategy.java create mode 100644 src/main/java/dev/openfeature/sdk/multiprovider/FirstSuccessfulStrategy.java create mode 100644 src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java create mode 100644 src/main/java/dev/openfeature/sdk/multiprovider/MultiProviderMetadata.java create mode 100644 src/main/java/dev/openfeature/sdk/multiprovider/Strategy.java create mode 100644 src/test/java/dev/openfeature/sdk/multiprovider/BaseStrategyTest.java create mode 100644 src/test/java/dev/openfeature/sdk/multiprovider/FirstMatchStrategyTest.java create mode 100644 src/test/java/dev/openfeature/sdk/multiprovider/FirstSuccessfulStrategyTest.java create mode 100644 src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderTest.java diff --git a/README.md b/README.md index 9a8f93aa2..b64aa3c46 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ See [here](https://javadoc.io/doc/dev.openfeature/sdk/latest/) for the Javadocs. | ------ |---------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------| | ✅ | [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. | | ✅ | [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). | +| ✅ | [Multi-provider (experimental)](#multi-provider-experimental) | Combine multiple providers and delegate evaluations according to a strategy. | | ✅ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. | | ✅ | [Tracking](#tracking) | Associate user actions with feature flag evaluations. | | ✅ | [Logging](#logging) | Integrate with popular logging packages. | @@ -147,7 +148,40 @@ Look [here](https://openfeature.dev/ecosystem?instant_search%5BrefinementList%5D If the provider you're looking for hasn't been created yet, see the [develop a provider](#develop-a-provider) section to learn how to build it yourself. Once you've added a provider as a dependency, it can be registered with OpenFeature like this: - + +In some situations, it may be beneficial to register multiple providers in the same application. +This is possible using [domains](#domains), which is covered in more detail below. + +#### Multi-provider (experimental) + +In addition to domains, you may want to delegate flag evaluation across multiple providers using a configurable strategy. +The multi-provider allows you to compose several `FeatureProvider` implementations and determine which provider's result to use. + +> **Experimental:** This API is experimental and may change in future releases. + +```java +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.multiprovider.MultiProvider; + +import java.util.List; + +public void multiProviderExample() throws Exception { + FeatureProvider primaryProvider = new MyPrimaryProvider(); + FeatureProvider fallbackProvider = new MyFallbackProvider(); + + MultiProvider multiProvider = new MultiProvider(List.of(primaryProvider, fallbackProvider)); + + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProviderAndWait(multiProvider); + + Client client = api.getClient(); + boolean value = client.getBooleanValue("some-flag", false); +} +``` + + #### Synchronous To register a provider in a blocking manner to ensure it is ready before further actions are taken, you can use the `setProviderAndWait` method as shown below: diff --git a/src/main/java/dev/openfeature/sdk/multiprovider/FirstMatchStrategy.java b/src/main/java/dev/openfeature/sdk/multiprovider/FirstMatchStrategy.java new file mode 100644 index 000000000..6a640b046 --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/multiprovider/FirstMatchStrategy.java @@ -0,0 +1,67 @@ +package dev.openfeature.sdk.multiprovider; + +import static dev.openfeature.sdk.ErrorCode.FLAG_NOT_FOUND; + +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.exceptions.FlagNotFoundError; +import java.util.Map; +import java.util.function.Function; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * First match strategy. + * + *

Return the first result returned by a provider. + *

+ * As soon as a non-{@code FLAG_NOT_FOUND} result is returned by a provider (success or other error), + * the rest of the operation short-circuits and does not call the remaining providers. + */ +@Slf4j +@NoArgsConstructor +public class FirstMatchStrategy implements Strategy { + + @Override + public ProviderEvaluation evaluate( + Map providers, + String key, + T defaultValue, + EvaluationContext ctx, + Function> providerFunction) { + for (FeatureProvider provider : providers.values()) { + try { + ProviderEvaluation res = providerFunction.apply(provider); + ErrorCode errorCode = res.getErrorCode(); + if (errorCode == null) { + // Successful evaluation + return res; + } + if (!FLAG_NOT_FOUND.equals(errorCode)) { + // Any non-FLAG_NOT_FOUND error bubbles up + return res; + } + // else FLAG_NOT_FOUND: skip to next provider + } catch (FlagNotFoundError e) { + log.debug( + "flag not found {} in provider {}", + key, + provider.getMetadata().getName(), + e); + } + } + + // All providers either threw or returned FLAG_NOT_FOUND + return ProviderEvaluation.builder() + .errorMessage("Flag not found in any provider") + .errorCode(FLAG_NOT_FOUND) + .build(); + } +} diff --git a/src/main/java/dev/openfeature/sdk/multiprovider/FirstSuccessfulStrategy.java b/src/main/java/dev/openfeature/sdk/multiprovider/FirstSuccessfulStrategy.java new file mode 100644 index 000000000..2adaf3978 --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/multiprovider/FirstSuccessfulStrategy.java @@ -0,0 +1,51 @@ +package dev.openfeature.sdk.multiprovider; + +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.ProviderEvaluation; +import java.util.Map; +import java.util.function.Function; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * First Successful Strategy. + * + *

Similar to “First Match”, except that errors from evaluated providers do not halt execution. + * Instead, it returns the first successful result from a provider. If no provider successfully + * responds, it returns a {@code GENERAL} error result. + */ +@Slf4j +@NoArgsConstructor +public class FirstSuccessfulStrategy implements Strategy { + + @Override + public ProviderEvaluation evaluate( + Map providers, + String key, + T defaultValue, + EvaluationContext ctx, + Function> providerFunction) { + for (FeatureProvider provider : providers.values()) { + try { + ProviderEvaluation res = providerFunction.apply(provider); + if (res.getErrorCode() == null) { + // First successful result (no error code) + return res; + } + } catch (Exception e) { + log.debug( + "evaluation exception for key {} in provider {}", + key, + provider.getMetadata().getName(), + e); + } + } + + return ProviderEvaluation.builder() + .errorMessage("No provider successfully responded") + .errorCode(ErrorCode.GENERAL) + .build(); + } +} diff --git a/src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java b/src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java new file mode 100644 index 000000000..803dd9458 --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java @@ -0,0 +1,186 @@ +package dev.openfeature.sdk.multiprovider; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.EventProvider; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.Metadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Experimental: Provider implementation for multi-provider. + * + *

This provider delegates flag evaluations to multiple underlying providers using a configurable + * {@link Strategy}. It also exposes combined metadata containing the original metadata of each + * underlying provider. + */ +@Slf4j +public class MultiProvider extends EventProvider { + + @Getter + private static final String NAME = "multiprovider"; + + public static final int INIT_THREADS_COUNT = 8; + + private final Map providers; + private final Strategy strategy; + private MultiProviderMetadata metadata; + + /** + * Constructs a MultiProvider with the given list of FeatureProviders, by default uses + * {@link FirstMatchStrategy}. + * + * @param providers the list of FeatureProviders to initialize the MultiProvider with + */ + public MultiProvider(List providers) { + this(providers, null); + } + + /** + * Constructs a MultiProvider with the given list of FeatureProviders and a strategy. + * + * @param providers the list of FeatureProviders to initialize the MultiProvider with + * @param strategy the strategy (if {@code null}, {@link FirstMatchStrategy} is used) + */ + public MultiProvider(List providers, Strategy strategy) { + this.providers = buildProviders(providers); + if (strategy != null) { + this.strategy = strategy; + } else { + this.strategy = new FirstMatchStrategy(); + } + } + + protected static Map buildProviders(List providers) { + Map providersMap = new LinkedHashMap<>(providers.size()); + for (FeatureProvider provider : providers) { + FeatureProvider prevProvider = + providersMap.put(provider.getMetadata().getName(), provider); + if (prevProvider != null) { + log.warn("duplicated provider name: {}", provider.getMetadata().getName()); + } + } + return Collections.unmodifiableMap(providersMap); + } + + /** + * Initialize the provider. + * + * @param evaluationContext evaluation context + * @throws Exception on error (e.g. wrapped {@link java.util.concurrent.ExecutionException} + * from a failing provider) + */ + @Override + public void initialize(EvaluationContext evaluationContext) throws Exception { + var metadataBuilder = MultiProviderMetadata.builder().name(NAME); + HashMap providersMetadata = new HashMap<>(); + + if (providers.isEmpty()) { + metadataBuilder.originalMetadata(Collections.unmodifiableMap(providersMetadata)); + metadata = metadataBuilder.build(); + return; + } + + ExecutorService executorService = Executors.newFixedThreadPool(Math.min(INIT_THREADS_COUNT, providers.size())); + try { + Collection> tasks = new ArrayList<>(providers.size()); + for (FeatureProvider provider : providers.values()) { + tasks.add(() -> { + provider.initialize(evaluationContext); + return null; + }); + Metadata providerMetadata = provider.getMetadata(); + providersMetadata.put(providerMetadata.getName(), providerMetadata); + } + + metadataBuilder.originalMetadata(Collections.unmodifiableMap(providersMetadata)); + + List> results = executorService.invokeAll(tasks); + for (Future result : results) { + // This will re-throw any exception from the provider's initialize method, + // wrapped in an ExecutionException. + result.get(); + } + } catch (Exception e) { + // If initialization fails for any provider, attempt to shut down all providers + // to avoid a partial/limbo state. + for (FeatureProvider provider : providers.values()) { + try { + provider.shutdown(); + } catch (Exception shutdownEx) { + log.error( + "error shutting down provider {} after failed initialize", + provider.getMetadata().getName(), + shutdownEx); + } + } + throw e; + } finally { + executorService.shutdown(); + } + + metadata = metadataBuilder.build(); + } + + @SuppressFBWarnings(value = "EI_EXPOSE_REP") + @Override + public Metadata getMetadata() { + return metadata; + } + + @Override + public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { + return strategy.evaluate( + providers, key, defaultValue, ctx, p -> p.getBooleanEvaluation(key, defaultValue, ctx)); + } + + @Override + public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { + return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getStringEvaluation(key, defaultValue, ctx)); + } + + @Override + public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { + return strategy.evaluate( + providers, key, defaultValue, ctx, p -> p.getIntegerEvaluation(key, defaultValue, ctx)); + } + + @Override + public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { + return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getDoubleEvaluation(key, defaultValue, ctx)); + } + + @Override + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { + return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getObjectEvaluation(key, defaultValue, ctx)); + } + + @Override + public void shutdown() { + log.debug("shutdown begin"); + for (FeatureProvider provider : providers.values()) { + try { + provider.shutdown(); + } catch (Exception e) { + log.error("error shutdown provider {}", provider.getMetadata().getName(), e); + } + } + log.debug("shutdown end"); + // Important: ensure EventProvider's executor is also shut down + super.shutdown(); + } +} diff --git a/src/main/java/dev/openfeature/sdk/multiprovider/MultiProviderMetadata.java b/src/main/java/dev/openfeature/sdk/multiprovider/MultiProviderMetadata.java new file mode 100644 index 000000000..2f1bbfd46 --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/multiprovider/MultiProviderMetadata.java @@ -0,0 +1,20 @@ +package dev.openfeature.sdk.multiprovider; + +import dev.openfeature.sdk.Metadata; +import java.util.Map; +import lombok.Builder; +import lombok.Value; + +/** + * Metadata for {@link MultiProvider}. + * + *

Contains the multiprovider's own name and a map of the original metadata from each underlying + * provider. + */ +@Value +@Builder +public class MultiProviderMetadata implements Metadata { + + String name; + Map originalMetadata; +} diff --git a/src/main/java/dev/openfeature/sdk/multiprovider/Strategy.java b/src/main/java/dev/openfeature/sdk/multiprovider/Strategy.java new file mode 100644 index 000000000..4c25fe8f0 --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/multiprovider/Strategy.java @@ -0,0 +1,38 @@ +package dev.openfeature.sdk.multiprovider; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.ProviderEvaluation; +import java.util.Map; +import java.util.function.Function; + +/** + * Strategy for determining how to evaluate a flag across multiple providers. + * + *

Implementations decide how to: + *

    + *
  • Order or select providers
  • + *
  • Handle {@code FLAG_NOT_FOUND} results
  • + *
  • Handle errors and exceptions from providers
  • + *
+ */ +public interface Strategy { + + /** + * Evaluate a flag across multiple providers. + * + * @param providers ordered map of provider name to provider + * @param key the flag key to evaluate + * @param defaultValue the default value to fall back to + * @param ctx the evaluation context (may be {@code null}) + * @param providerFunction function that executes the provider evaluation for the given key + * @param the flag value type + * @return the resolved {@link ProviderEvaluation} + */ + ProviderEvaluation evaluate( + Map providers, + String key, + T defaultValue, + EvaluationContext ctx, + Function> providerFunction); +} diff --git a/src/test/java/dev/openfeature/sdk/multiprovider/BaseStrategyTest.java b/src/test/java/dev/openfeature/sdk/multiprovider/BaseStrategyTest.java new file mode 100644 index 000000000..405a2f094 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/multiprovider/BaseStrategyTest.java @@ -0,0 +1,214 @@ +package dev.openfeature.sdk.multiprovider; + +import static dev.openfeature.sdk.ErrorCode.FLAG_NOT_FOUND; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.Metadata; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.providers.memory.Flag; +import dev.openfeature.sdk.providers.memory.InMemoryProvider; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; + +public abstract class BaseStrategyTest { + + protected FeatureProvider mockProvider1; + protected FeatureProvider mockProvider2; + protected FeatureProvider mockProvider3; + + protected Metadata mockMetaData1; + protected Metadata mockMetaData2; + protected Metadata mockMetaData3; + + protected InMemoryProvider inMemoryProvider1; + protected InMemoryProvider inMemoryProvider2; + + protected Map orderedProviders; + + protected EvaluationContext contextWithNewProvider; + + protected static final String FLAG_KEY = "test-flag"; + protected static final String DEFAULT_STRING = "default"; + protected static final boolean DEFAULT_BOOLEAN = false; + protected static final int DEFAULT_INTEGER = 0; + protected static final double DEFAULT_DOUBLE = 0.0; + + @BeforeEach + void setUp() { + setupMockProviders(); + setupInMemoryProviders(); + setupOrderedProviders(); + setupEvaluationContexts(); + } + + protected void setupMockProviders() { + mockProvider1 = mock(FeatureProvider.class); + mockProvider2 = mock(FeatureProvider.class); + mockProvider3 = mock(FeatureProvider.class); + mockMetaData1 = mock(Metadata.class); + mockMetaData2 = mock(Metadata.class); + mockMetaData3 = mock(Metadata.class); + when(mockMetaData1.getName()).thenReturn("provider1"); + when(mockMetaData2.getName()).thenReturn("provider2"); + when(mockMetaData3.getName()).thenReturn("provider3"); + when(mockProvider1.getMetadata()).thenReturn(mockMetaData1); + when(mockProvider2.getMetadata()).thenReturn(mockMetaData2); + when(mockProvider3.getMetadata()).thenReturn(mockMetaData3); + } + + protected void setupInMemoryProviders() { + Map> flags1 = createFlags1(); + Map> flags2 = createFlags2(); + + inMemoryProvider1 = new InMemoryProvider(flags1) { + @Override + public Metadata getMetadata() { + return () -> "old-provider"; + } + }; + + inMemoryProvider2 = new InMemoryProvider(flags2) { + @Override + public Metadata getMetadata() { + return () -> "new-provider"; + } + }; + } + + protected void setupOrderedProviders() { + orderedProviders = new LinkedHashMap<>(); + orderedProviders.put("provider1", mockProvider1); + orderedProviders.put("provider2", mockProvider2); + orderedProviders.put("provider3", mockProvider3); + } + + protected void setupEvaluationContexts() { + contextWithNewProvider = new MutableContext().add("provider", "new-provider"); + } + + protected Map> createFlags1() { + Map> flags = new HashMap<>(); + + flags.put( + "b1", + Flag.builder() + .variant("on", true) + .variant("off", false) + .defaultVariant("on") + .build()); + + flags.put( + "i1", + Flag.builder().variant("default", 1).defaultVariant("default").build()); + + flags.put( + "d1", + Flag.builder().variant("default", 1.0).defaultVariant("default").build()); + + flags.put( + "s1", + Flag.builder() + .variant("default", "str1") + .defaultVariant("default") + .build()); + + flags.put( + "o1", + Flag.builder() + .variant("default", new Value("v1")) + .defaultVariant("default") + .build()); + + return flags; + } + + protected Map> createFlags2() { + Map> flags = new HashMap<>(); + + flags.put( + "b1", + Flag.builder() + .variant("on", true) + .variant("off", false) + .defaultVariant("off") + .build()); + + flags.put( + "i1", + Flag.builder().variant("default", 2).defaultVariant("default").build()); + + flags.put( + "d1", + Flag.builder().variant("default", 2.0).defaultVariant("default").build()); + + flags.put( + "s1", + Flag.builder() + .variant("default", "str2") + .defaultVariant("default") + .build()); + + flags.put( + "o1", + Flag.builder() + .variant("default", new Value("v2")) + .defaultVariant("default") + .build()); + + flags.put( + "s2", + Flag.builder() + .variant("default", "s2str2") + .defaultVariant("default") + .build()); + + return flags; + } + + protected ProviderEvaluation createErrorResult(ErrorCode errorCode) { + ProviderEvaluation result = mock(ProviderEvaluation.class); + when(result.getErrorCode()).thenReturn(errorCode); + return result; + } + + protected void setupProviderFlagNotFound(FeatureProvider provider) { + ProviderEvaluation stringResult = createErrorResult(FLAG_NOT_FOUND); + ProviderEvaluation booleanResult = createErrorResult(FLAG_NOT_FOUND); + ProviderEvaluation integerResult = createErrorResult(FLAG_NOT_FOUND); + ProviderEvaluation doubleResult = createErrorResult(FLAG_NOT_FOUND); + ProviderEvaluation objectResult = createErrorResult(FLAG_NOT_FOUND); + + when(provider.getStringEvaluation(BaseStrategyTest.FLAG_KEY, DEFAULT_STRING, null)) + .thenReturn(stringResult); + when(provider.getBooleanEvaluation(BaseStrategyTest.FLAG_KEY, DEFAULT_BOOLEAN, null)) + .thenReturn(booleanResult); + when(provider.getIntegerEvaluation(BaseStrategyTest.FLAG_KEY, DEFAULT_INTEGER, null)) + .thenReturn(integerResult); + when(provider.getDoubleEvaluation(BaseStrategyTest.FLAG_KEY, DEFAULT_DOUBLE, null)) + .thenReturn(doubleResult); + when(provider.getObjectEvaluation(BaseStrategyTest.FLAG_KEY, null, null)) + .thenReturn(objectResult); + } + + protected void setupProviderError(FeatureProvider provider, ErrorCode errorCode) { + ProviderEvaluation result = createErrorResult(errorCode); + when(provider.getStringEvaluation(BaseStrategyTest.FLAG_KEY, DEFAULT_STRING, null)) + .thenReturn(result); + } + + protected void setupProviderSuccess(FeatureProvider provider, String value) { + ProviderEvaluation result = mock(ProviderEvaluation.class); + when(result.getErrorCode()).thenReturn(null); + when(result.getValue()).thenReturn(value); + when(provider.getStringEvaluation(BaseStrategyTest.FLAG_KEY, DEFAULT_STRING, null)) + .thenReturn(result); + } +} diff --git a/src/test/java/dev/openfeature/sdk/multiprovider/FirstMatchStrategyTest.java b/src/test/java/dev/openfeature/sdk/multiprovider/FirstMatchStrategyTest.java new file mode 100644 index 000000000..0205961cd --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/multiprovider/FirstMatchStrategyTest.java @@ -0,0 +1,91 @@ +package dev.openfeature.sdk.multiprovider; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.ProviderEvaluation; +import org.junit.jupiter.api.Test; + +class FirstMatchStrategyTest extends BaseStrategyTest { + + private final FirstMatchStrategy strategy = new FirstMatchStrategy(); + + @Test + void shouldSkipFlagNotFoundAndReturnFirstMatch() { + setupProviderFlagNotFound(mockProvider1); + setupProviderSuccess(mockProvider2, "success"); + + ProviderEvaluation result = strategy.evaluate( + orderedProviders, + FLAG_KEY, + DEFAULT_STRING, + null, + p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null)); + + assertNotNull(result); + assertEquals("success", result.getValue()); + assertNull(result.getErrorCode()); + } + + @Test + void shouldReturnFirstNonFlagNotFoundError() { + setupProviderError(mockProvider1, ErrorCode.PARSE_ERROR); + setupProviderSuccess(mockProvider2, "success"); + ProviderEvaluation result = strategy.evaluate( + orderedProviders, + FLAG_KEY, + DEFAULT_STRING, + null, + p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null)); + assertEquals(ErrorCode.PARSE_ERROR, result.getErrorCode()); + } + + @Test + void shouldReturnSuccessWhenFirstProviderSucceeds() { + setupProviderSuccess(mockProvider1, "first-success"); + setupProviderFlagNotFound(mockProvider2); + ProviderEvaluation result = strategy.evaluate( + orderedProviders, + FLAG_KEY, + DEFAULT_STRING, + null, + p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null)); + + assertNotNull(result); + assertEquals("first-success", result.getValue()); + assertNull(result.getErrorCode()); + } + + @Test + void shouldThrowFlagNotFoundWhenAllProvidersReturnFlagNotFound() { + setupProviderFlagNotFound(mockProvider1); + setupProviderFlagNotFound(mockProvider2); + setupProviderFlagNotFound(mockProvider3); + ProviderEvaluation providerEvaluation = strategy.evaluate( + orderedProviders, + FLAG_KEY, + DEFAULT_STRING, + null, + p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null)); + + assertEquals(ErrorCode.FLAG_NOT_FOUND, providerEvaluation.getErrorCode()); + assertEquals("Flag not found in any provider", providerEvaluation.getErrorMessage()); + } + + @Test + void shouldSkipMultipleFlagNotFoundAndReturnFirstOtherError() { + setupProviderFlagNotFound(mockProvider1); + setupProviderFlagNotFound(mockProvider2); + setupProviderError(mockProvider3, ErrorCode.PARSE_ERROR); + ProviderEvaluation result = strategy.evaluate( + orderedProviders, + FLAG_KEY, + DEFAULT_STRING, + null, + p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null)); + assertNotNull(result); + assertEquals(ErrorCode.PARSE_ERROR, result.getErrorCode()); + } +} diff --git a/src/test/java/dev/openfeature/sdk/multiprovider/FirstSuccessfulStrategyTest.java b/src/test/java/dev/openfeature/sdk/multiprovider/FirstSuccessfulStrategyTest.java new file mode 100644 index 000000000..a47af8a2f --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/multiprovider/FirstSuccessfulStrategyTest.java @@ -0,0 +1,78 @@ +package dev.openfeature.sdk.multiprovider; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.ProviderEvaluation; +import org.junit.jupiter.api.Test; + +class FirstSuccessfulStrategyTest extends BaseStrategyTest { + + private final FirstSuccessfulStrategy strategy = new FirstSuccessfulStrategy(); + + @Test + void shouldSkipFlagNotFoundAndReturnFirstSuccess() { + setupProviderFlagNotFound(mockProvider1); + setupProviderSuccess(mockProvider2, "success"); + ProviderEvaluation result = strategy.evaluate( + orderedProviders, + FLAG_KEY, + DEFAULT_STRING, + null, + p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null)); + assertNotNull(result); + assertEquals("success", result.getValue()); + assertNull(result.getErrorCode()); + } + + @Test + void shouldThrowGeneralErrorWhenAllProvidersFail() { + setupProviderFlagNotFound(mockProvider1); + setupProviderError(mockProvider2, ErrorCode.PARSE_ERROR); + setupProviderError(mockProvider3, ErrorCode.TYPE_MISMATCH); + ProviderEvaluation providerEvaluation = strategy.evaluate( + orderedProviders, + FLAG_KEY, + DEFAULT_STRING, + null, + p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null)); + + assertEquals(ErrorCode.GENERAL, providerEvaluation.getErrorCode()); + assertEquals("No provider successfully responded", providerEvaluation.getErrorMessage()); + } + + @Test + void shouldSkipProvidersThatOnlyReturnErrors() { + setupProviderError(mockProvider1, ErrorCode.INVALID_CONTEXT); + setupProviderError(mockProvider2, ErrorCode.PROVIDER_NOT_READY); + setupProviderError(mockProvider3, ErrorCode.GENERAL); + + ProviderEvaluation providerEvaluation = strategy.evaluate( + orderedProviders, + FLAG_KEY, + DEFAULT_STRING, + null, + p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null)); + + assertEquals(ErrorCode.GENERAL, providerEvaluation.getErrorCode()); + assertEquals("No provider successfully responded", providerEvaluation.getErrorMessage()); + } + + @Test + void shouldThrowGeneralErrorForNonExistentFlag() { + orderedProviders.clear(); + orderedProviders.put("old-provider", inMemoryProvider1); + orderedProviders.put("new-provider", inMemoryProvider2); + ProviderEvaluation providerEvaluation = strategy.evaluate( + orderedProviders, + FLAG_KEY, + DEFAULT_STRING, + null, + p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null)); + + assertEquals(ErrorCode.GENERAL, providerEvaluation.getErrorCode()); + assertEquals("No provider successfully responded", providerEvaluation.getErrorMessage()); + } +} diff --git a/src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderTest.java b/src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderTest.java new file mode 100644 index 000000000..e4a387e45 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderTest.java @@ -0,0 +1,163 @@ +package dev.openfeature.sdk.multiprovider; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.Metadata; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.GeneralError; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.function.Function; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; + +class MultiProviderTest extends BaseStrategyTest { + + @Test + void shouldInitializeWithEmptyProvidersList() { + MultiProvider multiProvider = new MultiProvider(new ArrayList<>()); + assertDoesNotThrow(() -> multiProvider.initialize(null)); + assertNotNull(multiProvider.getMetadata()); + assertEquals(MultiProvider.getNAME(), multiProvider.getMetadata().getName()); + } + + @SneakyThrows + @Test + void shouldInitializeSuccessfully() { + List providers = new ArrayList<>(2); + providers.add(mockProvider1); + providers.add(mockProvider2); + Strategy strategy = mock(Strategy.class); + MultiProvider multiProvider = new MultiProvider(providers, strategy); + multiProvider.initialize(null); + + MultiProviderMetadata metadata = (MultiProviderMetadata) multiProvider.getMetadata(); + Map map = metadata.getOriginalMetadata(); + assertEquals(mockMetaData1, map.get(mockProvider1.getMetadata().getName())); + assertEquals(mockMetaData2, map.get(mockProvider2.getMetadata().getName())); + assertEquals("multiprovider", multiProvider.getMetadata().getName()); + } + + @SneakyThrows + @Test + void shouldHandleInitializationFailure() { + doThrow(new GeneralError()).when(mockProvider1).initialize(any()); + doThrow(new GeneralError()).when(mockProvider1).shutdown(); + List providers = new ArrayList<>(2); + providers.add(mockProvider1); + providers.add(mockProvider2); + Strategy strategy = mock(Strategy.class); + MultiProvider multiProvider = new MultiProvider(providers, strategy); + assertThrows(ExecutionException.class, () -> multiProvider.initialize(null)); + assertDoesNotThrow(multiProvider::shutdown); + } + + @Test + void shouldHandleDuplicateProviderNames() { + when(mockProvider1.getMetadata()).thenReturn(() -> "provider"); + when(mockProvider2.getMetadata()).thenReturn(() -> "provider"); + List providers = new ArrayList<>(2); + providers.add(mockProvider1); + providers.add(mockProvider2); + assertDoesNotThrow(() -> new MultiProvider(providers, null).initialize(null)); + } + + @SneakyThrows + @Test + void shouldRetrieveCorrectMetadataName() { + List providers = new ArrayList<>(); + providers.add(mockProvider1); + Strategy mockStrategy = mock(Strategy.class); + MultiProvider multiProvider = new MultiProvider(providers, mockStrategy); + multiProvider.initialize(null); + MultiProviderMetadata metadata = (MultiProviderMetadata) multiProvider.getMetadata(); + Map map = metadata.getOriginalMetadata(); + assertEquals(mockMetaData1, map.get(mockProvider1.getMetadata().getName())); + } + + @SneakyThrows + @Test + void shouldUseDefaultFirstMatchStrategy() { + List providers = new ArrayList<>(2); + providers.add(inMemoryProvider1); + providers.add(inMemoryProvider2); + MultiProvider multiProvider = new MultiProvider(providers); + multiProvider.initialize(null); + assertEquals(true, multiProvider.getBooleanEvaluation("b1", false, null).getValue()); + assertEquals(1, multiProvider.getIntegerEvaluation("i1", 0, null).getValue()); + assertEquals(1.0, multiProvider.getDoubleEvaluation("d1", 0.0, null).getValue()); + assertEquals("str1", multiProvider.getStringEvaluation("s1", "", null).getValue()); + assertEquals( + "v1", + multiProvider.getObjectEvaluation("o1", null, null).getValue().asString()); + + ProviderEvaluation providerEvaluation = multiProvider.getStringEvaluation("non-existing", "", null); + assertEquals(ErrorCode.FLAG_NOT_FOUND, providerEvaluation.getErrorCode()); + assertEquals("Flag not found in any provider", providerEvaluation.getErrorMessage()); + } + + @SneakyThrows + @Test + void shouldWorkWithCustomStrategy() { + Strategy customStrategy = new Strategy() { + final FirstMatchStrategy fallbackStrategy = new FirstMatchStrategy(); + + @Override + public ProviderEvaluation evaluate( + Map providers, + String key, + T defaultValue, + EvaluationContext ctx, + Function> providerFunction) { + + Value contextProvider = null; + if (ctx != null) { + contextProvider = ctx.getValue("provider"); + } + + if (contextProvider != null && "new-provider".equals(contextProvider.asString())) { + return providerFunction.apply(providers.get("new-provider")); + } + return fallbackStrategy.evaluate(providers, key, defaultValue, ctx, providerFunction); + } + }; + + List providers = new ArrayList<>(2); + providers.add(inMemoryProvider1); + providers.add(inMemoryProvider2); + MultiProvider multiProvider = new MultiProvider(providers, customStrategy); + multiProvider.initialize(null); + EvaluationContext context = new MutableContext().add("provider", "new-provider"); + assertEquals( + false, multiProvider.getBooleanEvaluation("b1", true, context).getValue()); + assertEquals(true, multiProvider.getBooleanEvaluation("b1", true, null).getValue()); + } + + @SneakyThrows + @Test + void shouldSupportAllEvaluationTypes() { + List providers = new ArrayList<>(1); + providers.add(inMemoryProvider1); + MultiProvider multiProvider = new MultiProvider(providers); + multiProvider.initialize(null); + assertNotNull(multiProvider.getBooleanEvaluation("b1", false, null)); + assertNotNull(multiProvider.getIntegerEvaluation("i1", 0, null)); + assertNotNull(multiProvider.getDoubleEvaluation("d1", 0.0, null)); + assertNotNull(multiProvider.getStringEvaluation("s1", "", null)); + assertNotNull(multiProvider.getObjectEvaluation("o1", null, null)); + } +}