diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolver.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolver.java index 86111e055..95f2eb6d6 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolver.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolver.java @@ -10,6 +10,7 @@ import dev.openfeature.contrib.providers.flagd.resolver.process.model.FeatureFlag; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.FlagStore; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.Storage; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageQueryResult; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageState; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageStateChange; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.Connector; @@ -25,6 +26,7 @@ import dev.openfeature.sdk.Value; import dev.openfeature.sdk.exceptions.ParseError; import dev.openfeature.sdk.exceptions.TypeMismatchError; +import java.util.Map; import java.util.function.Consumer; import java.util.function.Supplier; import lombok.extern.slf4j.Slf4j; @@ -40,8 +42,8 @@ public class InProcessResolver implements Resolver { private final Consumer onConnectionEvent; private final Operator operator; private final long deadline; - private final ImmutableMetadata metadata; private final Supplier connectedSupplier; + private final String scope; /** * Resolves flag values using @@ -63,11 +65,7 @@ public InProcessResolver( this.onConnectionEvent = onConnectionEvent; this.operator = new Operator(); this.connectedSupplier = connectedSupplier; - this.metadata = options.getSelector() == null - ? null - : ImmutableMetadata.builder() - .addString("scope", options.getSelector()) - .build(); + this.scope = options.getSelector(); } /** @@ -167,13 +165,15 @@ static Connector getConnector(final FlagdOptions options, Consumer ProviderEvaluation resolve(Class type, String key, EvaluationContext ctx) { - final FeatureFlag flag = flagStore.getFlag(key); + final StorageQueryResult storageQueryResult = flagStore.getFlag(key); + final FeatureFlag flag = storageQueryResult.getFeatureFlag(); // missing flag if (flag == null) { return ProviderEvaluation.builder() .errorMessage("flag: " + key + " not found") .errorCode(ErrorCode.FLAG_NOT_FOUND) + .flagMetadata(getFlagMetadata(storageQueryResult)) .build(); } @@ -182,6 +182,7 @@ private ProviderEvaluation resolve(Class type, String key, EvaluationC return ProviderEvaluation.builder() .errorMessage("flag: " + key + " is disabled") .errorCode(ErrorCode.FLAG_NOT_FOUND) + .flagMetadata(getFlagMetadata(storageQueryResult)) .build(); } @@ -228,13 +229,59 @@ private ProviderEvaluation resolve(Class type, String key, EvaluationC throw new TypeMismatchError(message); } - final ProviderEvaluation.ProviderEvaluationBuilder evaluationBuilder = ProviderEvaluation.builder() + return ProviderEvaluation.builder() .value((T) value) .variant(resolvedVariant) - .reason(reason); + .reason(reason) + .flagMetadata(getFlagMetadata(storageQueryResult)) + .build(); + } + + private ImmutableMetadata getFlagMetadata(StorageQueryResult storageQueryResult) { + ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder = ImmutableMetadata.builder(); + for (Map.Entry entry : + storageQueryResult.getFlagSetMetadata().entrySet()) { + addEntryToMetadataBuilder(metadataBuilder, entry.getKey(), entry.getValue()); + } - return this.metadata == null - ? evaluationBuilder.build() - : evaluationBuilder.flagMetadata(this.metadata).build(); + if (scope != null) { + metadataBuilder.addString("scope", scope); + } + + FeatureFlag flag = storageQueryResult.getFeatureFlag(); + if (flag != null) { + for (Map.Entry entry : flag.getMetadata().entrySet()) { + addEntryToMetadataBuilder(metadataBuilder, entry.getKey(), entry.getValue()); + } + } + + return metadataBuilder.build(); + } + + private void addEntryToMetadataBuilder( + ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder, String key, Object value) { + if (value instanceof Number) { + if (value instanceof Long) { + metadataBuilder.addLong(key, (Long) value); + return; + } else if (value instanceof Double) { + metadataBuilder.addDouble(key, (Double) value); + return; + } else if (value instanceof Integer) { + metadataBuilder.addInteger(key, (Integer) value); + return; + } else if (value instanceof Float) { + metadataBuilder.addFloat(key, (Float) value); + return; + } + } else if (value instanceof Boolean) { + metadataBuilder.addBoolean(key, (Boolean) value); + return; + } else if (value instanceof String) { + metadataBuilder.addString(key, (String) value); + return; + } + throw new IllegalArgumentException( + "The type of the Metadata entry with key " + key + " and value " + value + " is not supported"); } } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FeatureFlag.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FeatureFlag.java index 9a3475aa8..2eaf2ed87 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FeatureFlag.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FeatureFlag.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.HashMap; import java.util.Map; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -23,6 +24,7 @@ public class FeatureFlag { private final String defaultVariant; private final Map variants; private final String targeting; + private final Map metadata; /** Construct a flagd feature flag. */ @JsonCreator @@ -30,11 +32,26 @@ public FeatureFlag( @JsonProperty("state") String state, @JsonProperty("defaultVariant") String defaultVariant, @JsonProperty("variants") Map variants, - @JsonProperty("targeting") @JsonDeserialize(using = StringSerializer.class) String targeting) { + @JsonProperty("targeting") @JsonDeserialize(using = StringSerializer.class) String targeting, + @JsonProperty("metadata") Map metadata) { this.state = state; this.defaultVariant = defaultVariant; this.variants = variants; this.targeting = targeting; + if (metadata == null) { + this.metadata = new HashMap<>(); + } else { + this.metadata = metadata; + } + } + + /** Construct a flagd feature flag. */ + public FeatureFlag(String state, String defaultVariant, Map variants, String targeting) { + this.state = state; + this.defaultVariant = defaultVariant; + this.variants = variants; + this.targeting = targeting; + this.metadata = new HashMap<>(); } /** Get targeting rule of the flag. */ diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParser.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParser.java index 1dbe7f710..e3eedda6b 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParser.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParser.java @@ -1,7 +1,9 @@ package dev.openfeature.contrib.providers.flagd.resolver.process.model; import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.TreeNode; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.networknt.schema.JsonSchema; import com.networknt.schema.JsonSchemaFactory; @@ -24,6 +26,7 @@ justification = "Feature flag comes as a Json configuration, hence they must be exposed") public class FlagParser { private static final String FLAG_KEY = "flags"; + private static final String METADATA_KEY = "metadata"; private static final String EVALUATOR_KEY = "$evaluators"; private static final String REPLACER_FORMAT = "\"\\$ref\":(\\s)*\"%s\""; private static final ObjectMapper MAPPER = new ObjectMapper(); @@ -50,8 +53,7 @@ private FlagParser() {} } /** Parse {@link String} for feature flags. */ - public static Map parseString(final String configuration, boolean throwIfInvalid) - throws IOException { + public static ParsingResult parseString(final String configuration, boolean throwIfInvalid) throws IOException { if (SCHEMA_VALIDATOR != null) { try (JsonParser parser = MAPPER.createParser(configuration)) { Set validationMessages = SCHEMA_VALIDATOR.validate(parser.readValueAsTree()); @@ -69,10 +71,12 @@ public static Map parseString(final String configuration, b final String transposedConfiguration = transposeEvaluators(configuration); final Map flagMap = new HashMap<>(); - + final Map flagSetMetadata; try (JsonParser parser = MAPPER.createParser(transposedConfiguration)) { final TreeNode treeNode = parser.readValueAsTree(); final TreeNode flagNode = treeNode.get(FLAG_KEY); + final TreeNode metadataNode = treeNode.get(METADATA_KEY); + flagSetMetadata = parseMetadata(metadataNode); if (flagNode == null) { throw new IllegalArgumentException("No flag configurations found in the payload"); @@ -85,7 +89,16 @@ public static Map parseString(final String configuration, b } } - return flagMap; + return new ParsingResult(flagMap, flagSetMetadata); + } + + private static Map parseMetadata(TreeNode metadataNode) throws JsonProcessingException { + if (metadataNode == null) { + return new HashMap<>(); + } + + TypeReference> typeRef = new TypeReference>() {}; + return MAPPER.treeToValue(metadataNode, typeRef); } private static String transposeEvaluators(final String configuration) throws IOException { diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/ParsingResult.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/ParsingResult.java new file mode 100644 index 000000000..485611250 --- /dev/null +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/ParsingResult.java @@ -0,0 +1,22 @@ +package dev.openfeature.contrib.providers.flagd.resolver.process.model; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.Map; +import lombok.Getter; + +/** + * The result of the parsing of a json string containing feature flag definitions. + */ +@Getter +@SuppressFBWarnings( + value = {"EI_EXPOSE_REP"}, + justification = "Feature flag comes as a Json configuration, hence they must be exposed") +public class ParsingResult { + private final Map flags; + private final Map flagSetMetadata; + + public ParsingResult(Map flags, Map flagSetMetadata) { + this.flags = flags; + this.flagSetMetadata = flagSetMetadata; + } +} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/FlagStore.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/FlagStore.java index 7d419a353..0645af82a 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/FlagStore.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/FlagStore.java @@ -4,6 +4,7 @@ import dev.openfeature.contrib.providers.flagd.resolver.process.model.FeatureFlag; import dev.openfeature.contrib.providers.flagd.resolver.process.model.FlagParser; +import dev.openfeature.contrib.providers.flagd.resolver.process.model.ParsingResult; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.Connector; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; import dev.openfeature.flagd.grpc.sync.Sync.GetMetadataResponse; @@ -35,6 +36,7 @@ public class FlagStore implements Storage { private final AtomicBoolean shutdown = new AtomicBoolean(false); private final BlockingQueue stateBlockingQueue = new LinkedBlockingQueue<>(1); private final Map flags = new HashMap<>(); + private final Map flagSetMetadata = new HashMap<>(); private final Connector connector; private final boolean throwIfInvalid; @@ -49,6 +51,7 @@ public FlagStore(final Connector connector, final boolean throwIfInvalid) { } /** Initialize storage layer. */ + @Override public void init() throws Exception { connector.init(); Thread streamer = new Thread(() -> { @@ -68,6 +71,7 @@ public void init() throws Exception { * * @throws InterruptedException if stream can't be closed within deadline. */ + @Override public void shutdown() throws InterruptedException { if (shutdown.getAndSet(true)) { return; @@ -76,17 +80,23 @@ public void shutdown() throws InterruptedException { connector.shutdown(); } - /** Retrieve flag for the given key. */ - public FeatureFlag getFlag(final String key) { + /** Retrieve flag for the given key and the flag set metadata. */ + @Override + public StorageQueryResult getFlag(final String key) { readLock.lock(); + FeatureFlag flag; + Map metadata; try { - return flags.get(key); + flag = flags.get(key); + metadata = new HashMap<>(flagSetMetadata); } finally { readLock.unlock(); } + return new StorageQueryResult(flag, metadata); } /** Retrieve blocking queue to check storage status. */ + @Override public BlockingQueue getStateQueue() { return stateBlockingQueue; } @@ -100,14 +110,18 @@ private void streamerListener(final Connector connector) throws InterruptedExcep case DATA: try { List changedFlagsKeys; - Map flagMap = - FlagParser.parseString(payload.getFlagData(), throwIfInvalid); + ParsingResult parsingResult = FlagParser.parseString(payload.getFlagData(), throwIfInvalid); + Map flagMap = parsingResult.getFlags(); + Map flagSetMetadataMap = parsingResult.getFlagSetMetadata(); + Structure metadata = parseSyncMetadata(payload.getMetadataResponse()); writeLock.lock(); try { changedFlagsKeys = getChangedFlagsKeys(flagMap); flags.clear(); flags.putAll(flagMap); + flagSetMetadata.clear(); + flagSetMetadata.putAll(flagSetMetadataMap); } finally { writeLock.unlock(); } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/Storage.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/Storage.java index c18ce82b2..38cd89e26 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/Storage.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/Storage.java @@ -1,6 +1,5 @@ package dev.openfeature.contrib.providers.flagd.resolver.process.storage; -import dev.openfeature.contrib.providers.flagd.resolver.process.model.FeatureFlag; import java.util.concurrent.BlockingQueue; /** Storage abstraction for resolver. */ @@ -9,7 +8,7 @@ public interface Storage { void shutdown() throws InterruptedException; - FeatureFlag getFlag(final String key); + StorageQueryResult getFlag(final String key); BlockingQueue getStateQueue(); } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/StorageQueryResult.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/StorageQueryResult.java new file mode 100644 index 000000000..b0dad0533 --- /dev/null +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/StorageQueryResult.java @@ -0,0 +1,24 @@ +package dev.openfeature.contrib.providers.flagd.resolver.process.storage; + +import dev.openfeature.contrib.providers.flagd.resolver.process.model.FeatureFlag; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.Map; +import lombok.Getter; + +/** + * To be returned by the storage when a flag is queried. Contains the flag (iff a flag associated with the given key + * exists, null otherwise) and flag set metadata + */ +@Getter +@SuppressFBWarnings( + value = {"EI_EXPOSE_REP"}, + justification = "The storage provides access to both feature flags and flag set metadata") +public class StorageQueryResult { + private final FeatureFlag featureFlag; + private final Map flagSetMetadata; + + public StorageQueryResult(FeatureFlag featureFlag, Map flagSetMetadata) { + this.featureFlag = featureFlag; + this.flagSetMetadata = flagSetMetadata; + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolverTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolverTest.java index e49046d13..90295ef10 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolverTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolverTest.java @@ -10,6 +10,8 @@ import static dev.openfeature.contrib.providers.flagd.resolver.process.MockFlags.INT_FLAG; import static dev.openfeature.contrib.providers.flagd.resolver.process.MockFlags.OBJECT_FLAG; import static dev.openfeature.contrib.providers.flagd.resolver.process.MockFlags.VARIANT_MISMATCH_FLAG; +import static dev.openfeature.contrib.providers.flagd.resolver.process.MockFlags.stringVariants; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -73,7 +75,7 @@ public void connectorSetup() { } @Test - public void eventHandling() throws Throwable { + void eventHandling() throws Throwable { // given // note - queues with adequate capacity final BlockingQueue sender = new LinkedBlockingQueue<>(5); @@ -83,9 +85,9 @@ public void eventHandling() throws Throwable { final MutableStructure syncMetadata = new MutableStructure(); syncMetadata.add(key, val); - InProcessResolver inProcessResolver = getInProcessResolverWth( + InProcessResolver inProcessResolver = getInProcessResolverWith( new MockStorage(new HashMap<>(), sender), - (connectionEvent) -> receiver.offer(new StorageStateChange( + connectionEvent -> receiver.offer(new StorageStateChange( connectionEvent.isConnected() ? StorageState.OK : StorageState.ERROR, connectionEvent.getFlagsChanged(), connectionEvent.getSyncMetadata()))); @@ -123,7 +125,7 @@ public void simpleBooleanResolving() throws Exception { flagMap.put("booleanFlag", BOOLEAN_FLAG); InProcessResolver inProcessResolver = - getInProcessResolverWth(new MockStorage(flagMap), (connectionEvent) -> {}); + getInProcessResolverWith(new MockStorage(flagMap), (connectionEvent) -> {}); // when ProviderEvaluation providerEvaluation = @@ -142,7 +144,7 @@ public void simpleDoubleResolving() throws Exception { flagMap.put("doubleFlag", DOUBLE_FLAG); InProcessResolver inProcessResolver = - getInProcessResolverWth(new MockStorage(flagMap), (connectionEvent) -> {}); + getInProcessResolverWith(new MockStorage(flagMap), (connectionEvent) -> {}); // when ProviderEvaluation providerEvaluation = @@ -161,7 +163,7 @@ public void fetchIntegerAsDouble() throws Exception { flagMap.put("doubleFlag", DOUBLE_FLAG); InProcessResolver inProcessResolver = - getInProcessResolverWth(new MockStorage(flagMap), (connectionEvent) -> {}); + getInProcessResolverWith(new MockStorage(flagMap), (connectionEvent) -> {}); // when ProviderEvaluation providerEvaluation = @@ -180,7 +182,7 @@ public void fetchDoubleAsInt() throws Exception { flagMap.put("integerFlag", INT_FLAG); InProcessResolver inProcessResolver = - getInProcessResolverWth(new MockStorage(flagMap), (connectionEvent) -> {}); + getInProcessResolverWith(new MockStorage(flagMap), (connectionEvent) -> {}); // when ProviderEvaluation providerEvaluation = @@ -199,7 +201,7 @@ public void simpleIntResolving() throws Exception { flagMap.put("integerFlag", INT_FLAG); InProcessResolver inProcessResolver = - getInProcessResolverWth(new MockStorage(flagMap), (connectionEvent) -> {}); + getInProcessResolverWith(new MockStorage(flagMap), (connectionEvent) -> {}); // when ProviderEvaluation providerEvaluation = @@ -218,7 +220,7 @@ public void simpleObjectResolving() throws Exception { flagMap.put("objectFlag", OBJECT_FLAG); InProcessResolver inProcessResolver = - getInProcessResolverWth(new MockStorage(flagMap), (connectionEvent) -> {}); + getInProcessResolverWith(new MockStorage(flagMap), (connectionEvent) -> {}); Map typeDefault = new HashMap<>(); typeDefault.put("key", "0164"); @@ -244,7 +246,7 @@ public void missingFlag() throws Exception { final Map flagMap = new HashMap<>(); InProcessResolver inProcessResolver = - getInProcessResolverWth(new MockStorage(flagMap), (connectionEvent) -> {}); + getInProcessResolverWith(new MockStorage(flagMap), (connectionEvent) -> {}); // when/then ProviderEvaluation missingFlag = @@ -259,7 +261,7 @@ public void disabledFlag() throws Exception { flagMap.put("disabledFlag", DISABLED_FLAG); InProcessResolver inProcessResolver = - getInProcessResolverWth(new MockStorage(flagMap), (connectionEvent) -> {}); + getInProcessResolverWith(new MockStorage(flagMap), (connectionEvent) -> {}); // when/then ProviderEvaluation disabledFlag = @@ -274,7 +276,7 @@ public void variantMismatchFlag() throws Exception { flagMap.put("mismatchFlag", VARIANT_MISMATCH_FLAG); InProcessResolver inProcessResolver = - getInProcessResolverWth(new MockStorage(flagMap), (connectionEvent) -> {}); + getInProcessResolverWith(new MockStorage(flagMap), (connectionEvent) -> {}); // when/then assertThrows(TypeMismatchError.class, () -> { @@ -289,7 +291,7 @@ public void typeMismatchEvaluation() throws Exception { flagMap.put("stringFlag", BOOLEAN_FLAG); InProcessResolver inProcessResolver = - getInProcessResolverWth(new MockStorage(flagMap), (connectionEvent) -> {}); + getInProcessResolverWith(new MockStorage(flagMap), (connectionEvent) -> {}); // when/then assertThrows(TypeMismatchError.class, () -> { @@ -304,7 +306,7 @@ public void booleanShorthandEvaluation() throws Exception { flagMap.put("shorthand", FLAG_WIH_SHORTHAND_TARGETING); InProcessResolver inProcessResolver = - getInProcessResolverWth(new MockStorage(flagMap), (connectionEvent) -> {}); + getInProcessResolverWith(new MockStorage(flagMap), (connectionEvent) -> {}); ProviderEvaluation providerEvaluation = inProcessResolver.booleanEvaluation("shorthand", false, new ImmutableContext()); @@ -322,7 +324,7 @@ public void targetingMatchedEvaluationFlag() throws Exception { flagMap.put("stringFlag", FLAG_WIH_IF_IN_TARGET); InProcessResolver inProcessResolver = - getInProcessResolverWth(new MockStorage(flagMap), (connectionEvent) -> {}); + getInProcessResolverWith(new MockStorage(flagMap), (connectionEvent) -> {}); // when ProviderEvaluation providerEvaluation = inProcessResolver.stringEvaluation( @@ -341,7 +343,7 @@ public void targetingUnmatchedEvaluationFlag() throws Exception { flagMap.put("stringFlag", FLAG_WIH_IF_IN_TARGET); InProcessResolver inProcessResolver = - getInProcessResolverWth(new MockStorage(flagMap), (connectionEvent) -> {}); + getInProcessResolverWith(new MockStorage(flagMap), (connectionEvent) -> {}); // when ProviderEvaluation providerEvaluation = inProcessResolver.stringEvaluation( @@ -360,7 +362,7 @@ public void explicitTargetingKeyHandling() throws NoSuchFieldException, IllegalA flagMap.put("stringFlag", FLAG_WITH_TARGETING_KEY); InProcessResolver inProcessResolver = - getInProcessResolverWth(new MockStorage(flagMap), (connectionEvent) -> {}); + getInProcessResolverWith(new MockStorage(flagMap), (connectionEvent) -> {}); // when ProviderEvaluation providerEvaluation = @@ -379,7 +381,7 @@ public void targetingErrorEvaluationFlag() throws Exception { flagMap.put("targetingErrorFlag", FLAG_WIH_INVALID_TARGET); InProcessResolver inProcessResolver = - getInProcessResolverWth(new MockStorage(flagMap), (connectionEvent) -> {}); + getInProcessResolverWith(new MockStorage(flagMap), (connectionEvent) -> {}); // when/then assertThrows(ParseError.class, () -> { @@ -395,7 +397,7 @@ public void validateMetadataInEvaluationResult() throws Exception { flagMap.put("booleanFlag", BOOLEAN_FLAG); InProcessResolver inProcessResolver = - getInProcessResolverWth(FlagdOptions.builder().selector(scope).build(), new MockStorage(flagMap)); + getInProcessResolverWith(FlagdOptions.builder().selector(scope).build(), new MockStorage(flagMap)); // when ProviderEvaluation providerEvaluation = @@ -407,14 +409,119 @@ public void validateMetadataInEvaluationResult() throws Exception { assertEquals(scope, metadata.getString("scope")); } - private InProcessResolver getInProcessResolverWth(final FlagdOptions options, final MockStorage storage) + @Test + void selectorIsAddedToFlagMetadata() throws Exception { + // given + final Map flagMap = new HashMap<>(); + flagMap.put("flag", INT_FLAG); + + InProcessResolver inProcessResolver = + getInProcessResolverWith(new MockStorage(flagMap), connectionEvent -> {}, "selector"); + + // when + ProviderEvaluation providerEvaluation = + inProcessResolver.integerEvaluation("flag", 0, new ImmutableContext()); + + // then + assertThat(providerEvaluation.getFlagMetadata()).isNotNull(); + assertThat(providerEvaluation.getFlagMetadata().getString("scope")).isEqualTo("selector"); + } + + @Test + void selectorIsOverwrittenByFlagMetadata() throws Exception { + // given + final Map flagMap = new HashMap<>(); + final Map flagMetadata = new HashMap<>(); + flagMetadata.put("scope", "new selector"); + flagMap.put("flag", new FeatureFlag("stage", "loop", stringVariants, "", flagMetadata)); + + InProcessResolver inProcessResolver = + getInProcessResolverWith(new MockStorage(flagMap), connectionEvent -> {}, "selector"); + + // when + ProviderEvaluation providerEvaluation = + inProcessResolver.stringEvaluation("flag", "def", new ImmutableContext()); + + // then + assertThat(providerEvaluation.getFlagMetadata()).isNotNull(); + assertThat(providerEvaluation.getFlagMetadata().getString("scope")).isEqualTo("new selector"); + } + + @Test + void flagSetMetadataIsAddedToEvaluation() throws Exception { + // given + final Map flagMap = new HashMap<>(); + final Map flagMetadata = new HashMap<>(); + flagMetadata.put("scope", "new selector"); + flagMap.put("flag", new FeatureFlag("stage", "loop", stringVariants, "", flagMetadata)); + + final Map flagSetMetadata = new HashMap<>(); + flagSetMetadata.put("flagSetMetadata", "metadata"); + InProcessResolver inProcessResolver = + getInProcessResolverWith(new MockStorage(flagMap, flagSetMetadata), connectionEvent -> {}, "selector"); + + // when + ProviderEvaluation providerEvaluation = + inProcessResolver.stringEvaluation("flag", "def", new ImmutableContext()); + + // then + assertThat(providerEvaluation.getFlagMetadata()).isNotNull(); + assertThat(providerEvaluation.getFlagMetadata().getString("scope")).isEqualTo("new selector"); + assertThat(providerEvaluation.getFlagMetadata().getString("flagSetMetadata")) + .isEqualTo("metadata"); + } + + @Test + void flagSetMetadataIsAddedToFailingEvaluation() throws Exception { + // given + final Map flagMap = new HashMap<>(); + + final Map flagSetMetadata = new HashMap<>(); + flagSetMetadata.put("flagSetMetadata", "metadata"); + InProcessResolver inProcessResolver = + getInProcessResolverWith(new MockStorage(flagMap, flagSetMetadata), connectionEvent -> {}, "selector"); + + // when + ProviderEvaluation providerEvaluation = + inProcessResolver.stringEvaluation("does not exist", "def", new ImmutableContext()); + + // then + assertThat(providerEvaluation.getReason()).isNull(); + assertThat(providerEvaluation.getFlagMetadata()).isNotNull(); + assertThat(providerEvaluation.getFlagMetadata().getString("flagSetMetadata")) + .isEqualTo("metadata"); + } + + @Test + void flagSetMetadataIsOverwrittenByFlagMetadataToEvaluation() throws Exception { + // given + final Map flagMap = new HashMap<>(); + final Map flagMetadata = new HashMap<>(); + flagMetadata.put("key", "expected"); + flagMap.put("flag", new FeatureFlag("stage", "loop", stringVariants, "", flagMetadata)); + + final Map flagSetMetadata = new HashMap<>(); + flagSetMetadata.put("key", "unexpected"); + InProcessResolver inProcessResolver = + getInProcessResolverWith(new MockStorage(flagMap, flagSetMetadata), connectionEvent -> {}, "selector"); + + // when + ProviderEvaluation providerEvaluation = + inProcessResolver.stringEvaluation("flag", "def", new ImmutableContext()); + + // then + assertThat(providerEvaluation.getFlagMetadata()).isNotNull(); + assertThat(providerEvaluation.getFlagMetadata().getString("key")).isEqualTo("expected"); + } + + private InProcessResolver getInProcessResolverWith(final FlagdOptions options, final MockStorage storage) throws NoSuchFieldException, IllegalAccessException { - final InProcessResolver resolver = new InProcessResolver(options, () -> true, (connectionEvent) -> {}); + final InProcessResolver resolver = new InProcessResolver(options, () -> true, connectionEvent -> {}); return injectFlagStore(resolver, storage); } - private InProcessResolver getInProcessResolverWth( + private InProcessResolver getInProcessResolverWith( final MockStorage storage, final Consumer onConnectionEvent) throws NoSuchFieldException, IllegalAccessException { @@ -423,6 +530,15 @@ private InProcessResolver getInProcessResolverWth( return injectFlagStore(resolver, storage); } + private InProcessResolver getInProcessResolverWith( + final MockStorage storage, final Consumer onConnectionEvent, String selector) + throws NoSuchFieldException, IllegalAccessException { + + final InProcessResolver resolver = new InProcessResolver( + FlagdOptions.builder().selector(selector).deadline(1000).build(), () -> true, onConnectionEvent); + return injectFlagStore(resolver, storage); + } + // helper to inject flagStore override private InProcessResolver injectFlagStore(final InProcessResolver resolver, final MockStorage storage) throws NoSuchFieldException, IllegalAccessException { diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/MockStorage.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/MockStorage.java index a48a05d12..5e5d4b199 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/MockStorage.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/MockStorage.java @@ -2,7 +2,9 @@ import dev.openfeature.contrib.providers.flagd.resolver.process.model.FeatureFlag; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.Storage; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageQueryResult; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageStateChange; +import java.util.Collections; import java.util.Map; import java.util.concurrent.BlockingQueue; import javax.annotation.Nullable; @@ -10,16 +12,25 @@ public class MockStorage implements Storage { private final Map mockFlags; + private final Map flagSetMetadata; private final BlockingQueue mockQueue; + public MockStorage(Map mockFlags, Map flagSetMetadata) { + this.mockFlags = mockFlags; + this.mockQueue = null; + this.flagSetMetadata = flagSetMetadata; + } + public MockStorage(Map mockFlags, BlockingQueue mockQueue) { this.mockFlags = mockFlags; this.mockQueue = mockQueue; + this.flagSetMetadata = Collections.emptyMap(); } public MockStorage(Map flagMap) { this.mockFlags = flagMap; this.mockQueue = null; + this.flagSetMetadata = Collections.emptyMap(); } public void init() { @@ -30,8 +41,9 @@ public void shutdown() { // no-op } - public FeatureFlag getFlag(String key) { - return mockFlags.get(key); + @Override + public StorageQueryResult getFlag(String key) { + return new StorageQueryResult(mockFlags.get(key), flagSetMetadata); } @Nullable public BlockingQueue getStateQueue() { diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/TestUtils.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/TestUtils.java index c0b6795c8..90e4ba8d8 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/TestUtils.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/TestUtils.java @@ -14,6 +14,9 @@ public class TestUtils { public static final String VALID_SIMPLE_EXTRA_FIELD = "flagConfigurations/valid-simple-with-extra-fields.json"; public static final String VALID_LONG = "flagConfigurations/valid-long.json"; public static final String INVALID_FLAG = "flagConfigurations/invalid-flag.json"; + public static final String INVALID_FLAG_METADATA = "flagConfigurations/invalid-metadata.json"; + public static final String INVALID_FLAG_SET_METADATA = "flagConfigurations/invalid-flag-set-metadata.json"; + public static final String VALID_FLAG_SET_METADATA = "flagConfigurations/valid-flag-set-metadata.json"; public static final String INVALID_CFG = "flagConfigurations/invalid-configuration.json"; public static final String UPDATABLE_FILE = "flagConfigurations/updatableFlags.json"; diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParserTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParserTest.java index 30453a80e..79ee279fd 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParserTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParserTest.java @@ -2,12 +2,17 @@ import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.INVALID_CFG; import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.INVALID_FLAG; +import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.INVALID_FLAG_METADATA; +import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.INVALID_FLAG_SET_METADATA; +import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.VALID_FLAG_SET_METADATA; import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.VALID_LONG; import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.VALID_SIMPLE; import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.VALID_SIMPLE_EXTRA_FIELD; import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.getFlagsFromResource; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import java.io.IOException; @@ -16,8 +21,9 @@ class FlagParserTest { @Test - public void validJsonConfigurationParsing() throws IOException { - Map flagMap = FlagParser.parseString(getFlagsFromResource(VALID_SIMPLE), true); + void validJsonConfigurationParsing() throws IOException { + Map flagMap = + FlagParser.parseString(getFlagsFromResource(VALID_SIMPLE), true).getFlags(); FeatureFlag boolFlag = flagMap.get("myBoolFlag"); assertNotNull(boolFlag); @@ -28,11 +34,29 @@ public void validJsonConfigurationParsing() throws IOException { assertEquals(true, variants.get("on")); assertEquals(false, variants.get("off")); + + Map metadata = boolFlag.getMetadata(); + + assertInstanceOf(String.class, metadata.get("string")); + assertEquals("string", metadata.get("string")); + + assertInstanceOf(Boolean.class, metadata.get("boolean")); + assertEquals(true, metadata.get("boolean")); + + assertInstanceOf(Double.class, metadata.get("float")); + assertEquals(1.234, metadata.get("float")); + + assertNotNull(boolFlag.getMetadata()); + assertEquals(3, boolFlag.getMetadata().size()); + assertEquals("string", boolFlag.getMetadata().get("string")); + assertEquals(true, boolFlag.getMetadata().get("boolean")); + assertEquals(1.234, boolFlag.getMetadata().get("float")); } @Test - public void validJsonConfigurationWithExtraFieldsParsing() throws IOException { - Map flagMap = FlagParser.parseString(getFlagsFromResource(VALID_SIMPLE_EXTRA_FIELD), true); + void validJsonConfigurationWithExtraFieldsParsing() throws IOException { + Map flagMap = FlagParser.parseString(getFlagsFromResource(VALID_SIMPLE_EXTRA_FIELD), true) + .getFlags(); FeatureFlag boolFlag = flagMap.get("myBoolFlag"); assertNotNull(boolFlag); @@ -46,8 +70,9 @@ public void validJsonConfigurationWithExtraFieldsParsing() throws IOException { } @Test - public void validJsonConfigurationWithTargetingRulesParsing() throws IOException { - Map flagMap = FlagParser.parseString(getFlagsFromResource(VALID_LONG), true); + void validJsonConfigurationWithTargetingRulesParsing() throws IOException { + Map flagMap = + FlagParser.parseString(getFlagsFromResource(VALID_LONG), true).getFlags(); FeatureFlag stringFlag = flagMap.get("fibAlgo"); assertNotNull(stringFlag); @@ -66,16 +91,68 @@ public void validJsonConfigurationWithTargetingRulesParsing() throws IOException } @Test - public void invalidFlagThrowsError() { - assertThrows(IllegalArgumentException.class, () -> { - FlagParser.parseString(getFlagsFromResource(INVALID_FLAG), true); - }); + void validJsonConfigurationWithFlagSetMetadataParsing() throws IOException { + ParsingResult parsingResult = FlagParser.parseString(getFlagsFromResource(VALID_FLAG_SET_METADATA), true); + Map flagMap = parsingResult.getFlags(); + FeatureFlag flag = flagMap.get("without-metadata"); + + assertNotNull(flag); + + Map metadata = flag.getMetadata(); + Map flagSetMetadata = parsingResult.getFlagSetMetadata(); + + assertNotNull(metadata); + assertNull(metadata.get("string")); + assertNull(metadata.get("boolean")); + assertNull(metadata.get("float")); + assertNotNull(flagSetMetadata); + assertEquals("some string", flagSetMetadata.get("string")); + assertEquals(true, flagSetMetadata.get("boolean")); + assertEquals(1.234, flagSetMetadata.get("float")); + } + + @Test + void validJsonConfigurationWithFlagMetadataParsing() throws IOException { + ParsingResult parsingResult = FlagParser.parseString(getFlagsFromResource(VALID_FLAG_SET_METADATA), true); + Map flagMap = parsingResult.getFlags(); + FeatureFlag flag = flagMap.get("with-metadata"); + + assertNotNull(flag); + + Map metadata = flag.getMetadata(); + Map flagSetMetadata = parsingResult.getFlagSetMetadata(); + + assertNotNull(flagSetMetadata); + assertEquals("some string", flagSetMetadata.get("string")); + assertEquals(true, flagSetMetadata.get("boolean")); + assertEquals(1.234, flagSetMetadata.get("float")); + assertNotNull(metadata); + assertEquals("other string", metadata.get("string")); + assertEquals(true, metadata.get("boolean")); + assertEquals(2.71828, metadata.get("float")); + } + + @Test + void invalidFlagThrowsError() throws IOException { + String flagString = getFlagsFromResource(INVALID_FLAG); + assertThrows(IllegalArgumentException.class, () -> FlagParser.parseString(flagString, true)); + } + + @Test + void invalidFlagMetadataThrowsError() throws IOException { + String flagString = getFlagsFromResource(INVALID_FLAG_METADATA); + assertThrows(IllegalArgumentException.class, () -> FlagParser.parseString(flagString, true)); + } + + @Test + void invalidFlagSetMetadataThrowsError() throws IOException { + String flagString = getFlagsFromResource(INVALID_FLAG_SET_METADATA); + assertThrows(IllegalArgumentException.class, () -> FlagParser.parseString(flagString, true)); } @Test - public void invalidConfigurationsThrowsError() { - assertThrows(IllegalArgumentException.class, () -> { - FlagParser.parseString(getFlagsFromResource(INVALID_CFG), true); - }); + void invalidConfigurationsThrowsError() throws IOException { + String flagString = getFlagsFromResource(INVALID_CFG); + assertThrows(IllegalArgumentException.class, () -> FlagParser.parseString(flagString, true)); } } diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/FlagStoreTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/FlagStoreTest.java index d0ac07a75..c9e4d1b54 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/FlagStoreTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/FlagStoreTest.java @@ -24,7 +24,7 @@ class FlagStoreTest { @Test - public void connectorHandling() throws Exception { + void connectorHandling() throws Exception { final int maxDelay = 1000; final BlockingQueue payload = new LinkedBlockingQueue<>(); @@ -100,7 +100,7 @@ public void changedFlags() throws Exception { }); // flags changed for first time assertEquals( - FlagParser.parseString(getFlagsFromResource(VALID_SIMPLE), true).keySet().stream() + FlagParser.parseString(getFlagsFromResource(VALID_SIMPLE), true).getFlags().keySet().stream() .collect(Collectors.toList()), storageStateDTOS.take().getChangedFlagsKeys()); @@ -108,7 +108,8 @@ public void changedFlags() throws Exception { payload.offer(new QueuePayload( QueuePayloadType.DATA, getFlagsFromResource(VALID_LONG), GetMetadataResponse.getDefaultInstance())); }); - Map expectedChangedFlags = FlagParser.parseString(getFlagsFromResource(VALID_LONG), true); + Map expectedChangedFlags = + FlagParser.parseString(getFlagsFromResource(VALID_LONG), true).getFlags(); expectedChangedFlags.remove("myBoolFlag"); // flags changed from initial VALID_SIMPLE flag, as a set because we don't care about order Assert.assertEquals( diff --git a/providers/flagd/src/test/resources/flagConfigurations/invalid-flag-set-metadata.json b/providers/flagd/src/test/resources/flagConfigurations/invalid-flag-set-metadata.json new file mode 100644 index 000000000..9caf8200d --- /dev/null +++ b/providers/flagd/src/test/resources/flagConfigurations/invalid-flag-set-metadata.json @@ -0,0 +1,21 @@ +{ + "$schema": "../../../main/resources/flagd/schemas/flags.json", + "metadata": { + "string": "string", + "boolean": true, + "float": 1.234, + "invalid": { + "a": "a" + } + }, + "flags": { + "myBoolFlag": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on" + } + } +} diff --git a/providers/flagd/src/test/resources/flagConfigurations/invalid-metadata.json b/providers/flagd/src/test/resources/flagConfigurations/invalid-metadata.json new file mode 100644 index 000000000..299eac071 --- /dev/null +++ b/providers/flagd/src/test/resources/flagConfigurations/invalid-metadata.json @@ -0,0 +1,21 @@ +{ + "$schema": "../../../main/resources/flagd/schemas/flags.json", + "flags": { + "myBoolFlag": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on", + "metadata": { + "string": "string", + "boolean": true, + "float": 1.234, + "invalid": { + "a": "a" + } + } + } + } +} diff --git a/providers/flagd/src/test/resources/flagConfigurations/valid-flag-set-metadata.json b/providers/flagd/src/test/resources/flagConfigurations/valid-flag-set-metadata.json new file mode 100644 index 000000000..f5323d9c4 --- /dev/null +++ b/providers/flagd/src/test/resources/flagConfigurations/valid-flag-set-metadata.json @@ -0,0 +1,31 @@ +{ + "$schema": "../../../main/resources/flagd/schemas/flags.json", + "metadata": { + "string": "some string", + "boolean": true, + "float": 1.234 + }, + "flags": { + "without-metadata": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on" + }, + "with-metadata": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on", + "metadata": { + "string": "other string", + "boolean": true, + "float": 2.71828 + } + } + } +} diff --git a/providers/flagd/src/test/resources/flagConfigurations/valid-long.json b/providers/flagd/src/test/resources/flagConfigurations/valid-long.json index 142eec2cf..3aecd81c9 100644 --- a/providers/flagd/src/test/resources/flagConfigurations/valid-long.json +++ b/providers/flagd/src/test/resources/flagConfigurations/valid-long.json @@ -7,7 +7,12 @@ "on": true, "off": false }, - "defaultVariant": "on" + "defaultVariant": "on", + "metadata": { + "string": "string", + "boolean": true, + "float": 1.234 + } }, "myStringFlag": { "state": "ENABLED", diff --git a/providers/flagd/src/test/resources/flagConfigurations/valid-simple.json b/providers/flagd/src/test/resources/flagConfigurations/valid-simple.json index 811abbc37..37d997f3b 100644 --- a/providers/flagd/src/test/resources/flagConfigurations/valid-simple.json +++ b/providers/flagd/src/test/resources/flagConfigurations/valid-simple.json @@ -7,7 +7,12 @@ "on": true, "off": false }, - "defaultVariant": "on" + "defaultVariant": "on", + "metadata": { + "string": "string", + "boolean": true, + "float": 1.234 + } } } }