diff --git a/pom.xml b/pom.xml
index 728d45b95..d850e4f0c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -143,6 +143,13 @@
test
+
+ io.cucumber
+ cucumber-picocontainer
+ ${io.cucumber.version}
+ test
+
+
org.simplify4u
slf4j2-mock
@@ -681,21 +688,6 @@
-
- copy-evaluation-gherkin-tests
- validate
-
- exec
-
-
-
- cp
-
- spec/specification/assets/gherkin/evaluation.feature
- src/test/resources/features/
-
-
-
diff --git a/src/main/java/dev/openfeature/sdk/ImmutableMetadata.java b/src/main/java/dev/openfeature/sdk/ImmutableMetadata.java
index c2b6f5838..f8311a9a5 100644
--- a/src/main/java/dev/openfeature/sdk/ImmutableMetadata.java
+++ b/src/main/java/dev/openfeature/sdk/ImmutableMetadata.java
@@ -97,6 +97,10 @@ public T getValue(final String key, final Class type) {
}
}
+ public boolean isEmpty() {
+ return metadata.isEmpty();
+ }
+
/**
* Obtain a builder for {@link ImmutableMetadata}.
*/
diff --git a/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java b/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java
index a393d83e8..3022ff006 100644
--- a/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java
+++ b/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java
@@ -217,7 +217,7 @@ private FlagEvaluationDetails evaluateFlag(
}
} catch (Exception e) {
if (details == null) {
- details = FlagEvaluationDetails.builder().build();
+ details = FlagEvaluationDetails.builder().flagKey(key).build();
}
if (e instanceof OpenFeatureError) {
details.setErrorCode(((OpenFeatureError) e).getErrorCode());
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 61778d85b..f2dc6b495 100644
--- a/src/main/java/dev/openfeature/sdk/providers/memory/Flag.java
+++ b/src/main/java/dev/openfeature/sdk/providers/memory/Flag.java
@@ -1,5 +1,6 @@
package dev.openfeature.sdk.providers.memory;
+import dev.openfeature.sdk.ImmutableMetadata;
import java.util.Map;
import lombok.Builder;
import lombok.Getter;
@@ -18,4 +19,5 @@ public class Flag {
private String defaultVariant;
private ContextEvaluator contextEvaluator;
+ private ImmutableMetadata flagMetadata;
}
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 d3fdb985c..3be1b6316 100644
--- a/src/main/java/dev/openfeature/sdk/providers/memory/InMemoryProvider.java
+++ b/src/main/java/dev/openfeature/sdk/providers/memory/InMemoryProvider.java
@@ -152,6 +152,7 @@ private ProviderEvaluation getEvaluation(
.value(value)
.variant(flag.getDefaultVariant())
.reason(Reason.STATIC.toString())
+ .flagMetadata(flag.getFlagMetadata())
.build();
}
}
diff --git a/src/test/java/dev/openfeature/sdk/FlagMetadataTest.java b/src/test/java/dev/openfeature/sdk/FlagMetadataTest.java
index f8b9ba58e..26d0421cd 100644
--- a/src/test/java/dev/openfeature/sdk/FlagMetadataTest.java
+++ b/src/test/java/dev/openfeature/sdk/FlagMetadataTest.java
@@ -1,6 +1,8 @@
package dev.openfeature.sdk;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -9,7 +11,7 @@ class FlagMetadataTest {
@Test
@DisplayName("Test metadata payload construction and retrieval")
- public void builder_validation() {
+ void builder_validation() {
// given
ImmutableMetadata flagMetadata = ImmutableMetadata.builder()
.addString("string", "string")
@@ -42,7 +44,7 @@ public void builder_validation() {
@Test
@DisplayName("Value type mismatch returns a null")
- public void value_type_validation() {
+ void value_type_validation() {
// given
ImmutableMetadata flagMetadata =
ImmutableMetadata.builder().addString("string", "string").build();
@@ -53,11 +55,32 @@ public void value_type_validation() {
@Test
@DisplayName("A null is returned if key does not exist")
- public void notfound_error_validation() {
+ void notfound_error_validation() {
// given
ImmutableMetadata flagMetadata = ImmutableMetadata.builder().build();
// then
assertThat(flagMetadata.getBoolean("string")).isNull();
}
+
+ @Test
+ @DisplayName("isEmpty returns true iff the metadata is empty")
+ void isEmpty_returns_true_if_metadata_is_empty() {
+ // given
+ ImmutableMetadata flagMetadata = ImmutableMetadata.builder().build();
+
+ // then
+ assertTrue(flagMetadata.isEmpty());
+ }
+
+ @Test
+ @DisplayName("isEmpty returns false iff the metadata is not empty")
+ void isEmpty_returns_false_if_metadata_is_not_empty() {
+ // given
+ ImmutableMetadata flagMetadata =
+ ImmutableMetadata.builder().addString("a", "b").build();
+
+ // then
+ assertFalse(flagMetadata.isEmpty());
+ }
}
diff --git a/src/test/java/dev/openfeature/sdk/e2e/EvaluationTest.java b/src/test/java/dev/openfeature/sdk/e2e/EvaluationTest.java
index 8a3381412..b7c834312 100644
--- a/src/test/java/dev/openfeature/sdk/e2e/EvaluationTest.java
+++ b/src/test/java/dev/openfeature/sdk/e2e/EvaluationTest.java
@@ -1,16 +1,18 @@
package dev.openfeature.sdk.e2e;
import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME;
+import static io.cucumber.junit.platform.engine.Constants.OBJECT_FACTORY_PROPERTY_NAME;
import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME;
import org.junit.platform.suite.api.ConfigurationParameter;
import org.junit.platform.suite.api.IncludeEngines;
-import org.junit.platform.suite.api.SelectClasspathResource;
+import org.junit.platform.suite.api.SelectDirectories;
import org.junit.platform.suite.api.Suite;
@Suite
@IncludeEngines("cucumber")
-@SelectClasspathResource("features/evaluation.feature")
+@SelectDirectories("spec/specification/assets/gherkin")
@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty")
-@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.sdk.e2e.evaluation")
+@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 {}
diff --git a/src/test/java/dev/openfeature/sdk/e2e/Flag.java b/src/test/java/dev/openfeature/sdk/e2e/Flag.java
new file mode 100644
index 000000000..2c4ffdb57
--- /dev/null
+++ b/src/test/java/dev/openfeature/sdk/e2e/Flag.java
@@ -0,0 +1,13 @@
+package dev.openfeature.sdk.e2e;
+
+public class Flag {
+ public String name;
+ public Object defaultValue;
+ public String type;
+
+ public Flag(String type, String name, Object defaultValue) {
+ this.name = name;
+ this.defaultValue = defaultValue;
+ this.type = type;
+ }
+}
diff --git a/src/test/java/dev/openfeature/sdk/e2e/MockHook.java b/src/test/java/dev/openfeature/sdk/e2e/MockHook.java
new file mode 100644
index 000000000..ac107cfd6
--- /dev/null
+++ b/src/test/java/dev/openfeature/sdk/e2e/MockHook.java
@@ -0,0 +1,50 @@
+package dev.openfeature.sdk.e2e;
+
+import dev.openfeature.sdk.EvaluationContext;
+import dev.openfeature.sdk.FlagEvaluationDetails;
+import dev.openfeature.sdk.Hook;
+import dev.openfeature.sdk.HookContext;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import lombok.Getter;
+
+public class MockHook implements Hook {
+ @Getter
+ private boolean beforeCalled;
+
+ @Getter
+ private boolean afterCalled;
+
+ @Getter
+ private boolean errorCalled;
+
+ @Getter
+ private boolean finallyAfterCalled;
+
+ @Getter
+ private final Map evaluationDetails = new HashMap<>();
+
+ @Override
+ public Optional before(HookContext ctx, Map hints) {
+ beforeCalled = true;
+ return Optional.of(ctx.getCtx());
+ }
+
+ @Override
+ public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) {
+ afterCalled = true;
+ evaluationDetails.put("after", details);
+ }
+
+ @Override
+ public void error(HookContext ctx, Exception error, Map hints) {
+ errorCalled = true;
+ }
+
+ @Override
+ public void finallyAfter(HookContext ctx, FlagEvaluationDetails details, Map hints) {
+ finallyAfterCalled = true;
+ evaluationDetails.put("finally", details);
+ }
+}
diff --git a/src/test/java/dev/openfeature/sdk/e2e/State.java b/src/test/java/dev/openfeature/sdk/e2e/State.java
new file mode 100644
index 000000000..ee513b00e
--- /dev/null
+++ b/src/test/java/dev/openfeature/sdk/e2e/State.java
@@ -0,0 +1,13 @@
+package dev.openfeature.sdk.e2e;
+
+import dev.openfeature.sdk.Client;
+import dev.openfeature.sdk.FlagEvaluationDetails;
+import dev.openfeature.sdk.MutableContext;
+
+public class State {
+ public Client client;
+ public Flag flag;
+ public MutableContext context = new MutableContext();
+ public FlagEvaluationDetails evaluation;
+ public MockHook hook;
+}
diff --git a/src/test/java/dev/openfeature/sdk/e2e/Utils.java b/src/test/java/dev/openfeature/sdk/e2e/Utils.java
new file mode 100644
index 000000000..902ee11d0
--- /dev/null
+++ b/src/test/java/dev/openfeature/sdk/e2e/Utils.java
@@ -0,0 +1,28 @@
+package dev.openfeature.sdk.e2e;
+
+import java.util.Objects;
+
+public final class Utils {
+
+ private Utils() {}
+
+ public static Object convert(String value, String type) {
+ if (Objects.equals(value, "null")) {
+ return null;
+ }
+ switch (type.toLowerCase()) {
+ case "boolean":
+ return Boolean.parseBoolean(value);
+ case "string":
+ return value;
+ case "integer":
+ return Integer.parseInt(value);
+ case "float":
+ case "double":
+ return Double.parseDouble(value);
+ case "long":
+ return Long.parseLong(value);
+ }
+ throw new RuntimeException("Unknown config type: " + type);
+ }
+}
diff --git a/src/test/java/dev/openfeature/sdk/e2e/steps/FlagStepDefinitions.java b/src/test/java/dev/openfeature/sdk/e2e/steps/FlagStepDefinitions.java
new file mode 100644
index 000000000..390e067f3
--- /dev/null
+++ b/src/test/java/dev/openfeature/sdk/e2e/steps/FlagStepDefinitions.java
@@ -0,0 +1,104 @@
+package dev.openfeature.sdk.e2e.steps;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import dev.openfeature.sdk.FlagEvaluationDetails;
+import dev.openfeature.sdk.ImmutableMetadata;
+import dev.openfeature.sdk.Value;
+import dev.openfeature.sdk.e2e.Flag;
+import dev.openfeature.sdk.e2e.State;
+import dev.openfeature.sdk.e2e.Utils;
+import io.cucumber.datatable.DataTable;
+import io.cucumber.java.en.Given;
+import io.cucumber.java.en.Then;
+import io.cucumber.java.en.When;
+import java.lang.reflect.Field;
+import java.util.HashMap;
+import java.util.List;
+
+public class FlagStepDefinitions {
+ private final State state;
+
+ public FlagStepDefinitions(State state) {
+ this.state = state;
+ }
+
+ @Given("a {}-flag with key {string} and a default value {string}")
+ public void givenAFlag(String type, String name, String defaultValue) {
+ state.flag = new Flag(type, name, Utils.convert(defaultValue, type));
+ }
+
+ @When("the flag was evaluated with details")
+ public void the_flag_was_evaluated_with_details() {
+ FlagEvaluationDetails details;
+ switch (state.flag.type.toLowerCase()) {
+ case "string":
+ details =
+ state.client.getStringDetails(state.flag.name, (String) state.flag.defaultValue, state.context);
+ break;
+ case "boolean":
+ details = state.client.getBooleanDetails(
+ state.flag.name, (Boolean) state.flag.defaultValue, state.context);
+ break;
+ case "float":
+ details =
+ state.client.getDoubleDetails(state.flag.name, (Double) state.flag.defaultValue, state.context);
+ break;
+ case "integer":
+ details = state.client.getIntegerDetails(
+ state.flag.name, (Integer) state.flag.defaultValue, state.context);
+ break;
+ case "object":
+ details =
+ state.client.getObjectDetails(state.flag.name, (Value) state.flag.defaultValue, state.context);
+ break;
+ default:
+ throw new AssertionError();
+ }
+ state.evaluation = 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));
+ }
+
+ @Then("the reason should be {string}")
+ public void the_reason_should_be(String reason) {
+ assertThat(state.evaluation.getReason()).isEqualTo(reason);
+ }
+
+ @Then("the variant should be {string}")
+ public void the_variant_should_be(String variant) {
+ assertThat(state.evaluation.getVariant()).isEqualTo(variant);
+ }
+
+ @Then("the resolved metadata value \"{}\" with type \"{}\" should be \"{}\"")
+ public void theResolvedMetadataValueShouldBe(String key, String type, String value)
+ throws NoSuchFieldException, IllegalAccessException {
+ Field f = state.evaluation.getFlagMetadata().getClass().getDeclaredField("metadata");
+ f.setAccessible(true);
+ HashMap metadata = (HashMap) f.get(state.evaluation.getFlagMetadata());
+ assertThat(metadata).containsEntry(key, Utils.convert(value, type));
+ }
+
+ @Then("the resolved metadata is empty")
+ public void theResolvedMetadataIsEmpty() {
+ assertThat(state.evaluation.getFlagMetadata().isEmpty()).isTrue();
+ }
+
+ @Then("the resolved metadata should contain")
+ public void theResolvedMetadataShouldContain(DataTable dataTable) {
+ ImmutableMetadata evaluationMetadata = state.evaluation.getFlagMetadata();
+ List> asLists = dataTable.asLists();
+ for (int i = 1; i < asLists.size(); i++) { // skip the header of the table
+ List line = asLists.get(i);
+ String key = line.get(0);
+ String metadataType = line.get(1);
+ Object value = Utils.convert(line.get(2), metadataType);
+
+ assertThat(value).isNotNull();
+ assertThat(evaluationMetadata.getValue(key, value.getClass())).isEqualTo(value);
+ }
+ }
+}
diff --git a/src/test/java/dev/openfeature/sdk/e2e/steps/HookSteps.java b/src/test/java/dev/openfeature/sdk/e2e/steps/HookSteps.java
new file mode 100644
index 000000000..1e6a9172f
--- /dev/null
+++ b/src/test/java/dev/openfeature/sdk/e2e/steps/HookSteps.java
@@ -0,0 +1,84 @@
+package dev.openfeature.sdk.e2e.steps;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import dev.openfeature.sdk.FlagEvaluationDetails;
+import dev.openfeature.sdk.e2e.MockHook;
+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;
+import io.cucumber.java.en.Then;
+import java.util.List;
+import java.util.Map;
+
+public class HookSteps {
+ private final State state;
+
+ public HookSteps(State state) {
+ this.state = state;
+ }
+
+ @Given("a client with added hook")
+ public void aClientWithAddedHook() {
+ MockHook hook = new MockHook();
+ state.hook = hook;
+ state.client.addHooks(hook);
+ }
+
+ @Then("the {string} hook should have been executed")
+ public void theHookShouldHaveBeenExecuted(String hookName) {
+ assertHookCalled(hookName);
+ }
+
+ public void assertHookCalled(String hookName) {
+ if ("before".equals(hookName)) {
+ assertTrue(state.hook.isBeforeCalled());
+ } else if ("after".equals(hookName)) {
+ assertTrue(state.hook.isAfterCalled());
+ } else if ("error".equals(hookName)) {
+ assertTrue(state.hook.isErrorCalled());
+ } else if ("finally".equals(hookName)) {
+ assertTrue(state.hook.isFinallyAfterCalled());
+ } else {
+ throw new IllegalArgumentException(hookName + " is not a valid hook name");
+ }
+ }
+
+ @And("the {string} hooks should be called with evaluation details")
+ public void theHooksShouldBeCalledWithEvaluationDetails(String hookNames, DataTable data) {
+ for (String hookName : hookNames.split(", ")) {
+ assertHookCalled(hookName);
+ FlagEvaluationDetails evaluationDetails =
+ state.hook.getEvaluationDetails().get(hookName);
+ assertNotNull(evaluationDetails);
+ List