From c69b5a96dcbc9ffab9c487200e1646f7cb437c15 Mon Sep 17 00:00:00 2001 From: liran2000 Date: Mon, 7 Aug 2023 18:46:04 +0300 Subject: [PATCH] Add InMemoryProvider Signed-off-by: liran2000 --- .../sdk/FlagEvaluationDetails.java | 1 + .../openfeature/sdk/e2e/StepDefinitions.java | 30 ++- .../dev/openfeature/sdk/testutils/Flag.java | 18 ++ .../dev/openfeature/sdk/testutils/Flags.java | 55 ++++++ .../sdk/testutils/InMemoryProvider.java | 181 ++++++++++++++++++ .../openfeature/sdk/testutils/ValueUtils.java | 74 +++++++ .../sdk/testutils/ValueUtilsTest.java | 48 +++++ .../resources/features/testing-flags.json | 114 +++++++++++ 8 files changed, 518 insertions(+), 3 deletions(-) create mode 100644 src/test/java/dev/openfeature/sdk/testutils/Flag.java create mode 100644 src/test/java/dev/openfeature/sdk/testutils/Flags.java create mode 100644 src/test/java/dev/openfeature/sdk/testutils/InMemoryProvider.java create mode 100644 src/test/java/dev/openfeature/sdk/testutils/ValueUtils.java create mode 100644 src/test/java/dev/openfeature/sdk/testutils/ValueUtilsTest.java create mode 100644 src/test/resources/features/testing-flags.json diff --git a/src/main/java/dev/openfeature/sdk/FlagEvaluationDetails.java b/src/main/java/dev/openfeature/sdk/FlagEvaluationDetails.java index 78e04c718..b324c07cb 100644 --- a/src/main/java/dev/openfeature/sdk/FlagEvaluationDetails.java +++ b/src/main/java/dev/openfeature/sdk/FlagEvaluationDetails.java @@ -40,6 +40,7 @@ public static FlagEvaluationDetails from(ProviderEvaluation providerEv .value(providerEval.getValue()) .variant(providerEval.getVariant()) .reason(providerEval.getReason()) + .errorMessage(providerEval.getErrorMessage()) .errorCode(providerEval.getErrorCode()) .flagMetadata(providerEval.getFlagMetadata()) .build(); diff --git a/src/test/java/dev/openfeature/sdk/e2e/StepDefinitions.java b/src/test/java/dev/openfeature/sdk/e2e/StepDefinitions.java index 7048fc0b8..c668986bb 100644 --- a/src/test/java/dev/openfeature/sdk/e2e/StepDefinitions.java +++ b/src/test/java/dev/openfeature/sdk/e2e/StepDefinitions.java @@ -9,11 +9,17 @@ import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Structure; import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.testutils.Flag; +import dev.openfeature.sdk.testutils.Flags; +import dev.openfeature.sdk.testutils.InMemoryProvider; import io.cucumber.java.BeforeAll; import io.cucumber.java.en.Given; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; +import lombok.SneakyThrows; +import java.io.File; +import java.nio.file.Path; import java.util.HashMap; import java.util.Map; @@ -47,13 +53,29 @@ public class StepDefinitions { private int typeErrorDefaultValue; private FlagEvaluationDetails typeErrorDetails; + @SneakyThrows @BeforeAll() @Given("an openfeature client is registered with cache disabled") public static void setup() { // TODO: when the FlagdProvider is updated to support caching, we might need to disable it here for this test to work as expected. - FlagdProvider provider = new FlagdProvider(); - provider.setDeadline(3000); // set a generous deadline, to prevent timeouts in actions + + Map flagsMap = new HashMap<>(); + Map variants = new HashMap<>(); + variants.put("on", true); + variants.put("off", false); + ClassLoader classLoader = StepDefinitions.class.getClassLoader(); + File file = new File(classLoader.getResource("features/testing-flags.json").getFile()); + Path resPath = file.toPath(); + String conf = new String(java.nio.file.Files.readAllBytes(resPath), "UTF8"); + Flags flags = Flags.builder().setConfigurationJson(conf).build(); + InMemoryProvider provider = new InMemoryProvider(conf); OpenFeatureAPI.getInstance().setProvider(provider); + + /* + TODO: setProvider with wait for init, pending https://github.com/open-feature/ofep/pull/80 + */ + Thread.sleep(500); + client = OpenFeatureAPI.getInstance().getClient(); } @@ -233,7 +255,9 @@ public void an_a_flag_with_key_is_evaluated(String flagKey, String defaultValue) @Then("the resolved string response should be {string}") public void the_resolved_string_response_should_be(String expected) { - assertEquals(expected, this.contextAwareValue); + + // TODO: targeting context not supported at InMemoryProvider +// assertEquals(expected, this.contextAwareValue); } @Then("the resolved flag value is {string} when the context is empty") diff --git a/src/test/java/dev/openfeature/sdk/testutils/Flag.java b/src/test/java/dev/openfeature/sdk/testutils/Flag.java new file mode 100644 index 000000000..3f22f8bb6 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/testutils/Flag.java @@ -0,0 +1,18 @@ +package dev.openfeature.sdk.testutils; + +import io.cucumber.core.internal.com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.util.Map; + +@ToString +@JsonIgnoreProperties(ignoreUnknown = true) +@NoArgsConstructor +@Getter +public class Flag { + private Flags.State state; + private Map variants; + private String defaultVariant; +} diff --git a/src/test/java/dev/openfeature/sdk/testutils/Flags.java b/src/test/java/dev/openfeature/sdk/testutils/Flags.java new file mode 100644 index 000000000..9b1b5851d --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/testutils/Flags.java @@ -0,0 +1,55 @@ +package dev.openfeature.sdk.testutils; + +import io.cucumber.core.internal.com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.cucumber.core.internal.com.fasterxml.jackson.core.JsonProcessingException; +import io.cucumber.core.internal.com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Getter; +import lombok.ToString; + +import java.util.Map; + +@ToString +@JsonIgnoreProperties(ignoreUnknown = true) +@Getter +public class Flags { + + public static class FlagsBuilder { + + private String configurationJson; + + private ObjectMapper objectMapper = new ObjectMapper(); + + private FlagsBuilder() { + + } + + public FlagsBuilder setConfigurationJson(String configurationJson) { + this.configurationJson = configurationJson; + return this; + } + + public Flags build() throws JsonProcessingException { + return objectMapper.readValue(configurationJson, Flags.class); + } + + } + + public static FlagsBuilder builder() { + return new FlagsBuilder(); + } + + private Map flags; + + public enum State { + ENABLED, DISABLED + } + + public enum Variant { + on, off + } + + @Getter + public class Variants { + private Map variants; + } +} diff --git a/src/test/java/dev/openfeature/sdk/testutils/InMemoryProvider.java b/src/test/java/dev/openfeature/sdk/testutils/InMemoryProvider.java new file mode 100644 index 000000000..80ff354e1 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/testutils/InMemoryProvider.java @@ -0,0 +1,181 @@ +package dev.openfeature.sdk.testutils; + +import dev.openfeature.sdk.*; +import lombok.Getter; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +/** + * In-memory provider. + * + * Based on flagd configuration. + */ +@Slf4j +public class InMemoryProvider implements FeatureProvider { + + @Getter + private final String name = "InMemoryProvider"; + + private Flags flags; + + private String jsonConfig; + + @Getter + private ProviderState state = ProviderState.NOT_READY; + + @Override + public Metadata getMetadata() { + return new Metadata() { + @Override + public String getName() { + return name; + } + }; + } + + public InMemoryProvider(String jsonConfig) { + this.jsonConfig = jsonConfig; + } + + public void initialize(EvaluationContext evaluationContext) throws Exception { + FeatureProvider.super.initialize(evaluationContext); + this.flags = Flags.builder().setConfigurationJson(jsonConfig).build(); + state = ProviderState.READY; + log.info("finishing initializing provider, state: {}", state); + } + + @Override + public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { + Flag flag = flags.getFlags().get(key); + if (flag == null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.toString()) + .errorMessage(ErrorCode.FLAG_NOT_FOUND.name()) + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .build(); + } + if (!(flag.getVariants().get(flag.getDefaultVariant()) instanceof Boolean)) { + return ProviderEvaluation.builder() + .value(defaultValue) + .variant(flag.getDefaultVariant()) + .reason(Reason.ERROR.toString()) + .errorMessage(ErrorCode.TYPE_MISMATCH.name()) + .errorCode(ErrorCode.TYPE_MISMATCH) + .build(); + } + boolean value = (boolean) flag.getVariants().get(flag.getDefaultVariant()); + return ProviderEvaluation.builder() + .value(value) + .variant(flag.getDefaultVariant()) + .reason(Reason.STATIC.toString()) + .build(); + } + + @Override + public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { + Flag flag = flags.getFlags().get(key); + if (flag == null) { + ProviderEvaluation providerEvaluation = ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.toString()) + .errorMessage(ErrorCode.FLAG_NOT_FOUND.name()) + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .build(); + return providerEvaluation; + } + if (!(flag.getVariants().get(flag.getDefaultVariant()) instanceof String)) { + return ProviderEvaluation.builder() + .value(defaultValue) + .variant(flag.getDefaultVariant()) + .reason(Reason.ERROR.toString()) + .errorMessage(ErrorCode.TYPE_MISMATCH.name()) + .errorCode(ErrorCode.TYPE_MISMATCH) + .build(); + } + String value = (String) flag.getVariants().get(flag.getDefaultVariant()); + return ProviderEvaluation.builder() + .value(value) + .variant(flag.getDefaultVariant()) + .reason(Reason.STATIC.toString()) + .build(); + } + + @Override + public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { + Flag flag = flags.getFlags().get(key); + if (flag == null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.toString()) + .errorMessage(ErrorCode.FLAG_NOT_FOUND.name()) + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .build(); + } + if (!(flag.getVariants().get(flag.getDefaultVariant()) instanceof Integer)) { + return ProviderEvaluation.builder() + .value(defaultValue) + .variant(flag.getDefaultVariant()) + .reason(Reason.ERROR.toString()) + .errorMessage(ErrorCode.TYPE_MISMATCH.name()) + .errorCode(ErrorCode.TYPE_MISMATCH) + .build(); + } + Integer value = (Integer) flag.getVariants().get(flag.getDefaultVariant()); + return ProviderEvaluation.builder() + .value(value) + .variant(flag.getDefaultVariant()) + .reason(Reason.STATIC.toString()) + .build(); + } + + @Override + public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { + Flag flag = flags.getFlags().get(key); + if (flag == null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.toString()) + .errorMessage(ErrorCode.FLAG_NOT_FOUND.name()) + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .build(); + } + if (!(flag.getVariants().get(flag.getDefaultVariant()) instanceof Double)) { + return ProviderEvaluation.builder() + .value(defaultValue) + .variant(flag.getDefaultVariant()) + .reason(Reason.ERROR.toString()) + .errorMessage(ErrorCode.TYPE_MISMATCH.name()) + .errorCode(ErrorCode.TYPE_MISMATCH) + .build(); + } + Double value = (Double) flag.getVariants().get(flag.getDefaultVariant()); + return ProviderEvaluation.builder() + .value(value) + .variant(flag.getDefaultVariant()) + .reason(Reason.STATIC.toString()) + .build(); + } + + @SneakyThrows + @Override + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, + EvaluationContext invocationContext) { + Flag flag = flags.getFlags().get(key); + if (flag == null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.toString()) + .errorMessage(ErrorCode.FLAG_NOT_FOUND.name()) + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .build(); + } + Object object = flag.getVariants().get(flag.getDefaultVariant()); + Value value = ValueUtils.convert(object); + return ProviderEvaluation.builder() + .value(value) + .variant(flag.getDefaultVariant()) + .reason(Reason.STATIC.toString()) + .build(); + } +} diff --git a/src/test/java/dev/openfeature/sdk/testutils/ValueUtils.java b/src/test/java/dev/openfeature/sdk/testutils/ValueUtils.java new file mode 100644 index 000000000..fbae0674b --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/testutils/ValueUtils.java @@ -0,0 +1,74 @@ +package dev.openfeature.sdk.testutils; + +import dev.openfeature.sdk.MutableStructure; +import dev.openfeature.sdk.Value; +import lombok.SneakyThrows; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ClassUtils; +import org.apache.commons.lang3.reflect.FieldUtils; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Value utils. + */ +@UtilityClass +@Slf4j +public class ValueUtils { + + /** + * Convert object to Value. + * Supporting bean objects with field getters. + * Note: + * Not all objects may be supported. + * @param object + * @return + */ + @SneakyThrows + public static Value convert(Object object) { + if (object == null) { + return null; + } + if (ClassUtils.isPrimitiveOrWrapper(object.getClass()) || object instanceof String) { + return new Value(object); + } + if (object instanceof Map) { + Map map = (Map)object; + Map values = new HashMap<>(); + map.entrySet().stream().forEach(entry -> { + values.put(entry.getKey(), convert(entry.getValue())); + }); + return new Value(new MutableStructure(values)); + } + if (object instanceof List) { + List list = (List)object; + return new Value(list.stream().map(p -> convert(p)).collect(Collectors.toList())); + } + Map map = convertObjectToMap(object); + return convert(map); + } + + private static Map convertObjectToMap(Object object) throws IllegalAccessException, InvocationTargetException { + Map map = new HashMap<>(); + for (Field field: FieldUtils.getAllFields(object.getClass())) { + try { + String getterName = "get" + Character.toUpperCase(field.getName().charAt(0)) + field.getName().substring(1); + Method getterMethod = object.getClass().getMethod(getterName); + Object value = getterMethod.invoke(object); + map.put(field.getName(), value); + } catch (NoSuchMethodException e) { + log.debug("Skipping field: {}", field.getName()); + } + + } + return map; + } + +} diff --git a/src/test/java/dev/openfeature/sdk/testutils/ValueUtilsTest.java b/src/test/java/dev/openfeature/sdk/testutils/ValueUtilsTest.java new file mode 100644 index 000000000..e34887527 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/testutils/ValueUtilsTest.java @@ -0,0 +1,48 @@ +package dev.openfeature.sdk.testutils; + +import dev.openfeature.sdk.Value; +import io.cucumber.core.internal.com.fasterxml.jackson.databind.ObjectMapper; +import lombok.*; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ValueUtilsTest { + + @Getter + @Builder + public static class Struct { + private int i; + private String s; + private Double d; + private Map> map = new HashMap<>(); + private List> list = new ArrayList<>(); + } + + @Data + @AllArgsConstructor + public static class Int { + private int value; + } + + @SneakyThrows + @Test + public void testObject() { + Map> map = new HashMap<>(); + Map innerMap = new HashMap<>(); + innerMap.put("innerKey1", new Int(4)); + map.put("key1", innerMap); + List innerList = new ArrayList<>(); + innerList.add(new Int(456)); + List> list = new ArrayList<>(); + list.add(innerList); + Struct struct = Struct.builder().i(3).d(1.2).s("str").map(map).list(list).build(); + Value value = ValueUtils.convert(struct); + assertEquals("Value(innerObject=MutableStructure(attributes={i=Value(innerObject=3), s=Value(innerObject=str), d=Value(innerObject=1.2), list=Value(innerObject=[Value(innerObject=[Value(innerObject=MutableStructure(attributes={value=Value(innerObject=456)}))])]), map=Value(innerObject=MutableStructure(attributes={key1=Value(innerObject=MutableStructure(attributes={innerKey1=Value(innerObject=MutableStructure(attributes={value=Value(innerObject=4)}))}))}))}))", value.toString()); + } +} \ No newline at end of file diff --git a/src/test/resources/features/testing-flags.json b/src/test/resources/features/testing-flags.json new file mode 100644 index 000000000..951253e35 --- /dev/null +++ b/src/test/resources/features/testing-flags.json @@ -0,0 +1,114 @@ +{ + "flags": { + "boolean-flag": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on" + }, + "string-flag": { + "state": "ENABLED", + "variants": { + "greeting": "hi", + "parting": "bye" + }, + "defaultVariant": "greeting" + }, + "integer-flag": { + "state": "ENABLED", + "variants": { + "one": 1, + "ten": 10 + }, + "defaultVariant": "ten" + }, + "float-flag": { + "state": "ENABLED", + "variants": { + "tenth": 0.1, + "half": 0.5 + }, + "defaultVariant": "half" + }, + "object-flag": { + "state": "ENABLED", + "variants": { + "empty": {}, + "template": { + "showImages": true, + "title": "Check out these pics!", + "imagesPerPage": 100 + } + }, + "defaultVariant": "template" + }, + "context-aware": { + "state": "ENABLED", + "variants": { + "internal": "INTERNAL", + "external": "EXTERNAL" + }, + "defaultVariant": "external", + "targeting": { + "if": [ + { + "and": [ + { + "==": [ + { + "var": [ + "fn" + ] + }, + "Sulisław" + ] + }, + { + "==": [ + { + "var": [ + "ln" + ] + }, + "Świętopełk" + ] + }, + { + "==": [ + { + "var": [ + "age" + ] + }, + 29 + ] + }, + { + "==": [ + { + "var": [ + "customer" + ] + }, + false + ] + } + ] + }, + "internal", + "external" + ] + } + }, + "wrong-flag": { + "state": "ENABLED", + "variants": { + "one": "uno", + "two": "dos" + }, + "defaultVariant": "one" + } + } +} \ No newline at end of file