From 4b3c2675a37c9c695cb922873046d99e8b4faf3b Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Mon, 25 Aug 2025 08:24:59 +0200 Subject: [PATCH 01/11] test: improve gherkin test suite only relying on the newest version, with data loading Signed-off-by: Simon Schrottner --- pom.xml | 14 +++ spec | 2 +- .../providers/memory/InMemoryProvider.java | 16 ++- ...aluationTest.java => GherkinSpecTest.java} | 4 +- .../sdk/e2e/steps/ContextSteps.java | 29 +++++ .../sdk/e2e/steps/FlagStepDefinitions.java | 20 +++ .../sdk/e2e/steps/StepDefinitions.java | 1 + .../sdk/testutils/TestFlagsUtils.java | 115 ++++++------------ .../jackson/CelContextEvaluator.java | 54 ++++++++ .../jackson/ContextEvaluatorDeserializer.java | 25 ++++ .../ImmutableMetadataDeserializer.java | 41 +++++++ .../testutils/jackson/InMemoryFlagMixin.java | 20 +++ .../jackson/VariantsMapDeserializer.java | 86 +++++++++++++ 13 files changed, 341 insertions(+), 86 deletions(-) rename src/test/java/dev/openfeature/sdk/e2e/{EvaluationTest.java => GherkinSpecTest.java} (88%) create mode 100644 src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java create mode 100644 src/test/java/dev/openfeature/sdk/testutils/jackson/ContextEvaluatorDeserializer.java create mode 100644 src/test/java/dev/openfeature/sdk/testutils/jackson/ImmutableMetadataDeserializer.java create mode 100644 src/test/java/dev/openfeature/sdk/testutils/jackson/InMemoryFlagMixin.java create mode 100644 src/test/java/dev/openfeature/sdk/testutils/jackson/VariantsMapDeserializer.java diff --git a/pom.xml b/pom.xml index ddbbc3563..d1b3e9af6 100644 --- a/pom.xml +++ b/pom.xml @@ -168,6 +168,20 @@ test + + com.fasterxml.jackson.core + jackson-databind + 2.19.2 + test + + + + dev.cel + cel + 0.10.1 + test + + diff --git a/spec b/spec index d4a9a9109..b7e719b82 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit d4a9a910946eded57cf82d6fd4921785a5e64c2b +Subproject commit b7e719b826856694ccc5b34c61f8d8145a2cf972 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..88b98acf0 100644 --- a/src/main/java/dev/openfeature/sdk/providers/memory/InMemoryProvider.java +++ b/src/main/java/dev/openfeature/sdk/providers/memory/InMemoryProvider.java @@ -138,11 +138,21 @@ 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"); } 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 +161,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 88% rename from src/test/java/dev/openfeature/sdk/e2e/EvaluationTest.java rename to src/test/java/dev/openfeature/sdk/e2e/GherkinSpecTest.java index b7c834312..e3d06870c 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"}) +public class GherkinSpecTest {} 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..78950afb1 100644 --- a/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java +++ b/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java @@ -8,6 +8,8 @@ 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; @@ -101,4 +103,31 @@ 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) + throws ClassNotFoundException, InstantiationException { + 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) + throws ClassNotFoundException, InstantiationException { + Map map = state.context.asMap(); + map.put(key, new Value(value)); + 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..446cdea34 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; @@ -63,6 +64,11 @@ public void the_resolved_details_value_should_be(String value) { assertThat(state.evaluation.getValue()).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}") public void the_reason_should_be(String reason) { assertThat(state.evaluation.getReason()).isEqualTo(reason); @@ -73,6 +79,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_error_message_should_contain(String errorCode) { + assertThat(state.evaluation.getErrorMessage()).contains(errorCode); + } + @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/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..e0c44eabc 100644 --- a/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java +++ b/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java @@ -1,18 +1,25 @@ package dev.openfeature.sdk.testutils; -import static dev.openfeature.sdk.Structure.mapToStructure; - -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.Map; import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; /** * Test flags utils. */ +@Slf4j @UtilityClass public class TestFlagsUtils { @@ -31,81 +38,27 @@ public class TestFlagsUtils { * @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()); - return flags; + ObjectMapper objectMapper = new ObjectMapper(); + 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); + } + + return flagsJson; } } 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..e13821c37 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java @@ -0,0 +1,54 @@ +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.STRING) + .addVar("email", SimpleType.STRING) + .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<>(Map.of("email", "")); + if (evaluationContext != null) { + // Evaluate with context + 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..339d8c84f --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/testutils/jackson/VariantsMapDeserializer.java @@ -0,0 +1,86 @@ +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.MutableStructure; +import dev.openfeature.sdk.Value; +import java.io.IOException; +import java.util.ArrayList; +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 = null; + try { + variantValue = convertToValue(variantNode); + } catch (InstantiationException e) { + throw new RuntimeException(e); + } + variants.put(variantKey, variantValue); + } + + return variants; + } + + private Object convertToValue(JsonNode node) throws InstantiationException { + // If the node has a "value" property, use that + if (node.isObject() && node.has("value")) { + return convertJsonNodeToValue(node.get("value")); + } + + // Otherwise, treat the entire node as the value + return convertJsonNodeToValue(node); + } + + private Object convertJsonNodeToValue(JsonNode node) throws InstantiationException { + 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()) { + List list = new ArrayList<>(); + for (JsonNode item : node) { + list.add(convertJsonNodeToValue(item)); + } + return list; + } else if (node.isObject()) { + Map map = new HashMap<>(); + Iterator> fields = node.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + Object o = convertJsonNodeToValue(field.getValue()); + if (o instanceof Value) { + map.put(field.getKey(), (Value) o); + } else { + map.put(field.getKey(), new Value(o)); + } + } + return new Value(new MutableStructure(map)); + } + + throw new IllegalArgumentException("Unsupported JSON node type: " + node.getNodeType()); + } +} From c7355a9dcac286cb1f5ac3ec6c6ac5cb99c6778d Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Mon, 25 Aug 2025 09:35:02 +0200 Subject: [PATCH 02/11] Update src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Simon Schrottner --- src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 78950afb1..e4cd603a5 100644 --- a/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java +++ b/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java @@ -114,9 +114,9 @@ public void a_context_containing_a_key_with_null_value(String key) public void a_context_containing_a_key_with_type_and_with_value(String key, String type, String value) throws ClassNotFoundException, InstantiationException { Map map = state.context.asMap(); - map.put(key, new Value(value)); + Map map = state.context.asMap(); + map.put(key, new Value(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) { From 04e3230b5e081454564f5f23c6c610122580c4ca Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Mon, 25 Aug 2025 09:35:22 +0200 Subject: [PATCH 03/11] Update src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Simon Schrottner --- .../sdk/testutils/jackson/CelContextEvaluator.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java b/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java index e13821c37..20f3f5f50 100644 --- a/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java +++ b/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java @@ -36,12 +36,17 @@ public CelContextEvaluator(String expression) { @Override @SuppressWarnings("unchecked") public T evaluate(Flag flag, EvaluationContext evaluationContext) { - try { - Map objectMap = new HashMap<>(Map.of("email", "")); + Map objectMap = new HashMap<>(); + // Provide defaults for all declared variables to prevent runtime errors. + objectMap.put("email", ""); + objectMap.put("customer", ""); + objectMap.put("dummy", ""); + if (evaluationContext != null) { - // Evaluate with context + // Evaluate with context, overriding defaults. objectMap.putAll(evaluationContext.asObjectMap()); } + } Object result = program.eval(objectMap); From c57e3da0c5ef63112b9eb576ad9ea6955a76599a Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Mon, 25 Aug 2025 08:24:59 +0200 Subject: [PATCH 04/11] test: improve gherkin test suite only relying on the newest version, with data loading Signed-off-by: Simon Schrottner diff --git c/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java i/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java index e4cd603..121b567 100644 --- c/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java +++ i/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java @@ -114,9 +114,9 @@ public class ContextSteps { public void a_context_containing_a_key_with_type_and_with_value(String key, String type, String value) throws ClassNotFoundException, InstantiationException { Map map = state.context.asMap(); - Map map = state.context.asMap(); map.put(key, new Value(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) { diff --git c/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java i/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java index 20f3f5f..138c23f 100644 --- c/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java +++ i/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java @@ -46,7 +46,6 @@ public class CelContextEvaluator implements ContextEvaluator { // Evaluate with context, overriding defaults. objectMap.putAll(evaluationContext.asObjectMap()); } - } Object result = program.eval(objectMap); diff --git c/src/test/java/dev/openfeature/sdk/testutils/jackson/VariantsMapDeserializer.java i/src/test/java/dev/openfeature/sdk/testutils/jackson/VariantsMapDeserializer.java index 339d8c8..9a63f58 100644 --- c/src/test/java/dev/openfeature/sdk/testutils/jackson/VariantsMapDeserializer.java +++ i/src/test/java/dev/openfeature/sdk/testutils/jackson/VariantsMapDeserializer.java @@ -50,6 +50,7 @@ public class VariantsMapDeserializer extends JsonDeserializer list = new ArrayList<>(); - for (JsonNode item : node) { - list.add(convertJsonNodeToValue(item)); - } - return list; } else if (node.isObject()) { - Map map = new HashMap<>(); - Iterator> fields = node.fields(); - while (fields.hasNext()) { - Map.Entry field = fields.next(); - Object o = convertJsonNodeToValue(field.getValue()); - if (o instanceof Value) { - map.put(field.getKey(), (Value) o); - } else { - map.put(field.getKey(), new Value(o)); - } - } - return new Value(new MutableStructure(map)); + return Value.objectToValue(node); } throw new IllegalArgumentException("Unsupported JSON node type: " + node.getNodeType()); Signed-off-by: Simon Schrottner --- pom.xml | 21 +++++++++- spec | 2 +- .../openfeature/sdk/e2e/GherkinSpecTest.java | 2 +- .../java/dev/openfeature/sdk/e2e/Utils.java | 9 +++++ .../sdk/e2e/steps/ContextSteps.java | 11 +++--- .../sdk/e2e/steps/FlagStepDefinitions.java | 10 ++++- .../sdk/testutils/TestFlagsUtils.java | 39 +++++++++++-------- .../jackson/CelContextEvaluator.java | 4 +- .../jackson/VariantsMapDeserializer.java | 37 ++++-------------- 9 files changed, 77 insertions(+), 58 deletions(-) diff --git a/pom.xml b/pom.xml index d1b3e9af6..b80179bcb 100644 --- a/pom.xml +++ b/pom.xml @@ -168,10 +168,21 @@ test + + com.fasterxml.jackson.core + jackson-core + test + + + + com.fasterxml.jackson.core + jackson-annotations + test + + com.fasterxml.jackson.core jackson-databind - 2.19.2 test @@ -205,6 +216,14 @@ + + com.fasterxml.jackson + jackson-bom + 2.16.1 + pom + import + + io.cucumber cucumber-bom diff --git a/spec b/spec index b7e719b82..0795ac4ca 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit b7e719b826856694ccc5b34c61f8d8145a2cf972 +Subproject commit 0795ac4ca253b1120a67a1b819a67016a92e2a7d diff --git a/src/test/java/dev/openfeature/sdk/e2e/GherkinSpecTest.java b/src/test/java/dev/openfeature/sdk/e2e/GherkinSpecTest.java index e3d06870c..460bece92 100644 --- a/src/test/java/dev/openfeature/sdk/e2e/GherkinSpecTest.java +++ b/src/test/java/dev/openfeature/sdk/e2e/GherkinSpecTest.java @@ -16,5 +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") -@ExcludeTags({"deprecated"}) +@ExcludeTags({"deprecated", "provider-status", "reason-codes", "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..1500d997a 100644 --- a/src/test/java/dev/openfeature/sdk/e2e/Utils.java +++ b/src/test/java/dev/openfeature/sdk/e2e/Utils.java @@ -1,5 +1,8 @@ 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 { @@ -22,6 +25,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(new ObjectMapper().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 e4cd603a5..ce9bb8b5f 100644 --- a/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java +++ b/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java @@ -15,6 +15,7 @@ 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; @@ -105,18 +106,16 @@ 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) - throws ClassNotFoundException, InstantiationException { + 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) - throws ClassNotFoundException, InstantiationException { + public void a_context_containing_a_key_with_type_and_with_value(String key, String type, String value) { Map map = state.context.asMap(); - Map map = state.context.asMap(); - map.put(key, new Value(Utils.convert(value, type))); + 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) { 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 446cdea34..a4d7e5fc7 100644 --- a/src/test/java/dev/openfeature/sdk/e2e/steps/FlagStepDefinitions.java +++ b/src/test/java/dev/openfeature/sdk/e2e/steps/FlagStepDefinitions.java @@ -61,7 +61,15 @@ 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}") diff --git a/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java b/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java index e0c44eabc..23e11d90f 100644 --- a/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java +++ b/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java @@ -12,6 +12,7 @@ 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; @@ -32,33 +33,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() { - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.configure(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION.mappedFeature(), true); - objectMapper.addMixIn(Flag.class, InMemoryFlagMixin.class); - objectMapper.addMixIn(Flag.FlagBuilder.class, InMemoryFlagMixin.FlagBuilderMixin.class); + if (flags == null) { + ObjectMapper objectMapper = new ObjectMapper(); + 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); + 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<>() {}); + 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); + } catch (IOException e) { + throw new RuntimeException(e); + } + flags = Collections.unmodifiableMap(flagsJson); } - return 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 index 20f3f5f50..45bf37805 100644 --- a/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java +++ b/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java @@ -19,7 +19,7 @@ public CelContextEvaluator(String expression) { CelRuntime celRuntime = CelRuntimeFactory.standardCelRuntimeBuilder().build(); CelCompiler celCompiler = CelCompilerFactory.standardCelCompilerBuilder() - .addVar("customer", SimpleType.STRING) + .addVar("customer", SimpleType.BOOL) .addVar("email", SimpleType.STRING) .addVar("dummy", SimpleType.STRING) .setResultType(SimpleType.STRING) @@ -36,6 +36,7 @@ public CelContextEvaluator(String expression) { @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", ""); @@ -46,7 +47,6 @@ public T evaluate(Flag flag, EvaluationContext evaluationContext) { // Evaluate with context, overriding defaults. objectMap.putAll(evaluationContext.asObjectMap()); } - } Object result = program.eval(objectMap); diff --git a/src/test/java/dev/openfeature/sdk/testutils/jackson/VariantsMapDeserializer.java b/src/test/java/dev/openfeature/sdk/testutils/jackson/VariantsMapDeserializer.java index 339d8c84f..f7a621cbb 100644 --- a/src/test/java/dev/openfeature/sdk/testutils/jackson/VariantsMapDeserializer.java +++ b/src/test/java/dev/openfeature/sdk/testutils/jackson/VariantsMapDeserializer.java @@ -1,13 +1,12 @@ 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.MutableStructure; import dev.openfeature.sdk.Value; import java.io.IOException; -import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.List; @@ -27,29 +26,24 @@ public Map deserialize(JsonParser p, DeserializationContext ctxt JsonNode variantNode = field.getValue(); // Convert the variant value to OpenFeature Value - Object variantValue = null; - try { - variantValue = convertToValue(variantNode); - } catch (InstantiationException e) { - throw new RuntimeException(e); - } + Object variantValue = convertToValue(p, variantNode); variants.put(variantKey, variantValue); } return variants; } - private Object convertToValue(JsonNode node) throws InstantiationException { + 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(node.get("value")); + return convertJsonNodeToValue(p, node.get("value")); } // Otherwise, treat the entire node as the value - return convertJsonNodeToValue(node); + return convertJsonNodeToValue(p, node); } - private Object convertJsonNodeToValue(JsonNode node) throws InstantiationException { + private Object convertJsonNodeToValue(JsonParser p, JsonNode node) throws JsonProcessingException { if (node.isNull()) { return null; } else if (node.isBoolean()) { @@ -61,24 +55,9 @@ private Object convertJsonNodeToValue(JsonNode node) throws InstantiationExcepti } else if (node.isTextual()) { return node.asText(); } else if (node.isArray()) { - List list = new ArrayList<>(); - for (JsonNode item : node) { - list.add(convertJsonNodeToValue(item)); - } - return list; + return Value.objectToValue(p.getCodec().treeToValue(node, List.class)); } else if (node.isObject()) { - Map map = new HashMap<>(); - Iterator> fields = node.fields(); - while (fields.hasNext()) { - Map.Entry field = fields.next(); - Object o = convertJsonNodeToValue(field.getValue()); - if (o instanceof Value) { - map.put(field.getKey(), (Value) o); - } else { - map.put(field.getKey(), new Value(o)); - } - } - return new Value(new MutableStructure(map)); + return Value.objectToValue(p.getCodec().treeToValue(node, Object.class)); } throw new IllegalArgumentException("Unsupported JSON node type: " + node.getNodeType()); From 71951f82371c8fe5297f3aba9635021819f00b02 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Mon, 25 Aug 2025 13:34:43 +0200 Subject: [PATCH 05/11] Update src/test/java/dev/openfeature/sdk/e2e/steps/FlagStepDefinitions.java Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Simon Schrottner --- .../dev/openfeature/sdk/e2e/steps/FlagStepDefinitions.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 a4d7e5fc7..577cb13d7 100644 --- a/src/test/java/dev/openfeature/sdk/e2e/steps/FlagStepDefinitions.java +++ b/src/test/java/dev/openfeature/sdk/e2e/steps/FlagStepDefinitions.java @@ -97,8 +97,8 @@ public void the_error_code_should_be(String errorCode) { } @Then("the error message should contain {string}") - public void the_error_error_message_should_contain(String errorCode) { - assertThat(state.evaluation.getErrorMessage()).contains(errorCode); + public void the_error_message_should_contain(String messageSubstring) { + assertThat(state.evaluation.getErrorMessage()).contains(messageSubstring); } @Then("the resolved metadata value \"{}\" with type \"{}\" should be \"{}\"") From cf34c776ea8af7991ee888202b55c9300497ac4a Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Mon, 25 Aug 2025 13:34:59 +0200 Subject: [PATCH 06/11] Update src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Simon Schrottner --- src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java b/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java index 23e11d90f..78a2fc55d 100644 --- a/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java +++ b/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java @@ -39,7 +39,7 @@ public class TestFlagsUtils { * * @return map of flags */ - public static Map> buildFlags() { + public static synchronized Map> buildFlags() { if (flags == null) { ObjectMapper objectMapper = new ObjectMapper(); objectMapper.configure(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION.mappedFeature(), true); From f3512f9506c65733c9098d7bbc6775282155dfce Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Mon, 25 Aug 2025 08:24:59 +0200 Subject: [PATCH 07/11] test: improve gherkin test suite only relying on the newest version, with data loading Signed-off-by: Simon Schrottner diff --git c/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java i/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java index e4cd603..121b567 100644 --- c/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java +++ i/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java @@ -114,9 +114,9 @@ public class ContextSteps { public void a_context_containing_a_key_with_type_and_with_value(String key, String type, String value) throws ClassNotFoundException, InstantiationException { Map map = state.context.asMap(); - Map map = state.context.asMap(); map.put(key, new Value(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) { diff --git c/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java i/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java index 20f3f5f..138c23f 100644 --- c/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java +++ i/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java @@ -46,7 +46,6 @@ public class CelContextEvaluator implements ContextEvaluator { // Evaluate with context, overriding defaults. objectMap.putAll(evaluationContext.asObjectMap()); } - } Object result = program.eval(objectMap); diff --git c/src/test/java/dev/openfeature/sdk/testutils/jackson/VariantsMapDeserializer.java i/src/test/java/dev/openfeature/sdk/testutils/jackson/VariantsMapDeserializer.java index 339d8c8..9a63f58 100644 --- c/src/test/java/dev/openfeature/sdk/testutils/jackson/VariantsMapDeserializer.java +++ i/src/test/java/dev/openfeature/sdk/testutils/jackson/VariantsMapDeserializer.java @@ -50,6 +50,7 @@ public class VariantsMapDeserializer extends JsonDeserializer list = new ArrayList<>(); - for (JsonNode item : node) { - list.add(convertJsonNodeToValue(item)); - } - return list; } else if (node.isObject()) { - Map map = new HashMap<>(); - Iterator> fields = node.fields(); - while (fields.hasNext()) { - Map.Entry field = fields.next(); - Object o = convertJsonNodeToValue(field.getValue()); - if (o instanceof Value) { - map.put(field.getKey(), (Value) o); - } else { - map.put(field.getKey(), new Value(o)); - } - } - return new Value(new MutableStructure(map)); + return Value.objectToValue(node); } throw new IllegalArgumentException("Unsupported JSON node type: " + node.getNodeType()); Signed-off-by: Simon Schrottner diff --git c/src/test/java/dev/openfeature/sdk/e2e/Utils.java i/src/test/java/dev/openfeature/sdk/e2e/Utils.java index 1500d99..565968c 100644 --- c/src/test/java/dev/openfeature/sdk/e2e/Utils.java +++ i/src/test/java/dev/openfeature/sdk/e2e/Utils.java @@ -7,6 +7,8 @@ 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) { @@ -27,7 +29,7 @@ public final class Utils { return Long.parseLong(value); case "object": try { - return Value.objectToValue(new ObjectMapper().readValue(value, Object.class)); + return Value.objectToValue(OBJECT_MAPPER.readValue(value, Object.class)); } catch (JsonProcessingException e) { throw new RuntimeException(e); } diff --git c/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java i/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java index 78a2fc5..13fe32f 100644 --- c/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java +++ i/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java @@ -17,6 +17,8 @@ import java.util.Map; import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; +import static dev.openfeature.sdk.e2e.Utils.OBJECT_MAPPER; + /** * Test flags utils. */ @@ -41,7 +43,8 @@ public class TestFlagsUtils { */ public static synchronized Map> buildFlags() { if (flags == null) { - ObjectMapper objectMapper = new ObjectMapper(); + 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); Signed-off-by: Simon Schrottner --- spec | 2 +- src/test/java/dev/openfeature/sdk/e2e/Utils.java | 4 +++- .../java/dev/openfeature/sdk/testutils/TestFlagsUtils.java | 4 +++- .../sdk/testutils/jackson/CelContextEvaluator.java | 4 +++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/spec b/spec index 0795ac4ca..c2da41904 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 0795ac4ca253b1120a67a1b819a67016a92e2a7d +Subproject commit c2da419047cf70010e6e2ef9442b19809b14f721 diff --git a/src/test/java/dev/openfeature/sdk/e2e/Utils.java b/src/test/java/dev/openfeature/sdk/e2e/Utils.java index 1500d997a..565968c1c 100644 --- a/src/test/java/dev/openfeature/sdk/e2e/Utils.java +++ b/src/test/java/dev/openfeature/sdk/e2e/Utils.java @@ -7,6 +7,8 @@ public final class Utils { + public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private Utils() {} public static Object convert(String value, String type) { @@ -27,7 +29,7 @@ public static Object convert(String value, String type) { return Long.parseLong(value); case "object": try { - return Value.objectToValue(new ObjectMapper().readValue(value, Object.class)); + return Value.objectToValue(OBJECT_MAPPER.readValue(value, Object.class)); } catch (JsonProcessingException e) { throw new RuntimeException(e); } diff --git a/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java b/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java index 78a2fc55d..7c45166f9 100644 --- a/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java +++ b/src/test/java/dev/openfeature/sdk/testutils/TestFlagsUtils.java @@ -1,5 +1,7 @@ package dev.openfeature.sdk.testutils; +import static dev.openfeature.sdk.e2e.Utils.OBJECT_MAPPER; + import com.fasterxml.jackson.core.StreamReadFeature; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -41,7 +43,7 @@ public class TestFlagsUtils { */ public static synchronized Map> buildFlags() { if (flags == null) { - ObjectMapper objectMapper = new ObjectMapper(); + 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); diff --git a/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java b/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java index 45bf37805..6ca3875ef 100644 --- a/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java +++ b/src/test/java/dev/openfeature/sdk/testutils/jackson/CelContextEvaluator.java @@ -21,6 +21,7 @@ public CelContextEvaluator(String expression) { 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 @@ -40,7 +41,8 @@ public T evaluate(Flag flag, EvaluationContext evaluationContext) { Map objectMap = new HashMap<>(); // Provide defaults for all declared variables to prevent runtime errors. objectMap.put("email", ""); - objectMap.put("customer", ""); + objectMap.put("customer", true); + objectMap.put("age", 0); objectMap.put("dummy", ""); if (evaluationContext != null) { From e0970325d3cf673c44ad9837274fffc19c7895ab Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Mon, 25 Aug 2025 16:24:22 +0200 Subject: [PATCH 08/11] fixup: add provider-status tests Signed-off-by: Simon Schrottner --- .../openfeature/sdk/e2e/GherkinSpecTest.java | 2 +- .../sdk/e2e/steps/ProviderSteps.java | 138 +++++++++++++++++- 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/src/test/java/dev/openfeature/sdk/e2e/GherkinSpecTest.java b/src/test/java/dev/openfeature/sdk/e2e/GherkinSpecTest.java index 460bece92..c4b603816 100644 --- a/src/test/java/dev/openfeature/sdk/e2e/GherkinSpecTest.java +++ b/src/test/java/dev/openfeature/sdk/e2e/GherkinSpecTest.java @@ -16,5 +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") -@ExcludeTags({"deprecated", "provider-status", "reason-codes", "async", "immutability", "evaluation-options"}) +@ExcludeTags({"deprecated", "reason-codes", "async", "immutability", "evaluation-options"}) public class GherkinSpecTest {} 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..64784b0ac 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,31 @@ 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.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 +34,127 @@ 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); + state.client = OpenFeatureAPI.getInstance().getClient(providerState.name()); + + 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 = state.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(); + } } From 017611b9022d983af54f4af1f79682b9d7636fdc Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Wed, 10 Sep 2025 16:29:40 +0200 Subject: [PATCH 09/11] fixup: update spec Signed-off-by: Simon Schrottner --- spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec b/spec index c2da41904..43c36a988 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit c2da419047cf70010e6e2ef9442b19809b14f721 +Subproject commit 43c36a988d2c1ea4d8bfadf911d72e70bcbb093c From 4a3faf1a800776f91d4155b3ac6e75bed20195d9 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Wed, 10 Sep 2025 20:27:31 +0200 Subject: [PATCH 10/11] fixup: add missing steps and adapt to new spec steps Signed-off-by: Simon Schrottner --- spec | 2 +- .../sdk/providers/memory/Flag.java | 1 + .../providers/memory/InMemoryProvider.java | 20 +++++++++++++------ .../openfeature/sdk/e2e/GherkinSpecTest.java | 2 +- .../sdk/e2e/steps/FlagStepDefinitions.java | 2 +- 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/spec b/spec index 43c36a988..e33a15e92 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 43c36a988d2c1ea4d8bfadf911d72e70bcbb093c +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 88b98acf0..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"); @@ -140,6 +141,13 @@ private ProviderEvaluation getEvaluation( if (flag == null) { 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) { diff --git a/src/test/java/dev/openfeature/sdk/e2e/GherkinSpecTest.java b/src/test/java/dev/openfeature/sdk/e2e/GherkinSpecTest.java index c4b603816..89c7161be 100644 --- a/src/test/java/dev/openfeature/sdk/e2e/GherkinSpecTest.java +++ b/src/test/java/dev/openfeature/sdk/e2e/GherkinSpecTest.java @@ -16,5 +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") -@ExcludeTags({"deprecated", "reason-codes", "async", "immutability", "evaluation-options"}) +@ExcludeTags({"deprecated", "reason-codes-cached", "async", "immutability", "evaluation-options"}) public class GherkinSpecTest {} 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 577cb13d7..dccdbf9af 100644 --- a/src/test/java/dev/openfeature/sdk/e2e/steps/FlagStepDefinitions.java +++ b/src/test/java/dev/openfeature/sdk/e2e/steps/FlagStepDefinitions.java @@ -24,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)); } From af30a3520d901f7401ae56704e94d2662268d69c Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Mon, 15 Sep 2025 15:24:29 +0200 Subject: [PATCH 11/11] fix: race condition? Signed-off-by: Simon Schrottner --- .../java/dev/openfeature/sdk/e2e/steps/ProviderSteps.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 64784b0ac..d9dde3c2b 100644 --- a/src/test/java/dev/openfeature/sdk/e2e/steps/ProviderSteps.java +++ b/src/test/java/dev/openfeature/sdk/e2e/steps/ProviderSteps.java @@ -9,6 +9,7 @@ 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; @@ -104,7 +105,8 @@ private void setupMockProvider(ErrorCode errorCode, String errorMessage, Provide configureMockEvaluations(mockProvider, errorCode, errorMessage); OpenFeatureAPI.getInstance().setProvider(providerState.name(), mockProvider); - state.client = OpenFeatureAPI.getInstance().getClient(providerState.name()); + Client client = OpenFeatureAPI.getInstance().getClient(providerState.name()); + state.client = client; ProviderEventDetails details = ProviderEventDetails.builder().errorCode(errorCode).build(); @@ -121,7 +123,7 @@ private void setupMockProvider(ErrorCode errorCode, String errorMessage, Provide default: } Awaitility.await().until(() -> { - ProviderState providerState1 = state.client.getProviderState(); + ProviderState providerState1 = client.getProviderState(); return providerState1 == providerState; }); }