diff --git a/pom.xml b/pom.xml index ada93b719..0a4dbfbfe 100644 --- a/pom.xml +++ b/pom.xml @@ -168,6 +168,31 @@ test + + com.fasterxml.jackson.core + jackson-core + test + + + + com.fasterxml.jackson.core + jackson-annotations + test + + + + com.fasterxml.jackson.core + jackson-databind + test + + + + dev.cel + cel + 0.10.1 + test + + @@ -191,6 +216,14 @@ + + com.fasterxml.jackson + jackson-bom + 2.16.1 + pom + import + + io.cucumber cucumber-bom diff --git a/spec b/spec index d4a9a9109..e33a15e92 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit d4a9a910946eded57cf82d6fd4921785a5e64c2b +Subproject commit e33a15e92bd0e45f0de087e7e55ee7e87f952c29 diff --git a/src/main/java/dev/openfeature/sdk/providers/memory/Flag.java b/src/main/java/dev/openfeature/sdk/providers/memory/Flag.java index f2dc6b495..4422dc51f 100644 --- a/src/main/java/dev/openfeature/sdk/providers/memory/Flag.java +++ b/src/main/java/dev/openfeature/sdk/providers/memory/Flag.java @@ -20,4 +20,5 @@ public class Flag { private String defaultVariant; private ContextEvaluator contextEvaluator; private ImmutableMetadata flagMetadata; + private boolean disabled; } diff --git a/src/main/java/dev/openfeature/sdk/providers/memory/InMemoryProvider.java b/src/main/java/dev/openfeature/sdk/providers/memory/InMemoryProvider.java index 3be1b6316..1773ae8a8 100644 --- a/src/main/java/dev/openfeature/sdk/providers/memory/InMemoryProvider.java +++ b/src/main/java/dev/openfeature/sdk/providers/memory/InMemoryProvider.java @@ -97,36 +97,37 @@ public void updateFlag(String flagKey, Flag newFlag) { @Override public ProviderEvaluation getBooleanEvaluation( String key, Boolean defaultValue, EvaluationContext evaluationContext) { - return getEvaluation(key, evaluationContext, Boolean.class); + return getEvaluation(key, defaultValue, evaluationContext, Boolean.class); } @Override public ProviderEvaluation getStringEvaluation( String key, String defaultValue, EvaluationContext evaluationContext) { - return getEvaluation(key, evaluationContext, String.class); + return getEvaluation(key, defaultValue, evaluationContext, String.class); } @Override public ProviderEvaluation getIntegerEvaluation( String key, Integer defaultValue, EvaluationContext evaluationContext) { - return getEvaluation(key, evaluationContext, Integer.class); + return getEvaluation(key, defaultValue, evaluationContext, Integer.class); } @Override public ProviderEvaluation getDoubleEvaluation( String key, Double defaultValue, EvaluationContext evaluationContext) { - return getEvaluation(key, evaluationContext, Double.class); + return getEvaluation(key, defaultValue, evaluationContext, Double.class); } @SneakyThrows @Override public ProviderEvaluation getObjectEvaluation( String key, Value defaultValue, EvaluationContext evaluationContext) { - return getEvaluation(key, evaluationContext, Value.class); + return getEvaluation(key, defaultValue, evaluationContext, Value.class); } private ProviderEvaluation getEvaluation( - String key, EvaluationContext evaluationContext, Class expectedType) throws OpenFeatureError { + String key, T defaultValue, EvaluationContext evaluationContext, Class expectedType) + throws OpenFeatureError { if (!ProviderState.READY.equals(state)) { if (ProviderState.NOT_READY.equals(state)) { throw new ProviderNotReadyError("provider not yet initialized"); @@ -138,11 +139,28 @@ private ProviderEvaluation getEvaluation( } Flag flag = flags.get(key); if (flag == null) { - throw new FlagNotFoundError("flag " + key + "not found"); + throw new FlagNotFoundError("flag " + key + " not found"); + } + if (flag.isDisabled()) { + return ProviderEvaluation.builder() + .reason(Reason.DISABLED.name()) + .value(defaultValue) + .flagMetadata(flag.getFlagMetadata()) + .build(); } T value; + Reason reason = Reason.STATIC; if (flag.getContextEvaluator() != null) { - value = (T) flag.getContextEvaluator().evaluate(flag, evaluationContext); + try { + value = (T) flag.getContextEvaluator().evaluate(flag, evaluationContext); + reason = Reason.TARGETING_MATCH; + } catch (Exception e) { + value = null; + } + if (value == null) { + value = (T) flag.getVariants().get(flag.getDefaultVariant()); + reason = Reason.DEFAULT; + } } else if (!expectedType.isInstance(flag.getVariants().get(flag.getDefaultVariant()))) { throw new TypeMismatchError("flag " + key + "is not of expected type"); } else { @@ -151,7 +169,7 @@ private ProviderEvaluation getEvaluation( return ProviderEvaluation.builder() .value(value) .variant(flag.getDefaultVariant()) - .reason(Reason.STATIC.toString()) + .reason(reason.toString()) .flagMetadata(flag.getFlagMetadata()) .build(); } diff --git a/src/test/java/dev/openfeature/sdk/e2e/EvaluationTest.java b/src/test/java/dev/openfeature/sdk/e2e/GherkinSpecTest.java similarity index 82% rename from src/test/java/dev/openfeature/sdk/e2e/EvaluationTest.java rename to src/test/java/dev/openfeature/sdk/e2e/GherkinSpecTest.java index b7c834312..89c7161be 100644 --- a/src/test/java/dev/openfeature/sdk/e2e/EvaluationTest.java +++ b/src/test/java/dev/openfeature/sdk/e2e/GherkinSpecTest.java @@ -5,6 +5,7 @@ import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME; import org.junit.platform.suite.api.ConfigurationParameter; +import org.junit.platform.suite.api.ExcludeTags; import org.junit.platform.suite.api.IncludeEngines; import org.junit.platform.suite.api.SelectDirectories; import org.junit.platform.suite.api.Suite; @@ -15,4 +16,5 @@ @ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty") @ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.sdk.e2e.steps") @ConfigurationParameter(key = OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory") -public class EvaluationTest {} +@ExcludeTags({"deprecated", "reason-codes-cached", "async", "immutability", "evaluation-options"}) +public class GherkinSpecTest {} diff --git a/src/test/java/dev/openfeature/sdk/e2e/Utils.java b/src/test/java/dev/openfeature/sdk/e2e/Utils.java index 902ee11d0..565968c1c 100644 --- a/src/test/java/dev/openfeature/sdk/e2e/Utils.java +++ b/src/test/java/dev/openfeature/sdk/e2e/Utils.java @@ -1,9 +1,14 @@ package dev.openfeature.sdk.e2e; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.openfeature.sdk.Value; import java.util.Objects; public final class Utils { + public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private Utils() {} public static Object convert(String value, String type) { @@ -22,6 +27,12 @@ public static Object convert(String value, String type) { return Double.parseDouble(value); case "long": return Long.parseLong(value); + case "object": + try { + return Value.objectToValue(OBJECT_MAPPER.readValue(value, Object.class)); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } } throw new RuntimeException("Unknown config type: " + type); } diff --git a/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java b/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java index ccb78e72a..ce9bb8b5f 100644 --- a/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java +++ b/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java @@ -8,11 +8,14 @@ import dev.openfeature.sdk.Hook; import dev.openfeature.sdk.HookContext; import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ImmutableStructure; +import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.OpenFeatureAPI; import dev.openfeature.sdk.ThreadLocalTransactionContextPropagator; import dev.openfeature.sdk.Value; import dev.openfeature.sdk.e2e.ContextStoringProvider; import dev.openfeature.sdk.e2e.State; +import dev.openfeature.sdk.e2e.Utils; import io.cucumber.datatable.DataTable; import io.cucumber.java.en.And; import io.cucumber.java.en.Given; @@ -101,4 +104,29 @@ public void contextEntriesForEachLevelFromAPILevelDownToTheLevelWithKeyAndValue( } } } + + @Given("a context containing a key {string} with null value") + public void a_context_containing_a_key_with_null_value(String key) { + a_context_containing_a_key_with_type_and_with_value(key, "String", null); + } + + @Given("a context containing a key {string}, with type {string} and with value {string}") + public void a_context_containing_a_key_with_type_and_with_value(String key, String type, String value) { + Map map = state.context.asMap(); + map.put(key, Value.objectToValue(Utils.convert(value, type))); + state.context = new MutableContext(state.context.getTargetingKey(), map); + } + + @Given("a context containing a targeting key with value {string}") + public void a_context_containing_a_targeting_key_with_value(String string) { + state.context.setTargetingKey(string); + } + + @Given("a context containing a nested property with outer key {string} and inner key {string}, with value {string}") + public void a_context_containing_a_nested_property_with_outer_key_and_inner_key_with_value( + String outer, String inner, String value) { + Map innerMap = new HashMap<>(); + innerMap.put(inner, new Value(value)); + state.context.add(outer, new ImmutableStructure(innerMap)); + } } diff --git a/src/test/java/dev/openfeature/sdk/e2e/steps/FlagStepDefinitions.java b/src/test/java/dev/openfeature/sdk/e2e/steps/FlagStepDefinitions.java index 390e067f3..dccdbf9af 100644 --- a/src/test/java/dev/openfeature/sdk/e2e/steps/FlagStepDefinitions.java +++ b/src/test/java/dev/openfeature/sdk/e2e/steps/FlagStepDefinitions.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.ImmutableMetadata; import dev.openfeature.sdk.Value; @@ -23,7 +24,7 @@ public FlagStepDefinitions(State state) { this.state = state; } - @Given("a {}-flag with key {string} and a default value {string}") + @Given("a {}-flag with key {string} and a fallback value {string}") public void givenAFlag(String type, String name, String defaultValue) { state.flag = new Flag(type, name, Utils.convert(defaultValue, type)); } @@ -60,7 +61,20 @@ public void the_flag_was_evaluated_with_details() { @Then("the resolved details value should be {string}") public void the_resolved_details_value_should_be(String value) { - assertThat(state.evaluation.getValue()).isEqualTo(Utils.convert(value, state.flag.type)); + Object evaluationValue = state.evaluation.getValue(); + if (state.flag.type.equalsIgnoreCase("object")) { + assertThat(((Value) evaluationValue).asStructure().asObjectMap()) + .isEqualTo(((Value) Utils.convert(value, state.flag.type)) + .asStructure() + .asObjectMap()); + } else { + assertThat(evaluationValue).isEqualTo(Utils.convert(value, state.flag.type)); + } + } + + @Then("the flag key should be {string}") + public void the_flag_key_should_be(String key) { + assertThat(state.evaluation.getFlagKey()).isEqualTo(key); } @Then("the reason should be {string}") @@ -73,6 +87,20 @@ public void the_variant_should_be(String variant) { assertThat(state.evaluation.getVariant()).isEqualTo(variant); } + @Then("the error-code should be {string}") + public void the_error_code_should_be(String errorCode) { + if (errorCode.isEmpty()) { + assertThat(state.evaluation.getErrorCode()).isNull(); + } else { + assertThat(state.evaluation.getErrorCode()).isEqualTo(ErrorCode.valueOf(errorCode)); + } + } + + @Then("the error message should contain {string}") + public void the_error_message_should_contain(String messageSubstring) { + assertThat(state.evaluation.getErrorMessage()).contains(messageSubstring); + } + @Then("the resolved metadata value \"{}\" with type \"{}\" should be \"{}\"") public void theResolvedMetadataValueShouldBe(String key, String type, String value) throws NoSuchFieldException, IllegalAccessException { diff --git a/src/test/java/dev/openfeature/sdk/e2e/steps/ProviderSteps.java b/src/test/java/dev/openfeature/sdk/e2e/steps/ProviderSteps.java index 82cdb2e79..d9dde3c2b 100644 --- a/src/test/java/dev/openfeature/sdk/e2e/steps/ProviderSteps.java +++ b/src/test/java/dev/openfeature/sdk/e2e/steps/ProviderSteps.java @@ -1,13 +1,32 @@ package dev.openfeature.sdk.e2e.steps; import static dev.openfeature.sdk.testutils.TestFlagsUtils.buildFlags; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EventProvider; +import dev.openfeature.sdk.FeatureProvider; import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEventDetails; +import dev.openfeature.sdk.ProviderState; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; import dev.openfeature.sdk.e2e.State; +import dev.openfeature.sdk.exceptions.FatalError; import dev.openfeature.sdk.providers.memory.Flag; import dev.openfeature.sdk.providers.memory.InMemoryProvider; import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; import java.util.Map; +import org.awaitility.Awaitility; public class ProviderSteps { private final State state; @@ -16,11 +35,128 @@ public ProviderSteps(State state) { this.state = state; } - @Given("a stable provider") - public void aStableProvider() { + @Given("a {} provider") + public void a_provider_with_status(String providerType) throws Exception { + // Normalize input to handle both single word and quoted strings + String normalizedType = + providerType.toLowerCase().replaceAll("[\"\\s]+", " ").trim(); + + switch (normalizedType) { + case "not ready": + setupMockProvider(ErrorCode.PROVIDER_NOT_READY, "Provider in not ready state", ProviderState.NOT_READY); + break; + case "stable": + case "ready": + setupStableProvider(); + break; + case "fatal": + setupMockProvider(ErrorCode.PROVIDER_FATAL, "Provider in fatal state", ProviderState.FATAL); + break; + case "error": + setupMockProvider(ErrorCode.GENERAL, "Provider in error state", ProviderState.ERROR); + break; + case "stale": + setupMockProvider(null, null, ProviderState.STALE); + break; + default: + throw new IllegalArgumentException("Unsupported provider type: " + providerType); + } + } + + // =============================== + // Provider Status Assertion Steps + // =============================== + + @Then("the provider status should be {string}") + public void the_provider_status_should_be(String expectedStatus) { + ProviderState actualStatus = state.client.getProviderState(); + ProviderState expected = ProviderState.valueOf(expectedStatus); + assertThat(actualStatus).isEqualTo(expected); + } + + // =============================== + // Helper Methods + // =============================== + + private void setupStableProvider() throws Exception { Map> flags = buildFlags(); InMemoryProvider provider = new InMemoryProvider(flags); OpenFeatureAPI.getInstance().setProviderAndWait(provider); state.client = OpenFeatureAPI.getInstance().getClient(); } + + private void setupMockProvider(ErrorCode errorCode, String errorMessage, ProviderState providerState) + throws Exception { + EventProvider mockProvider = spy(EventProvider.class); + + switch (providerState) { + case NOT_READY: + doAnswer(invocationOnMock -> { + while (true) {} + }) + .when(mockProvider) + .initialize(any()); + break; + case FATAL: + doThrow(new FatalError(errorMessage)).when(mockProvider).initialize(any()); + break; + } + // Configure all evaluation methods with a single helper + configureMockEvaluations(mockProvider, errorCode, errorMessage); + + OpenFeatureAPI.getInstance().setProvider(providerState.name(), mockProvider); + Client client = OpenFeatureAPI.getInstance().getClient(providerState.name()); + state.client = client; + + ProviderEventDetails details = + ProviderEventDetails.builder().errorCode(errorCode).build(); + switch (providerState) { + case FATAL: + case ERROR: + mockProvider.emitProviderReady(details); + mockProvider.emitProviderError(details); + break; + case STALE: + mockProvider.emitProviderReady(details); + mockProvider.emitProviderStale(details); + break; + default: + } + Awaitility.await().until(() -> { + ProviderState providerState1 = client.getProviderState(); + return providerState1 == providerState; + }); + } + + private void configureMockEvaluations(FeatureProvider mockProvider, ErrorCode errorCode, String errorMessage) { + // Configure Boolean evaluation + when(mockProvider.getBooleanEvaluation(anyString(), any(Boolean.class), any())) + .thenAnswer(invocation -> createProviderEvaluation(invocation.getArgument(1), errorCode, errorMessage)); + + // Configure String evaluation + when(mockProvider.getStringEvaluation(anyString(), any(String.class), any())) + .thenAnswer(invocation -> createProviderEvaluation(invocation.getArgument(1), errorCode, errorMessage)); + + // Configure Integer evaluation + when(mockProvider.getIntegerEvaluation(anyString(), any(Integer.class), any())) + .thenAnswer(invocation -> createProviderEvaluation(invocation.getArgument(1), errorCode, errorMessage)); + + // Configure Double evaluation + when(mockProvider.getDoubleEvaluation(anyString(), any(Double.class), any())) + .thenAnswer(invocation -> createProviderEvaluation(invocation.getArgument(1), errorCode, errorMessage)); + + // Configure Object evaluation + when(mockProvider.getObjectEvaluation(anyString(), any(Value.class), any())) + .thenAnswer(invocation -> createProviderEvaluation(invocation.getArgument(1), errorCode, errorMessage)); + } + + private ProviderEvaluation createProviderEvaluation( + T defaultValue, ErrorCode errorCode, String errorMessage) { + return ProviderEvaluation.builder() + .value(defaultValue) + .errorCode(errorCode) + .errorMessage(errorMessage) + .reason(Reason.ERROR.toString()) + .build(); + } } diff --git a/src/test/java/dev/openfeature/sdk/e2e/steps/StepDefinitions.java b/src/test/java/dev/openfeature/sdk/e2e/steps/StepDefinitions.java index 924c9d59e..c31e9eb7e 100644 --- a/src/test/java/dev/openfeature/sdk/e2e/steps/StepDefinitions.java +++ b/src/test/java/dev/openfeature/sdk/e2e/steps/StepDefinitions.java @@ -21,6 +21,7 @@ import java.util.Map; import lombok.SneakyThrows; +@Deprecated public class StepDefinitions { private static Client client; diff --git a/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java b/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java index c1767ff6f..7c45166f9 100644 --- a/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java +++ b/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java @@ -1,18 +1,28 @@ package dev.openfeature.sdk.testutils; -import static dev.openfeature.sdk.Structure.mapToStructure; +import static dev.openfeature.sdk.e2e.Utils.OBJECT_MAPPER; -import com.google.common.collect.ImmutableMap; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.providers.memory.ContextEvaluator; import dev.openfeature.sdk.providers.memory.Flag; -import java.util.HashMap; +import dev.openfeature.sdk.testutils.jackson.ContextEvaluatorDeserializer; +import dev.openfeature.sdk.testutils.jackson.ImmutableMetadataDeserializer; +import dev.openfeature.sdk.testutils.jackson.InMemoryFlagMixin; +import java.io.IOException; +import java.nio.file.Paths; +import java.util.Collections; import java.util.Map; import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; /** * Test flags utils. */ +@Slf4j @UtilityClass public class TestFlagsUtils { @@ -25,87 +35,37 @@ public class TestFlagsUtils { public static final String WRONG_FLAG_KEY = "wrong-flag"; public static final String METADATA_FLAG_KEY = "metadata-flag"; + private static Map> flags; /** * Building flags for testing purposes. * * @return map of flags */ - public static Map> buildFlags() { - Map> flags = new HashMap<>(); - flags.put( - BOOLEAN_FLAG_KEY, - Flag.builder() - .variant("on", true) - .variant("off", false) - .defaultVariant("on") - .build()); - flags.put( - STRING_FLAG_KEY, - Flag.builder() - .variant("greeting", "hi") - .variant("parting", "bye") - .defaultVariant("greeting") - .build()); - flags.put( - INT_FLAG_KEY, - Flag.builder() - .variant("one", 1) - .variant("ten", 10) - .defaultVariant("ten") - .build()); - flags.put( - FLOAT_FLAG_KEY, - Flag.builder() - .variant("tenth", 0.1) - .variant("half", 0.5) - .defaultVariant("half") - .build()); - flags.put( - OBJECT_FLAG_KEY, - Flag.builder() - .variant("empty", new HashMap<>()) - .variant( - "template", - new Value(mapToStructure(ImmutableMap.of( - "showImages", new Value(true), - "title", new Value("Check out these pics!"), - "imagesPerPage", new Value(100))))) - .defaultVariant("template") - .build()); - flags.put( - CONTEXT_AWARE_FLAG_KEY, - Flag.builder() - .variant("internal", "INTERNAL") - .variant("external", "EXTERNAL") - .defaultVariant("external") - .contextEvaluator((flag, evaluationContext) -> { - if (new Value(false).equals(evaluationContext.getValue("customer"))) { - return (String) flag.getVariants().get("internal"); - } else { - return (String) flag.getVariants().get(flag.getDefaultVariant()); - } - }) - .build()); - flags.put( - WRONG_FLAG_KEY, - Flag.builder() - .variant("one", "uno") - .variant("two", "dos") - .defaultVariant("one") - .build()); - flags.put( - METADATA_FLAG_KEY, - Flag.builder() - .variant("on", true) - .variant("off", false) - .defaultVariant("on") - .flagMetadata(ImmutableMetadata.builder() - .addString("string", "1.0.2") - .addInteger("integer", 2) - .addBoolean("boolean", true) - .addDouble("float", 0.1d) - .build()) - .build()); + public static synchronized Map> buildFlags() { + if (flags == null) { + ObjectMapper objectMapper = OBJECT_MAPPER; + objectMapper.configure(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION.mappedFeature(), true); + objectMapper.addMixIn(Flag.class, InMemoryFlagMixin.class); + objectMapper.addMixIn(Flag.FlagBuilder.class, InMemoryFlagMixin.FlagBuilderMixin.class); + + SimpleModule module = new SimpleModule(); + module.addDeserializer(ImmutableMetadata.class, new ImmutableMetadataDeserializer()); + module.addDeserializer(ContextEvaluator.class, new ContextEvaluatorDeserializer()); + objectMapper.registerModule(module); + + Map> flagsJson; + try { + flagsJson = objectMapper.readValue( + Paths.get("spec/specification/assets/gherkin/test-flags.json") + .toFile(), + new TypeReference<>() {}); + + } catch (IOException e) { + throw new RuntimeException(e); + } + flags = Collections.unmodifiableMap(flagsJson); + } + return flags; } } diff --git a/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java b/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java new file mode 100644 index 000000000..6ca3875ef --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java @@ -0,0 +1,61 @@ +package dev.openfeature.sdk.testutils.jackson; + +import dev.cel.common.types.SimpleType; +import dev.cel.compiler.CelCompiler; +import dev.cel.compiler.CelCompilerFactory; +import dev.cel.runtime.CelRuntime; +import dev.cel.runtime.CelRuntimeFactory; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.providers.memory.ContextEvaluator; +import dev.openfeature.sdk.providers.memory.Flag; +import java.util.HashMap; +import java.util.Map; + +public class CelContextEvaluator implements ContextEvaluator { + private final CelRuntime.Program program; + + public CelContextEvaluator(String expression) { + try { + CelRuntime celRuntime = + CelRuntimeFactory.standardCelRuntimeBuilder().build(); + CelCompiler celCompiler = CelCompilerFactory.standardCelCompilerBuilder() + .addVar("customer", SimpleType.BOOL) + .addVar("email", SimpleType.STRING) + .addVar("age", SimpleType.INT) + .addVar("dummy", SimpleType.STRING) + .setResultType(SimpleType.STRING) + // Add other variables you expect + .build(); + + var ast = celCompiler.compile(expression).getAst(); + this.program = celRuntime.createProgram(ast); + } catch (Exception e) { + throw new RuntimeException("Failed to compile CEL expression: " + expression, e); + } + } + + @Override + @SuppressWarnings("unchecked") + public T evaluate(Flag flag, EvaluationContext evaluationContext) { + try { + Map objectMap = new HashMap<>(); + // Provide defaults for all declared variables to prevent runtime errors. + objectMap.put("email", ""); + objectMap.put("customer", true); + objectMap.put("age", 0); + objectMap.put("dummy", ""); + + if (evaluationContext != null) { + // Evaluate with context, overriding defaults. + objectMap.putAll(evaluationContext.asObjectMap()); + } + + Object result = program.eval(objectMap); + + String stringResult = (String) result; + return (T) flag.getVariants().get(stringResult); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/dev/openfeature/sdk/testutils/jackson/ContextEvaluatorDeserializer.java b/src/test/java/dev/openfeature/sdk/testutils/jackson/ContextEvaluatorDeserializer.java new file mode 100644 index 000000000..e348fc8c5 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/testutils/jackson/ContextEvaluatorDeserializer.java @@ -0,0 +1,25 @@ +package dev.openfeature.sdk.testutils.jackson; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import dev.openfeature.sdk.providers.memory.ContextEvaluator; +import java.io.IOException; + +public class ContextEvaluatorDeserializer extends JsonDeserializer> { + @Override + public ContextEvaluator deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode node = p.getCodec().readTree(p); + + if (node.isTextual()) { + return new CelContextEvaluator<>(node.asText()); + } + + if (node.isObject() && node.has("expression")) { + return new CelContextEvaluator<>(node.get("expression").asText()); + } + + return null; + } +} diff --git a/src/test/java/dev/openfeature/sdk/testutils/jackson/ImmutableMetadataDeserializer.java b/src/test/java/dev/openfeature/sdk/testutils/jackson/ImmutableMetadataDeserializer.java new file mode 100644 index 000000000..09f7c6f24 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/testutils/jackson/ImmutableMetadataDeserializer.java @@ -0,0 +1,41 @@ +package dev.openfeature.sdk.testutils.jackson; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import dev.openfeature.sdk.ImmutableMetadata; +import java.io.IOException; +import java.util.Map; + +public class ImmutableMetadataDeserializer extends JsonDeserializer { + @Override + public ImmutableMetadata deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + Map properties = p.readValueAs(new TypeReference>() {}); + + ImmutableMetadata.ImmutableMetadataBuilder builder = ImmutableMetadata.builder(); + + if (properties != null) { + for (Map.Entry entry : properties.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + + if (value instanceof String) { + builder.addString(key, (String) value); + } else if (value instanceof Integer) { + builder.addInteger(key, (Integer) value); + } else if (value instanceof Long) { + builder.addLong(key, (Long) value); + } else if (value instanceof Float) { + builder.addFloat(key, (Float) value); + } else if (value instanceof Double) { + builder.addDouble(key, (Double) value); + } else if (value instanceof Boolean) { + builder.addBoolean(key, (Boolean) value); + } + } + } + + return builder.build(); + } +} diff --git a/src/test/java/dev/openfeature/sdk/testutils/jackson/InMemoryFlagMixin.java b/src/test/java/dev/openfeature/sdk/testutils/jackson/InMemoryFlagMixin.java new file mode 100644 index 000000000..dd0154cdd --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/testutils/jackson/InMemoryFlagMixin.java @@ -0,0 +1,20 @@ +package dev.openfeature.sdk.testutils.jackson; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import dev.openfeature.sdk.providers.memory.Flag; +import java.util.Map; + +@JsonDeserialize(builder = Flag.FlagBuilder.class) +@SuppressWarnings("rawtypes") +public abstract class InMemoryFlagMixin { + + @JsonPOJOBuilder(withPrefix = "") + public abstract class FlagBuilderMixin { + + @JsonProperty("variants") + @JsonDeserialize(using = VariantsMapDeserializer.class) + public abstract Flag.FlagBuilder variants(Map variants); + } +} diff --git a/src/test/java/dev/openfeature/sdk/testutils/jackson/VariantsMapDeserializer.java b/src/test/java/dev/openfeature/sdk/testutils/jackson/VariantsMapDeserializer.java new file mode 100644 index 000000000..f7a621cbb --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/testutils/jackson/VariantsMapDeserializer.java @@ -0,0 +1,65 @@ +package dev.openfeature.sdk.testutils.jackson; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import dev.openfeature.sdk.Value; +import java.io.IOException; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +public class VariantsMapDeserializer extends JsonDeserializer> { + + @Override + public Map deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode node = p.getCodec().readTree(p); + Map variants = new HashMap<>(); + + Iterator> fields = node.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + String variantKey = field.getKey(); + JsonNode variantNode = field.getValue(); + + // Convert the variant value to OpenFeature Value + Object variantValue = convertToValue(p, variantNode); + variants.put(variantKey, variantValue); + } + + return variants; + } + + private Object convertToValue(JsonParser p, JsonNode node) throws JsonProcessingException { + // If the node has a "value" property, use that + if (node.isObject() && node.has("value")) { + return convertJsonNodeToValue(p, node.get("value")); + } + + // Otherwise, treat the entire node as the value + return convertJsonNodeToValue(p, node); + } + + private Object convertJsonNodeToValue(JsonParser p, JsonNode node) throws JsonProcessingException { + if (node.isNull()) { + return null; + } else if (node.isBoolean()) { + return node.asBoolean(); + } else if (node.isInt()) { + return node.asInt(); + } else if (node.isDouble()) { + return node.asDouble(); + } else if (node.isTextual()) { + return node.asText(); + } else if (node.isArray()) { + return Value.objectToValue(p.getCodec().treeToValue(node, List.class)); + } else if (node.isObject()) { + return Value.objectToValue(p.getCodec().treeToValue(node, Object.class)); + } + + throw new IllegalArgumentException("Unsupported JSON node type: " + node.getNodeType()); + } +}