From 61bb7263a8b4ad2b15560b1306b04cd28986a95f Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Mon, 30 Sep 2024 10:21:40 -0400 Subject: [PATCH] feat: expose sync-metadata, call RPC with (re)connect (#967) Signed-off-by: Todd Baert Co-authored-by: Simon Schrottner --- providers/flagd/README.md | 7 + providers/flagd/pom.xml | 2 +- .../providers/flagd/FlagdProvider.java | 33 +- .../providers/flagd/resolver/Resolver.java | 2 +- .../resolver/common/ConnectionEvent.java | 68 + .../flagd/resolver/common/Convert.java | 184 ++ .../flagd/resolver/grpc/Constants.java | 10 + .../resolver/grpc/EventStreamObserver.java | 30 +- .../flagd/resolver/grpc/GrpcConnector.java | 29 +- .../flagd/resolver/grpc/GrpcResolver.java | 175 +- .../resolver/process/InProcessResolver.java | 36 +- .../resolver/process/storage/FlagStore.java | 42 +- .../process/storage/StorageStateChange.java | 35 +- .../process/storage/connector/Connector.java | 4 +- .../storage/connector/QueuePayload.java | 20 + ...PayloadType.java => QueuePayloadType.java} | 2 +- .../storage/connector/StreamPayload.java | 14 - .../storage/connector/file/FileConnector.java | 22 +- .../connector/grpc/GrpcStreamConnector.java | 122 +- .../providers/flagd/FlagdProviderTest.java | 1723 +++++++++-------- .../e2e/process/FlagdInProcessSetup.java | 1 + .../process/FlagdInProcessSetup.java | 3 +- .../e2e/reconnect/rpc/FlagdRpcSetup.java | 1 - .../grpc/EventStreamObserverTest.java | 2 +- .../resolver/grpc/GrpcConnectorTest.java | 92 +- .../process/InProcessResolverTest.java | 57 +- .../process/storage/FlagStoreTest.java | 44 +- .../process/storage/MockConnector.java | 17 +- .../connector/file/FileConnectorTest.java | 28 +- .../grpc/GrpcStreamConnectorTest.java | 81 +- 30 files changed, 1656 insertions(+), 1230 deletions(-) create mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/ConnectionEvent.java create mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/Convert.java create mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/Constants.java create mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/QueuePayload.java rename providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/{StreamPayloadType.java => QueuePayloadType.java} (83%) delete mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/StreamPayload.java diff --git a/providers/flagd/README.md b/providers/flagd/README.md index 5144a4749..4c6786efb 100644 --- a/providers/flagd/README.md +++ b/providers/flagd/README.md @@ -47,6 +47,13 @@ FlagdProvider flagdProvider = new FlagdProvider( In the above example, in-process handlers attempt to connect to a sync service on address `localhost:8013` to obtain [flag definitions](https://github.com/open-feature/schemas/blob/main/json/flags.json). +#### Sync-metadata + +To support the injection of contextual data configured in flagd for in-process evaluation, the provider exposes a `getSyncMetadata` accessor which provides the most recent value returned by the [GetMetadata RPC](https://buf.build/open-feature/flagd/docs/main:flagd.sync.v1#flagd.sync.v1.FlagSyncService.GetMetadata). +The value is updated with every (re)connection to the sync implementation. +This can be used to enrich evaluations with such data. +If the `in-process` mode is not used, and before the provider is ready, the `getSyncMetadata` returns an empty map. + #### Offline mode In-process resolvers can also work in an offline mode. diff --git a/providers/flagd/pom.xml b/providers/flagd/pom.xml index 62fae18a2..dd3648239 100644 --- a/providers/flagd/pom.xml +++ b/providers/flagd/pom.xml @@ -19,7 +19,7 @@ flagd - FlagD provider for Java + flagd provider for Java https://openfeature.dev diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdProvider.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdProvider.java index a396d00f8..15bfb5cbb 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdProvider.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdProvider.java @@ -1,8 +1,10 @@ package dev.openfeature.contrib.providers.flagd; -import java.util.List; +import java.util.Collections; +import java.util.Map; import dev.openfeature.contrib.providers.flagd.resolver.Resolver; +import dev.openfeature.contrib.providers.flagd.resolver.common.ConnectionEvent; import dev.openfeature.contrib.providers.flagd.resolver.grpc.GrpcResolver; import dev.openfeature.contrib.providers.flagd.resolver.grpc.cache.Cache; import dev.openfeature.contrib.providers.flagd.resolver.process.InProcessResolver; @@ -20,10 +22,11 @@ @Slf4j @SuppressWarnings({ "PMD.TooManyStaticImports", "checkstyle:NoFinalizer" }) public class FlagdProvider extends EventProvider { - private static final String FLAGD_PROVIDER = "flagD Provider"; + private static final String FLAGD_PROVIDER = "flagd"; private final Resolver flagResolver; private volatile boolean initialized = false; private volatile boolean connected = false; + private volatile Map syncMetadata = Collections.emptyMap(); private EvaluationContext evaluationContext; @@ -47,13 +50,13 @@ public FlagdProvider(final FlagdOptions options) { switch (options.getResolverType().asString()) { case Config.RESOLVER_IN_PROCESS: this.flagResolver = new InProcessResolver(options, this::isConnected, - this::onResolverConnectionChanged); + this::onConnectionEvent); break; case Config.RESOLVER_RPC: this.flagResolver = new GrpcResolver(options, new Cache(options.getCacheType(), options.getMaxCacheSize()), this::isConnected, - this::onResolverConnectionChanged); + this::onConnectionEvent); break; default: throw new IllegalStateException( @@ -117,6 +120,19 @@ public ProviderEvaluation getObjectEvaluation(String key, Value defaultVa return this.flagResolver.objectEvaluation(key, defaultValue, mergeContext(ctx)); } + /** + * An unmodifiable view of an object map representing the latest result of the + * SyncMetadata. + * Set on initial connection and updated with every reconnection. + * see: + * https://buf.build/open-feature/flagd/docs/main:flagd.sync.v1#flagd.sync.v1.FlagSyncService.GetMetadata + * + * @return Object map representing sync metadata + */ + protected Map getSyncMetadata() { + return Collections.unmodifiableMap(syncMetadata); + } + private EvaluationContext mergeContext(final EvaluationContext clientCallCtx) { if (this.evaluationContext != null) { return evaluationContext.merge(clientCallCtx); @@ -129,15 +145,16 @@ private boolean isConnected() { return this.connected; } - private void onResolverConnectionChanged(boolean newConnectedState, List changedFlagKeys) { + private void onConnectionEvent(ConnectionEvent connectionEvent) { boolean previous = connected; - boolean current = newConnectedState; - this.connected = newConnectedState; + boolean current = connected = connectionEvent.isConnected(); + syncMetadata = connectionEvent.getSyncMetadata(); // configuration changed if (initialized && previous && current) { log.debug("Configuration changed"); - ProviderEventDetails details = ProviderEventDetails.builder().flagsChanged(changedFlagKeys) + ProviderEventDetails details = ProviderEventDetails.builder() + .flagsChanged(connectionEvent.getFlagsChanged()) .message("configuration changed").build(); this.emitProviderConfigurationChanged(details); return; diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/Resolver.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/Resolver.java index 8976a6789..45d82b66b 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/Resolver.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/Resolver.java @@ -5,7 +5,7 @@ import dev.openfeature.sdk.Value; /** - * A generic flag resolving contract for flagd. + * Abstraction that resolves flag values in from some source. */ public interface Resolver { void init() throws Exception; diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/ConnectionEvent.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/ConnectionEvent.java new file mode 100644 index 000000000..cc8a7a0b0 --- /dev/null +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/ConnectionEvent.java @@ -0,0 +1,68 @@ +package dev.openfeature.contrib.providers.flagd.resolver.common; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Event payload for a + * {@link dev.openfeature.contrib.providers.flagd.resolver.Resolver} connection + * state change event. + */ +@AllArgsConstructor +public class ConnectionEvent { + @Getter + private final boolean connected; + private final List flagsChanged; + private final Map syncMetadata; + + /** + * Construct a new ConnectionEvent. + * + * @param connected status of the connection + */ + public ConnectionEvent(boolean connected) { + this(connected, Collections.emptyList(), Collections.emptyMap()); + } + + /** + * Construct a new ConnectionEvent. + * + * @param connected status of the connection + * @param flagsChanged list of flags changed + */ + public ConnectionEvent(boolean connected, List flagsChanged) { + this(connected, flagsChanged, Collections.emptyMap()); + } + + /** + * Construct a new ConnectionEvent. + * + * @param connected status of the connection + * @param syncMetadata sync.getMetadata + */ + public ConnectionEvent(boolean connected, Map syncMetadata) { + this(connected, Collections.emptyList(), syncMetadata); + } + + /** + * Get changed flags. + * + * @return an unmodifiable view of the changed flags + */ + public List getFlagsChanged() { + return Collections.unmodifiableList(flagsChanged); + } + + /** + * Get changed sync metadata. + * + * @return an unmodifiable view of the sync metadata + */ + public Map getSyncMetadata() { + return Collections.unmodifiableMap(syncMetadata); + } +} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/Convert.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/Convert.java new file mode 100644 index 000000000..939b614d3 --- /dev/null +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/Convert.java @@ -0,0 +1,184 @@ +package dev.openfeature.contrib.providers.flagd.resolver.common; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.google.protobuf.Descriptors; +import com.google.protobuf.ListValue; +import com.google.protobuf.Message; +import com.google.protobuf.NullValue; +import com.google.protobuf.Struct; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.MutableStructure; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.Value; + +/** + * gRPC type conversion utils. + */ +public class Convert { + /** + * Recursively convert protobuf structure to openfeature value. + */ + public static Value convertObjectResponse(Struct protobuf) { + return convertProtobufMap(protobuf.getFieldsMap()); + } + + /** + * Recursively convert the Evaluation context to a protobuf structure. + */ + public static Struct convertContext(EvaluationContext ctx) { + Map ctxMap = ctx.asMap(); + // asMap() does not provide explicitly set targeting key (ex:- new + // ImmutableContext("TargetingKey") ). + // Hence, we add this explicitly here for targeting rule processing. + ctxMap.put("targetingKey", new Value(ctx.getTargetingKey())); + + return convertMap(ctxMap).getStructValue(); + } + + /** + * Convert any openfeature value to a protobuf value. + */ + public static com.google.protobuf.Value convertAny(Value value) { + if (value.isList()) { + return convertList(value.asList()); + } else if (value.isStructure()) { + return convertMap(value.asStructure().asMap()); + } else { + return convertPrimitive(value); + } + } + + /** + * Convert any protobuf value to {@link Value}. + */ + public static Value convertAny(com.google.protobuf.Value protobuf) { + if (protobuf.hasListValue()) { + return convertList(protobuf.getListValue()); + } else if (protobuf.hasStructValue()) { + return convertProtobufMap(protobuf.getStructValue().getFieldsMap()); + } else { + return convertPrimitive(protobuf); + } + } + + /** + * Convert OpenFeature map to protobuf {@link com.google.protobuf.Value}. + */ + public static com.google.protobuf.Value convertMap(Map map) { + Map values = new HashMap<>(); + + map.keySet().forEach((String key) -> { + Value value = map.get(key); + values.put(key, convertAny(value)); + }); + Struct struct = Struct.newBuilder() + .putAllFields(values).build(); + return com.google.protobuf.Value.newBuilder().setStructValue(struct).build(); + } + + /** + * Convert protobuf map with {@link com.google.protobuf.Value} to OpenFeature + * map. + */ + public static Value convertProtobufMap(Map map) { + return new Value(convertProtobufMapToStructure(map)); + } + + /** + * Convert protobuf map with {@link com.google.protobuf.Value} to OpenFeature + * map. + */ + public static Structure convertProtobufMapToStructure(Map map) { + Map values = new HashMap<>(); + + map.keySet().forEach((String key) -> { + com.google.protobuf.Value value = map.get(key); + values.put(key, convertAny(value)); + }); + return new MutableStructure(values); + } + + /** + * Convert OpenFeature list to protobuf {@link com.google.protobuf.Value}. + */ + public static com.google.protobuf.Value convertList(List values) { + ListValue list = ListValue.newBuilder() + .addAllValues(values.stream() + .map(v -> convertAny(v)).collect(Collectors.toList())) + .build(); + return com.google.protobuf.Value.newBuilder().setListValue(list).build(); + } + + /** + * Convert protobuf list to OpenFeature {@link com.google.protobuf.Value}. + */ + public static Value convertList(ListValue protobuf) { + return new Value(protobuf.getValuesList().stream().map(p -> convertAny(p)).collect(Collectors.toList())); + } + + /** + * Convert OpenFeature {@link Value} to protobuf + * {@link com.google.protobuf.Value}. + */ + public static com.google.protobuf.Value convertPrimitive(Value value) { + com.google.protobuf.Value.Builder builder = com.google.protobuf.Value.newBuilder(); + + if (value.isBoolean()) { + builder.setBoolValue(value.asBoolean()); + } else if (value.isString()) { + builder.setStringValue(value.asString()); + } else if (value.isNumber()) { + builder.setNumberValue(value.asDouble()); + } else { + builder.setNullValue(NullValue.NULL_VALUE); + } + return builder.build(); + } + + /** + * Convert protobuf {@link com.google.protobuf.Value} to OpenFeature + * {@link Value}. + */ + public static Value convertPrimitive(com.google.protobuf.Value protobuf) { + final Value value; + if (protobuf.hasBoolValue()) { + value = new Value(protobuf.getBoolValue()); + } else if (protobuf.hasStringValue()) { + value = new Value(protobuf.getStringValue()); + } else if (protobuf.hasNumberValue()) { + value = new Value(protobuf.getNumberValue()); + } else { + value = new Value(); + } + + return value; + } + + /** + * Get the specified protobuf field from the message. + * + * @param type + * @param message protobuf message + * @param name field name + * @return field value + */ + public static T getField(Message message, String name) { + return (T) message.getField(getFieldDescriptor(message, name)); + } + + /** + * Get the specified protobuf field descriptor from the message. + * + * @param message protobuf message + * @param name field name + * @return field descriptor + */ + public static Descriptors.FieldDescriptor getFieldDescriptor(Message message, String name) { + return message.getDescriptorForType().findFieldByName(name); + } +} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/Constants.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/Constants.java new file mode 100644 index 000000000..d6ba68efd --- /dev/null +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/Constants.java @@ -0,0 +1,10 @@ +package dev.openfeature.contrib.providers.flagd.resolver.grpc; + +/** + * Constants for evaluation proto. + */ +public class Constants { + public static final String CONFIGURATION_CHANGE = "configuration_change"; + public static final String PROVIDER_READY = "provider_ready"; + public static final String FLAGS_KEY = "flags"; +} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/EventStreamObserver.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/EventStreamObserver.java index ed279da8e..927fcebdc 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/EventStreamObserver.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/EventStreamObserver.java @@ -20,34 +20,30 @@ @Slf4j @SuppressFBWarnings(justification = "cache needs to be read and write by multiple objects") class EventStreamObserver implements StreamObserver { - private final BiConsumer> stateConsumer; + private final BiConsumer> onConnectionEvent; private final Object sync; private final Cache cache; - private static final String CONFIGURATION_CHANGE = "configuration_change"; - private static final String PROVIDER_READY = "provider_ready"; - static final String FLAGS_KEY = "flags"; - /** * Create a gRPC stream that get notified about flag changes. * - * @param sync synchronization object from caller - * @param cache cache to update - * @param stateConsumer lambda to call for setting the state + * @param sync synchronization object from caller + * @param cache cache to update + * @param onConnectionEvent lambda to call to handle the response */ - EventStreamObserver(Object sync, Cache cache, BiConsumer> stateConsumer) { + EventStreamObserver(Object sync, Cache cache, BiConsumer> onConnectionEvent) { this.sync = sync; this.cache = cache; - this.stateConsumer = stateConsumer; + this.onConnectionEvent = onConnectionEvent; } @Override public void onNext(EventStreamResponse value) { switch (value.getType()) { - case CONFIGURATION_CHANGE: + case Constants.CONFIGURATION_CHANGE: this.handleConfigurationChangeEvent(value); break; - case PROVIDER_READY: + case Constants.PROVIDER_READY: this.handleProviderReadyEvent(); break; default: @@ -61,7 +57,7 @@ public void onError(Throwable t) { if (this.cache.getEnabled()) { this.cache.clear(); } - this.stateConsumer.accept(false, Collections.emptyList()); + this.onConnectionEvent.accept(false, Collections.emptyList()); // handle last call of this stream handleEndOfStream(); @@ -72,7 +68,7 @@ public void onCompleted() { if (this.cache.getEnabled()) { this.cache.clear(); } - this.stateConsumer.accept(false, Collections.emptyList()); + this.onConnectionEvent.accept(false, Collections.emptyList()); // handle last call of this stream handleEndOfStream(); @@ -83,7 +79,7 @@ private void handleConfigurationChangeEvent(EventStreamResponse value) { boolean cachingEnabled = this.cache.getEnabled(); Map data = value.getData().getFieldsMap(); - Value flagsValue = data.get(FLAGS_KEY); + Value flagsValue = data.get(Constants.FLAGS_KEY); if (flagsValue == null) { if (cachingEnabled) { this.cache.clear(); @@ -99,11 +95,11 @@ private void handleConfigurationChangeEvent(EventStreamResponse value) { } } - this.stateConsumer.accept(true, changedFlags); + this.onConnectionEvent.accept(true, changedFlags); } private void handleProviderReadyEvent() { - this.stateConsumer.accept(true, Collections.emptyList()); + this.onConnectionEvent.accept(true, Collections.emptyList()); if (this.cache.getEnabled()) { this.cache.clear(); } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcConnector.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcConnector.java index 563bad739..4b2563afe 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcConnector.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcConnector.java @@ -4,11 +4,12 @@ import java.util.List; import java.util.Random; import java.util.concurrent.TimeUnit; -import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.function.Supplier; import dev.openfeature.contrib.providers.flagd.FlagdOptions; import dev.openfeature.contrib.providers.flagd.resolver.common.ChannelBuilder; +import dev.openfeature.contrib.providers.flagd.resolver.common.ConnectionEvent; import dev.openfeature.contrib.providers.flagd.resolver.common.Util; import dev.openfeature.contrib.providers.flagd.resolver.grpc.cache.Cache; import dev.openfeature.flagd.grpc.evaluation.Evaluation.EventStreamRequest; @@ -37,7 +38,7 @@ public class GrpcConnector { private final long deadline; private final Cache cache; - private final BiConsumer> stateConsumer; + private final Consumer onConnectionEvent; private final Supplier connectedSupplier; private int eventStreamAttempt = 1; @@ -48,23 +49,23 @@ public class GrpcConnector { /** * GrpcConnector creates an abstraction over gRPC communication. - * - * @param options options to build the gRPC channel. - * @param cache cache to use. - * @param stateConsumer lambda to call for setting the state. + * + * @param options flagd options + * @param cache cache to use + * @param connectedSupplier lambda providing current connection status from caller + * @param onConnectionEvent lambda which handles changes in the connection/stream */ public GrpcConnector(final FlagdOptions options, final Cache cache, final Supplier connectedSupplier, - BiConsumer> stateConsumer) { + Consumer onConnectionEvent) { this.channel = ChannelBuilder.nettyChannel(options); this.serviceStub = ServiceGrpc.newStub(channel); this.serviceBlockingStub = ServiceGrpc.newBlockingStub(channel); - this.maxEventStreamRetries = options.getMaxEventStreamRetries(); this.startEventStreamRetryBackoff = options.getRetryBackoffMs(); this.eventStreamRetryBackoff = options.getRetryBackoffMs(); this.deadline = options.getDeadline(); this.cache = cache; - this.stateConsumer = stateConsumer; + this.onConnectionEvent = onConnectionEvent; this.connectedSupplier = connectedSupplier; } @@ -104,7 +105,7 @@ public void shutdown() throws Exception { this.channel.awaitTermination(this.deadline, TimeUnit.MILLISECONDS); log.warn(String.format("Unable to shut down channel by %d deadline", this.deadline)); } - this.stateConsumer.accept(false, Collections.emptyList()); + this.onConnectionEvent.accept(new ConnectionEvent(false)); } } @@ -124,7 +125,7 @@ public ServiceGrpc.ServiceBlockingStub getResolver() { private void observeEventStream() { while (this.eventStreamAttempt <= this.maxEventStreamRetries) { final StreamObserver responseObserver = new EventStreamObserver(sync, this.cache, - this::grpcStateConsumer); + this::onConnectionEvent); this.serviceStub.eventStream(EventStreamRequest.getDefaultInstance(), responseObserver); try { @@ -155,16 +156,16 @@ private void observeEventStream() { } log.error("failed to connect to event stream, exhausted retries"); - this.grpcStateConsumer(false, null); + this.onConnectionEvent(false, Collections.emptyList()); } - private void grpcStateConsumer(final boolean connected, final List changedFlags) { + private void onConnectionEvent(final boolean connected, final List changedFlags) { // reset reconnection states if (connected) { this.eventStreamAttempt = 1; this.eventStreamRetryBackoff = this.startEventStreamRetryBackoff; } // chain to initiator - this.stateConsumer.accept(connected, changedFlags); + this.onConnectionEvent.accept(new ConnectionEvent(connected, changedFlags)); } } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcResolver.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcResolver.java index 7226d7257..9fcede67e 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcResolver.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcResolver.java @@ -1,22 +1,22 @@ package dev.openfeature.contrib.providers.flagd.resolver.grpc; -import java.util.HashMap; -import java.util.List; +import static dev.openfeature.contrib.providers.flagd.resolver.common.Convert.convertContext; +import static dev.openfeature.contrib.providers.flagd.resolver.common.Convert.convertObjectResponse; +import static dev.openfeature.contrib.providers.flagd.resolver.common.Convert.getField; +import static dev.openfeature.contrib.providers.flagd.resolver.common.Convert.getFieldDescriptor; + import java.util.Map; -import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; -import java.util.stream.Collectors; -import com.google.protobuf.Descriptors; -import com.google.protobuf.ListValue; import com.google.protobuf.Message; -import com.google.protobuf.NullValue; import com.google.protobuf.Struct; import dev.openfeature.contrib.providers.flagd.Config; import dev.openfeature.contrib.providers.flagd.FlagdOptions; import dev.openfeature.contrib.providers.flagd.resolver.Resolver; +import dev.openfeature.contrib.providers.flagd.resolver.common.ConnectionEvent; import dev.openfeature.contrib.providers.flagd.resolver.grpc.cache.Cache; import dev.openfeature.contrib.providers.flagd.resolver.grpc.strategy.ResolveFactory; import dev.openfeature.contrib.providers.flagd.resolver.grpc.strategy.ResolveStrategy; @@ -27,7 +27,6 @@ import dev.openfeature.flagd.grpc.evaluation.Evaluation.ResolveStringRequest; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.MutableStructure; import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.Value; import dev.openfeature.sdk.exceptions.FlagNotFoundError; @@ -40,7 +39,8 @@ import io.grpc.StatusRuntimeException; /** - * FlagResolution resolves flags from flagd. + * Resolves flag values using https://buf.build/open-feature/flagd/docs/main:flagd.evaluation.v1. + * Flags are evaluated remotely. */ @SuppressWarnings("PMD.TooManyStaticImports") @SuppressFBWarnings(justification = "cache needs to be read and write by multiple objects") @@ -52,20 +52,20 @@ public final class GrpcResolver implements Resolver { private final Supplier connectedSupplier; /** - * Initialize Grpc resolver. - * - * @param options flagd options. - * @param cache cache to use. - * @param connectedSupplier lambda to call for getting the state. - * @param onResolverConnectionChanged lambda to communicate back the state. + * Resolves flag values using https://buf.build/open-feature/flagd/docs/main:flagd.evaluation.v1. + * Flags are evaluated remotely. + * + * @param options flagd options + * @param cache cache to use + * @param connectedSupplier lambda providing current connection status from caller + * @param onConnectionEvent lambda which handles changes in the connection/stream */ public GrpcResolver(final FlagdOptions options, final Cache cache, final Supplier connectedSupplier, - final BiConsumer> onResolverConnectionChanged) { + final Consumer onConnectionEvent) { this.cache = cache; this.connectedSupplier = connectedSupplier; - this.strategy = ResolveFactory.getStrategy(options); - this.connector = new GrpcConnector(options, cache, connectedSupplier, onResolverConnectionChanged); + this.connector = new GrpcConnector(options, cache, connectedSupplier, onConnectionEvent); } /** @@ -200,145 +200,6 @@ private Boolean cacheAvailable() { return this.cache.getEnabled() && this.connectedSupplier.get(); } - /** - * Recursively convert protobuf structure to openfeature value. - */ - private static Value convertObjectResponse(Struct protobuf) { - return convertProtobufMap(protobuf.getFieldsMap()); - } - - /** - * Recursively convert the Evaluation context to a protobuf structure. - */ - private static Struct convertContext(EvaluationContext ctx) { - Map ctxMap = ctx.asMap(); - // asMap() does not provide explicitly set targeting key (ex:- new - // ImmutableContext("TargetingKey") ). - // Hence, we add this explicitly here for targeting rule processing. - ctxMap.put("targetingKey", new Value(ctx.getTargetingKey())); - - return convertMap(ctxMap).getStructValue(); - } - - /** - * Convert any openfeature value to a protobuf value. - */ - private static com.google.protobuf.Value convertAny(Value value) { - if (value.isList()) { - return convertList(value.asList()); - } else if (value.isStructure()) { - return convertMap(value.asStructure().asMap()); - } else { - return convertPrimitive(value); - } - } - - /** - * Convert any protobuf value to {@link Value}. - */ - private static Value convertAny(com.google.protobuf.Value protobuf) { - if (protobuf.hasListValue()) { - return convertList(protobuf.getListValue()); - } else if (protobuf.hasStructValue()) { - return convertProtobufMap(protobuf.getStructValue().getFieldsMap()); - } else { - return convertPrimitive(protobuf); - } - } - - /** - * Convert OpenFeature map to protobuf {@link com.google.protobuf.Value}. - */ - private static com.google.protobuf.Value convertMap(Map map) { - Map values = new HashMap<>(); - - map.keySet().forEach((String key) -> { - Value value = map.get(key); - values.put(key, convertAny(value)); - }); - Struct struct = Struct.newBuilder() - .putAllFields(values).build(); - return com.google.protobuf.Value.newBuilder().setStructValue(struct).build(); - } - - /** - * Convert protobuf map with {@link com.google.protobuf.Value} to OpenFeature - * map. - */ - private static Value convertProtobufMap(Map map) { - Map values = new HashMap<>(); - - map.keySet().forEach((String key) -> { - com.google.protobuf.Value value = map.get(key); - values.put(key, convertAny(value)); - }); - return new Value(new MutableStructure(values)); - } - - /** - * Convert OpenFeature list to protobuf {@link com.google.protobuf.Value}. - */ - private static com.google.protobuf.Value convertList(List values) { - ListValue list = ListValue.newBuilder() - .addAllValues(values.stream() - .map(v -> convertAny(v)).collect(Collectors.toList())) - .build(); - return com.google.protobuf.Value.newBuilder().setListValue(list).build(); - } - - /** - * Convert protobuf list to OpenFeature {@link com.google.protobuf.Value}. - */ - private static Value convertList(ListValue protobuf) { - return new Value(protobuf.getValuesList().stream().map(p -> convertAny(p)).collect(Collectors.toList())); - } - - /** - * Convert OpenFeature {@link Value} to protobuf - * {@link com.google.protobuf.Value}. - */ - private static com.google.protobuf.Value convertPrimitive(Value value) { - com.google.protobuf.Value.Builder builder = com.google.protobuf.Value.newBuilder(); - - if (value.isBoolean()) { - builder.setBoolValue(value.asBoolean()); - } else if (value.isString()) { - builder.setStringValue(value.asString()); - } else if (value.isNumber()) { - builder.setNumberValue(value.asDouble()); - } else { - builder.setNullValue(NullValue.NULL_VALUE); - } - return builder.build(); - } - - /** - * Convert protobuf {@link com.google.protobuf.Value} to OpenFeature - * {@link Value}. - */ - private static Value convertPrimitive(com.google.protobuf.Value protobuf) { - final Value value; - if (protobuf.hasBoolValue()) { - value = new Value(protobuf.getBoolValue()); - } else if (protobuf.hasStringValue()) { - value = new Value(protobuf.getStringValue()); - } else if (protobuf.hasNumberValue()) { - value = new Value(protobuf.getNumberValue()); - } else { - value = new Value(); - } - - return value; - } - - private static T getField(Message message, String name) { - return (T) message.getField(getFieldDescriptor(message, name)); - } - - private static Descriptors.FieldDescriptor getFieldDescriptor(Message message, String name) { - return message.getDescriptorForType().findFieldByName(name); - } - private static ImmutableMetadata metadataFromResponse(Message response) { final Object metadata = response.getField(getFieldDescriptor(response, Config.METADATA_FIELD)); 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 06e641736..39c77f01b 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 @@ -2,12 +2,12 @@ import static dev.openfeature.contrib.providers.flagd.resolver.process.model.FeatureFlag.EMPTY_TARGETING_STRING; -import java.util.List; -import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.function.Supplier; import dev.openfeature.contrib.providers.flagd.FlagdOptions; import dev.openfeature.contrib.providers.flagd.resolver.Resolver; +import dev.openfeature.contrib.providers.flagd.resolver.common.ConnectionEvent; import dev.openfeature.contrib.providers.flagd.resolver.common.Util; import dev.openfeature.contrib.providers.flagd.resolver.process.model.FeatureFlag; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.FlagStore; @@ -29,30 +29,35 @@ import lombok.extern.slf4j.Slf4j; /** - * flagd in-process resolver. Resolves feature flags in-process. Flags are - * retrieved from {@link Storage}, where the - * {@link Storage} maintain flag configurations obtained from known source. + * Resolves flag values using + * https://buf.build/open-feature/flagd/docs/main:flagd.sync.v1. + * Flags are evaluated locally. */ @Slf4j public class InProcessResolver implements Resolver { private final Storage flagStore; - private final BiConsumer> onResolverConnectionChanged; + private final Consumer onConnectionEvent; private final Operator operator; private final long deadline; private final ImmutableMetadata metadata; private final Supplier connectedSupplier; /** - * Initialize an in-process resolver. - * @param options flagd options - * @param connectedSupplier supplier for connection state - * @param onResolverConnectionChanged handler for connection change + * Resolves flag values using + * https://buf.build/open-feature/flagd/docs/main:flagd.sync.v1. + * Flags are evaluated locally. + * + * @param options flagd options + * @param connectedSupplier lambda providing current connection status from + * caller + * @param onConnectionEvent lambda which handles changes in the + * connection/stream */ public InProcessResolver(FlagdOptions options, final Supplier connectedSupplier, - BiConsumer> onResolverConnectionChanged) { + Consumer onConnectionEvent) { this.flagStore = new FlagStore(getConnector(options)); this.deadline = options.getDeadline(); - this.onResolverConnectionChanged = onResolverConnectionChanged; + this.onConnectionEvent = onConnectionEvent; this.operator = new Operator(); this.connectedSupplier = connectedSupplier; this.metadata = options.getSelector() == null ? null @@ -72,10 +77,11 @@ public void init() throws Exception { final StorageStateChange storageStateChange = flagStore.getStateQueue().take(); switch (storageStateChange.getStorageState()) { case OK: - onResolverConnectionChanged.accept(true, storageStateChange.getChangedFlagsKeys()); + onConnectionEvent.accept(new ConnectionEvent(true, storageStateChange.getChangedFlagsKeys(), + storageStateChange.getSyncMetadata())); break; case ERROR: - onResolverConnectionChanged.accept(false, null); + onConnectionEvent.accept(new ConnectionEvent(false)); break; default: log.info(String.format("Storage emitted unhandled status: %s", @@ -101,7 +107,7 @@ public void init() throws Exception { */ public void shutdown() throws InterruptedException { flagStore.shutdown(); - onResolverConnectionChanged.accept(false, null); + onConnectionEvent.accept(new ConnectionEvent(false)); } /** 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 3b4c03113..a4581f338 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 @@ -1,11 +1,8 @@ package dev.openfeature.contrib.providers.flagd.resolver.process.storage; -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.storage.connector.Connector; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.StreamPayload; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import lombok.extern.slf4j.Slf4j; +import static dev.openfeature.contrib.providers.flagd.resolver.common.Convert.convertProtobufMapToStructure; + +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -17,6 +14,14 @@ import java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock; import java.util.stream.Collectors; +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.storage.connector.Connector; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; +import dev.openfeature.flagd.grpc.sync.Sync.GetMetadataResponse; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import lombok.extern.slf4j.Slf4j; + /** * Feature flag storage. */ @@ -94,15 +99,17 @@ public BlockingQueue getStateQueue() { } private void streamerListener(final Connector connector) throws InterruptedException { - final BlockingQueue streamPayloads = connector.getStream(); + final BlockingQueue streamPayloads = connector.getStream(); while (!shutdown.get()) { - final StreamPayload take = streamPayloads.take(); - switch (take.getType()) { + final QueuePayload payload = streamPayloads.take(); + switch (payload.getType()) { case DATA: try { List changedFlagsKeys; - Map flagMap = FlagParser.parseString(take.getData(), throwIfInvalid); + Map flagMap = FlagParser.parseString(payload.getFlagData(), + throwIfInvalid); + Map metadata = parseSyncMetadata(payload.getMetadataResponse()); writeLock.lock(); try { changedFlagsKeys = getChangedFlagsKeys(flagMap); @@ -111,7 +118,8 @@ private void streamerListener(final Connector connector) throws InterruptedExcep } finally { writeLock.unlock(); } - if (!stateBlockingQueue.offer(new StorageStateChange(StorageState.OK, changedFlagsKeys))) { + if (!stateBlockingQueue + .offer(new StorageStateChange(StorageState.OK, changedFlagsKeys, metadata))) { log.warn("Failed to convey OK satus, queue is full"); } } catch (Throwable e) { @@ -128,13 +136,23 @@ private void streamerListener(final Connector connector) throws InterruptedExcep } break; default: - log.info(String.format("Payload with unknown type: %s", take.getType())); + log.info(String.format("Payload with unknown type: %s", payload.getType())); } } log.info("Shutting down store stream listener"); } + private Map parseSyncMetadata(GetMetadataResponse metadataResponse) { + try { + return convertProtobufMapToStructure(metadataResponse.getMetadata().getFieldsMap()) + .asObjectMap(); + } catch (Exception exception) { + log.error("Failed to parse metadataResponse, provider metadata may not be up-to-date"); + } + return Collections.emptyMap(); + } + private List getChangedFlagsKeys(Map newFlags) { Map changedFlags = new HashMap<>(); Map addedFeatureFlags = new HashMap<>(); diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/StorageStateChange.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/StorageStateChange.java index cf85b0432..042bd082b 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/StorageStateChange.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/StorageStateChange.java @@ -1,13 +1,13 @@ package dev.openfeature.contrib.providers.flagd.resolver.process.storage; +import java.util.Collections; +import java.util.List; +import java.util.Map; + import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.ToString; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - /** * Represents a change in the stored flags. */ @@ -17,14 +17,39 @@ public class StorageStateChange { private final StorageState storageState; private final List changedFlagsKeys; + private final Map syncMetadata; + + /** + * Construct a new StorageStateChange. + * @param storageState state of the storage + * @param changedFlagsKeys flags changed + * @param syncMetadata possibly updated metadata + */ + public StorageStateChange(StorageState storageState, List changedFlagsKeys, + Map syncMetadata) { + this.storageState = storageState; + this.changedFlagsKeys = Collections.unmodifiableList(changedFlagsKeys); + this.syncMetadata = Collections.unmodifiableMap(syncMetadata); + } + /** + * Construct a new StorageStateChange. + * @param storageState state of the storage + * @param changedFlagsKeys flags changed + */ public StorageStateChange(StorageState storageState, List changedFlagsKeys) { this.storageState = storageState; - this.changedFlagsKeys = new ArrayList<>(changedFlagsKeys); + this.changedFlagsKeys = Collections.unmodifiableList(changedFlagsKeys); + this.syncMetadata = Collections.emptyMap(); } + /** + * Construct a new StorageStateChange. + * @param storageState state of the storage + */ public StorageStateChange(StorageState storageState) { this.storageState = storageState; this.changedFlagsKeys = Collections.emptyList(); + this.syncMetadata = Collections.emptyMap(); } } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/Connector.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/Connector.java index 66ebf2c90..1a00737b5 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/Connector.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/Connector.java @@ -4,12 +4,12 @@ /** * Contract of the in-process storage connector. Connectors are responsible to stream flag configurations in - * {@link StreamPayload} format. + * {@link QueuePayload} format. */ public interface Connector { void init() throws Exception; - BlockingQueue getStream(); + BlockingQueue getStream(); void shutdown() throws InterruptedException; } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/QueuePayload.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/QueuePayload.java new file mode 100644 index 000000000..e9983a42d --- /dev/null +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/QueuePayload.java @@ -0,0 +1,20 @@ +package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector; + +import dev.openfeature.flagd.grpc.sync.Sync.GetMetadataResponse; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Payload emitted by a {@link Connector}. + */ +@AllArgsConstructor +@Getter +public class QueuePayload { + private final QueuePayloadType type; + private final String flagData; + private final GetMetadataResponse metadataResponse; + + public QueuePayload(QueuePayloadType type, String flagData) { + this(type, flagData, GetMetadataResponse.getDefaultInstance()); + } +} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/StreamPayloadType.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/QueuePayloadType.java similarity index 83% rename from providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/StreamPayloadType.java rename to providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/QueuePayloadType.java index dad6ba80d..4839dab51 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/StreamPayloadType.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/QueuePayloadType.java @@ -3,7 +3,7 @@ /** * Payload type emitted by {@link Connector}. */ -public enum StreamPayloadType { +public enum QueuePayloadType { DATA, ERROR } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/StreamPayload.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/StreamPayload.java deleted file mode 100644 index f2afc34b5..000000000 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/StreamPayload.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -/** - * Payload emitted by a {@link Connector}. - */ -@AllArgsConstructor -@Getter -public class StreamPayload { - private final StreamPayloadType type; - private final String data; -} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/file/FileConnector.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/file/FileConnector.java index 775b5a453..a60c58be2 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/file/FileConnector.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/file/FileConnector.java @@ -1,11 +1,5 @@ package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.file; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.Connector; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.StreamPayload; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.StreamPayloadType; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import lombok.extern.slf4j.Slf4j; - import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -14,6 +8,12 @@ import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; +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.contrib.providers.flagd.resolver.process.storage.connector.QueuePayloadType; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import lombok.extern.slf4j.Slf4j; + /** * File connector reads flag configurations from a given file, polls for changes and expose the content through * {@code Connector} contract. @@ -28,7 +28,7 @@ public class FileConnector implements Connector { private static final String OFFER_WARN = "Unable to offer file content to queue: queue is full"; private final String flagSourcePath; - private final BlockingQueue queue = new LinkedBlockingQueue<>(1); + private final BlockingQueue queue = new LinkedBlockingQueue<>(1); private boolean shutdown = false; public FileConnector(final String flagSourcePath) { @@ -45,7 +45,7 @@ public void init() throws IOException { // initial read String flagData = new String(Files.readAllBytes(filePath), StandardCharsets.UTF_8); - if (!queue.offer(new StreamPayload(StreamPayloadType.DATA, flagData))) { + if (!queue.offer(new QueuePayload(QueuePayloadType.DATA, flagData))) { log.warn(OFFER_WARN); } @@ -58,7 +58,7 @@ public void init() throws IOException { if (currentTS > lastTS) { lastTS = currentTS; flagData = new String(Files.readAllBytes(filePath), StandardCharsets.UTF_8); - if (!queue.offer(new StreamPayload(StreamPayloadType.DATA, flagData))) { + if (!queue.offer(new QueuePayload(QueuePayloadType.DATA, flagData))) { log.warn(OFFER_WARN); } } @@ -72,7 +72,7 @@ public void init() throws IOException { Thread.currentThread().interrupt(); } catch (Throwable t) { log.error("Error from file connector. File connector will exit", t); - if (!queue.offer(new StreamPayload(StreamPayloadType.ERROR, t.toString()))) { + if (!queue.offer(new QueuePayload(QueuePayloadType.ERROR, t.toString(), null))) { log.warn(OFFER_WARN); } } @@ -86,7 +86,7 @@ public void init() throws IOException { /** * Expose the queue to fulfil the {@code Connector} contract. */ - public BlockingQueue getStream() { + public BlockingQueue getStream() { return queue; } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/grpc/GrpcStreamConnector.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/grpc/GrpcStreamConnector.java index 4e28f8bdf..769c70a30 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/grpc/GrpcStreamConnector.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/grpc/GrpcStreamConnector.java @@ -1,24 +1,29 @@ package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.grpc; +import java.util.Random; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + import dev.openfeature.contrib.providers.flagd.FlagdOptions; import dev.openfeature.contrib.providers.flagd.resolver.common.ChannelBuilder; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.Connector; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.StreamPayload; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.StreamPayloadType; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayloadType; import dev.openfeature.flagd.grpc.sync.FlagSyncServiceGrpc; +import dev.openfeature.flagd.grpc.sync.FlagSyncServiceGrpc.FlagSyncServiceBlockingStub; import dev.openfeature.flagd.grpc.sync.FlagSyncServiceGrpc.FlagSyncServiceStub; +import dev.openfeature.flagd.grpc.sync.Sync.GetMetadataRequest; +import dev.openfeature.flagd.grpc.sync.Sync.GetMetadataResponse; import dev.openfeature.flagd.grpc.sync.Sync.SyncFlagsRequest; import dev.openfeature.flagd.grpc.sync.Sync.SyncFlagsResponse; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import io.grpc.Context; +import io.grpc.Context.CancellableContext; import io.grpc.ManagedChannel; import lombok.extern.slf4j.Slf4j; -import java.util.Random; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; - /** * Implements the {@link Connector} contract and emit flags obtained from flagd * sync gRPC contract. @@ -28,17 +33,15 @@ "EI_EXPOSE_REP" }, justification = "Random is used to generate a variation & flag configurations require exposing") public class GrpcStreamConnector implements Connector { private static final Random RANDOM = new Random(); - private static final int INIT_BACK_OFF = 2 * 1000; private static final int MAX_BACK_OFF = 120 * 1000; - private static final int QUEUE_SIZE = 5; private final AtomicBoolean shutdown = new AtomicBoolean(false); - private final BlockingQueue blockingQueue = new LinkedBlockingQueue<>(QUEUE_SIZE); - + private final BlockingQueue blockingQueue = new LinkedBlockingQueue<>(QUEUE_SIZE); private final ManagedChannel channel; - private final FlagSyncServiceGrpc.FlagSyncServiceStub serviceStub; + private final FlagSyncServiceStub serviceStub; + private final FlagSyncServiceBlockingStub serviceBlockingStub; private final int deadline; private final String selector; @@ -50,6 +53,7 @@ public class GrpcStreamConnector implements Connector { public GrpcStreamConnector(final FlagdOptions options) { channel = ChannelBuilder.nettyChannel(options); serviceStub = FlagSyncServiceGrpc.newStub(channel); + serviceBlockingStub = FlagSyncServiceGrpc.newBlockingStub(channel); deadline = options.getDeadline(); selector = options.getSelector(); } @@ -60,13 +64,7 @@ public GrpcStreamConnector(final FlagdOptions options) { public void init() { Thread listener = new Thread(() -> { try { - final SyncFlagsRequest.Builder requestBuilder = SyncFlagsRequest.newBuilder(); - - if (selector != null) { - requestBuilder.setSelector(selector); - } - - observeEventStream(blockingQueue, shutdown, serviceStub, requestBuilder.build()); + observeEventStream(blockingQueue, shutdown, serviceStub, serviceBlockingStub, selector, deadline); } catch (InterruptedException e) { log.warn("gRPC event stream interrupted, flag configurations are stale", e); Thread.currentThread().interrupt(); @@ -80,7 +78,7 @@ public void init() { /** * Get blocking queue to obtain payloads exposed by this connector. */ - public BlockingQueue getStream() { + public BlockingQueue getStream() { return blockingQueue; } @@ -111,10 +109,12 @@ public void shutdown() throws InterruptedException { /** * Contains blocking calls, to be used concurrently. */ - static void observeEventStream(final BlockingQueue writeTo, + static void observeEventStream(final BlockingQueue writeTo, final AtomicBoolean shutdown, final FlagSyncServiceStub serviceStub, - final SyncFlagsRequest request) + final FlagSyncServiceBlockingStub serviceBlockingStub, + final String selector, + final int deadline) throws InterruptedException { final BlockingQueue streamReceiver = new LinkedBlockingQueue<>(QUEUE_SIZE); @@ -123,40 +123,66 @@ static void observeEventStream(final BlockingQueue writeTo, log.info("Initializing sync stream observer"); while (!shutdown.get()) { + writeTo.clear(); + Exception metadataException = null; log.debug("Initializing sync stream request"); - serviceStub.syncFlags(request, new GrpcStreamHandler(streamReceiver)); + final SyncFlagsRequest.Builder syncRequest = SyncFlagsRequest.newBuilder(); + final GetMetadataRequest.Builder metadataRequest = GetMetadataRequest.newBuilder(); + GetMetadataResponse metadataResponse = GetMetadataResponse.getDefaultInstance(); - while (!shutdown.get()) { - final GrpcResponseModel response = streamReceiver.take(); + if (selector != null) { + syncRequest.setSelector(selector); + } - if (response.isComplete()) { - log.info("Sync stream completed"); - // The stream is complete, this isn't really an error but we should try to - // reconnect - break; + try (CancellableContext context = Context.current().withCancellation()) { + serviceStub.syncFlags(syncRequest.build(), new GrpcStreamHandler(streamReceiver)); + try { + metadataResponse = serviceBlockingStub.withDeadlineAfter(deadline, TimeUnit.MILLISECONDS) + .getMetadata(metadataRequest.build()); + } catch (Exception e) { + // the chances this call fails but the syncRequest does not are slim + // it could be that the server doesn't implement this RPC + // instead of logging and throwing here, retain the exception and handle in the + // stream logic below + metadataException = e; } - if (response.getError() != null) { - log.error(String.format("Error from grpc connection, retrying in %dms", retryDelay), - response.getError()); + while (!shutdown.get()) { + final GrpcResponseModel response = streamReceiver.take(); + + if (response.isComplete()) { + log.info("Sync stream completed"); + // The stream is complete, this isn't really an error but we should try to + // reconnect + break; + } + + if (response.getError() != null || metadataException != null) { + log.error(String.format("Error from initializing stream or metadata, retrying in %dms", + retryDelay), response.getError()); + + if (!writeTo.offer( + new QueuePayload(QueuePayloadType.ERROR, "Error from stream or metadata", + metadataResponse))) { + log.error("Failed to convey ERROR status, queue is full"); + } + // close the context to cancel the stream in case just the metadata call failed + context.cancel(metadataException); + break; + } + + final SyncFlagsResponse flagsResponse = response.getSyncFlagsResponse(); + String data = flagsResponse.getFlagConfiguration(); + log.debug("Got stream response: " + data); if (!writeTo.offer( - new StreamPayload(StreamPayloadType.ERROR, "Error from stream connection, retrying"))) { - log.error("Failed to convey ERROR status, queue is full"); + new QueuePayload(QueuePayloadType.DATA, data, metadataResponse))) { + log.error("Stream writing failed"); } - break; - } - final SyncFlagsResponse flagsResponse = response.getSyncFlagsResponse(); - String data = flagsResponse.getFlagConfiguration(); - log.debug("Got stream response: " + data); - if (!writeTo.offer( - new StreamPayload(StreamPayloadType.DATA, data))) { - log.error("Stream writing failed"); + // reset retry delay if we succeeded in a retry attempt + retryDelay = INIT_BACK_OFF; } - - // reset retry delay if we succeeded in a retry attempt - retryDelay = INIT_BACK_OFF; } // check for shutdown and avoid sleep @@ -166,7 +192,7 @@ static void observeEventStream(final BlockingQueue writeTo, } // busy wait till next attempt - log.warn(String.format("Stream failed, retrying in %dms", retryDelay)); + log.debug(String.format("Stream failed, retrying in %dms", retryDelay)); Thread.sleep(retryDelay + RANDOM.nextInt(INIT_BACK_OFF)); if (retryDelay < MAX_BACK_OFF) { diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdProviderTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdProviderTest.java index f1318ac4b..269c14d9d 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdProviderTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdProviderTest.java @@ -11,6 +11,7 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; @@ -20,23 +21,27 @@ import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.function.Supplier; import java.util.concurrent.LinkedBlockingQueue; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageStateChange; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.mockito.MockedConstruction; import org.mockito.MockedStatic; import org.mockito.Mockito; import com.google.protobuf.Struct; import dev.openfeature.contrib.providers.flagd.resolver.Resolver; +import dev.openfeature.contrib.providers.flagd.resolver.common.ConnectionEvent; import dev.openfeature.contrib.providers.flagd.resolver.grpc.GrpcConnector; import dev.openfeature.contrib.providers.flagd.resolver.grpc.GrpcResolver; import dev.openfeature.contrib.providers.flagd.resolver.grpc.cache.Cache; @@ -60,6 +65,7 @@ import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.MutableStructure; import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.ProviderState; import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Structure; @@ -67,846 +73,939 @@ import io.cucumber.java.AfterAll; import io.grpc.Channel; import io.grpc.Deadline; +import lombok.val; class FlagdProviderTest { - private static final String FLAG_KEY = "some-key"; - private static final String FLAG_KEY_BOOLEAN = "some-key-boolean"; - private static final String FLAG_KEY_INTEGER = "some-key-integer"; - private static final String FLAG_KEY_DOUBLE = "some-key-double"; - private static final String FLAG_KEY_STRING = "some-key-string"; - private static final String FLAG_KEY_OBJECT = "some-key-object"; - private static final String BOOL_VARIANT = "on"; - private static final String DOUBLE_VARIANT = "half"; - private static final String INT_VARIANT = "one-hundred"; - private static final String STRING_VARIANT = "greeting"; - private static final String OBJECT_VARIANT = "obj"; - private static final Reason DEFAULT = Reason.DEFAULT; - private static final Integer INT_VALUE = 100; - private static final Double DOUBLE_VALUE = .5d; - private static final String INNER_STRUCT_KEY = "inner_key"; - private static final String INNER_STRUCT_VALUE = "inner_value"; - private static final com.google.protobuf.Struct PROTOBUF_STRUCTURE_VALUE = Struct.newBuilder() - .putFields(INNER_STRUCT_KEY, - com.google.protobuf.Value.newBuilder().setStringValue(INNER_STRUCT_VALUE).build()) - .build(); - private static final String STRING_VALUE = "hi!"; - - private static OpenFeatureAPI api; - - @BeforeAll - public static void init() { - api = OpenFeatureAPI.getInstance(); - } - - @AfterAll - public static void cleanUp() { - api.shutdown(); - } - - @Test - void resolvers_call_grpc_service_and_return_details() { - ResolveBooleanResponse booleanResponse = ResolveBooleanResponse.newBuilder() - .setValue(true) - .setVariant(BOOL_VARIANT) - .setReason(DEFAULT.toString()) - .build(); - - ResolveStringResponse stringResponse = ResolveStringResponse.newBuilder() - .setValue(STRING_VALUE) - .setVariant(STRING_VARIANT) - .setReason(DEFAULT.toString()) - .build(); - - ResolveIntResponse intResponse = ResolveIntResponse.newBuilder() - .setValue(INT_VALUE) - .setVariant(INT_VARIANT) - .setReason(DEFAULT.toString()) - .build(); - - ResolveFloatResponse floatResponse = ResolveFloatResponse.newBuilder() - .setValue(DOUBLE_VALUE) - .setVariant(DOUBLE_VARIANT) - .setReason(DEFAULT.toString()) - .build(); - - ResolveObjectResponse objectResponse = ResolveObjectResponse.newBuilder() - .setValue(PROTOBUF_STRUCTURE_VALUE) - .setVariant(OBJECT_VARIANT) - .setReason(DEFAULT.toString()) - .build(); - - ServiceBlockingStub serviceBlockingStubMock = mock(ServiceBlockingStub.class); - - when(serviceBlockingStubMock.withDeadlineAfter(anyLong(), any(TimeUnit.class))) - .thenReturn(serviceBlockingStubMock); - when(serviceBlockingStubMock - .resolveBoolean(argThat(x -> FLAG_KEY_BOOLEAN.equals(x.getFlagKey())))).thenReturn(booleanResponse); - when(serviceBlockingStubMock - .resolveFloat(argThat(x -> FLAG_KEY_DOUBLE.equals(x.getFlagKey())))).thenReturn(floatResponse); - when(serviceBlockingStubMock - .resolveInt(argThat(x -> FLAG_KEY_INTEGER.equals(x.getFlagKey())))).thenReturn(intResponse); - when(serviceBlockingStubMock - .resolveString(argThat(x -> FLAG_KEY_STRING.equals(x.getFlagKey())))).thenReturn(stringResponse); - when(serviceBlockingStubMock - .resolveObject(argThat(x -> FLAG_KEY_OBJECT.equals(x.getFlagKey())))).thenReturn(objectResponse); - - GrpcConnector grpc = mock(GrpcConnector.class); - when(grpc.getResolver()).thenReturn(serviceBlockingStubMock); - - OpenFeatureAPI.getInstance().setProviderAndWait(createProvider(grpc)); - - FlagEvaluationDetails booleanDetails = api.getClient().getBooleanDetails(FLAG_KEY_BOOLEAN, false); - assertTrue(booleanDetails.getValue()); - assertEquals(BOOL_VARIANT, booleanDetails.getVariant()); - assertEquals(DEFAULT.toString(), booleanDetails.getReason()); - - FlagEvaluationDetails stringDetails = api.getClient().getStringDetails(FLAG_KEY_STRING, "wrong"); - assertEquals(STRING_VALUE, stringDetails.getValue()); - assertEquals(STRING_VARIANT, stringDetails.getVariant()); - assertEquals(DEFAULT.toString(), stringDetails.getReason()); - - FlagEvaluationDetails intDetails = api.getClient().getIntegerDetails(FLAG_KEY_INTEGER, 0); - assertEquals(INT_VALUE, intDetails.getValue()); - assertEquals(INT_VARIANT, intDetails.getVariant()); - assertEquals(DEFAULT.toString(), intDetails.getReason()); - - FlagEvaluationDetails floatDetails = api.getClient().getDoubleDetails(FLAG_KEY_DOUBLE, 0.1); - assertEquals(DOUBLE_VALUE, floatDetails.getValue()); - assertEquals(DOUBLE_VARIANT, floatDetails.getVariant()); - assertEquals(DEFAULT.toString(), floatDetails.getReason()); - - FlagEvaluationDetails objectDetails = api.getClient().getObjectDetails(FLAG_KEY_OBJECT, new Value()); - assertEquals(INNER_STRUCT_VALUE, objectDetails.getValue().asStructure() - .asMap().get(INNER_STRUCT_KEY).asString()); - assertEquals(OBJECT_VARIANT, objectDetails.getVariant()); - assertEquals(DEFAULT.toString(), objectDetails.getReason()); - } - - @Test - void zero_value() { - ResolveBooleanResponse booleanResponse = ResolveBooleanResponse.newBuilder() - .setVariant(BOOL_VARIANT) - .setReason(DEFAULT.toString()) - .build(); - - ResolveStringResponse stringResponse = ResolveStringResponse.newBuilder() - .setVariant(STRING_VARIANT) - .setReason(DEFAULT.toString()) - .build(); - - ResolveIntResponse intResponse = ResolveIntResponse.newBuilder() - .setVariant(INT_VARIANT) - .setReason(DEFAULT.toString()) - .build(); - - ResolveFloatResponse floatResponse = ResolveFloatResponse.newBuilder() - .setVariant(DOUBLE_VARIANT) - .setReason(DEFAULT.toString()) - .build(); - - ResolveObjectResponse objectResponse = ResolveObjectResponse.newBuilder() - .setVariant(OBJECT_VARIANT) - .setReason(DEFAULT.toString()) - .build(); - - ServiceBlockingStub serviceBlockingStubMock = mock(ServiceBlockingStub.class); - when(serviceBlockingStubMock.withDeadlineAfter(anyLong(), any(TimeUnit.class))) - .thenReturn(serviceBlockingStubMock); - when(serviceBlockingStubMock - .resolveBoolean(argThat(x -> FLAG_KEY_BOOLEAN.equals(x.getFlagKey())))).thenReturn(booleanResponse); - when(serviceBlockingStubMock - .resolveFloat(argThat(x -> FLAG_KEY_DOUBLE.equals(x.getFlagKey())))).thenReturn(floatResponse); - when(serviceBlockingStubMock - .resolveInt(argThat(x -> FLAG_KEY_INTEGER.equals(x.getFlagKey())))).thenReturn(intResponse); - when(serviceBlockingStubMock - .resolveString(argThat(x -> FLAG_KEY_STRING.equals(x.getFlagKey())))).thenReturn(stringResponse); - when(serviceBlockingStubMock - .resolveObject(argThat(x -> FLAG_KEY_OBJECT.equals(x.getFlagKey())))).thenReturn(objectResponse); - - GrpcConnector grpc = mock(GrpcConnector.class); - when(grpc.getResolver()).thenReturn(serviceBlockingStubMock); - - OpenFeatureAPI.getInstance().setProviderAndWait(createProvider(grpc)); - - FlagEvaluationDetails booleanDetails = api.getClient().getBooleanDetails(FLAG_KEY_BOOLEAN, false); - assertEquals(false, booleanDetails.getValue()); - assertEquals(BOOL_VARIANT, booleanDetails.getVariant()); - assertEquals(DEFAULT.toString(), booleanDetails.getReason()); - - FlagEvaluationDetails stringDetails = api.getClient().getStringDetails(FLAG_KEY_STRING, "wrong"); - assertEquals("", stringDetails.getValue()); - assertEquals(STRING_VARIANT, stringDetails.getVariant()); - assertEquals(DEFAULT.toString(), stringDetails.getReason()); - - FlagEvaluationDetails intDetails = api.getClient().getIntegerDetails(FLAG_KEY_INTEGER, 0); - assertEquals(0, intDetails.getValue()); - assertEquals(INT_VARIANT, intDetails.getVariant()); - assertEquals(DEFAULT.toString(), intDetails.getReason()); - - FlagEvaluationDetails floatDetails = api.getClient().getDoubleDetails(FLAG_KEY_DOUBLE, 0.1); - assertEquals(0.0, floatDetails.getValue()); - assertEquals(DOUBLE_VARIANT, floatDetails.getVariant()); - assertEquals(DEFAULT.toString(), floatDetails.getReason()); - - FlagEvaluationDetails objectDetails = api.getClient().getObjectDetails(FLAG_KEY_OBJECT, new Value()); - assertEquals(new MutableStructure(), objectDetails.getValue().asObject()); - assertEquals(OBJECT_VARIANT, objectDetails.getVariant()); - assertEquals(DEFAULT.toString(), objectDetails.getReason()); - } - - @Test - void test_metadata_from_grpc_response() { - // given - final Map metadataInput = new HashMap<>(); - - com.google.protobuf.Value scope = com.google.protobuf.Value.newBuilder().setStringValue("flagd-scope").build(); - metadataInput.put("scope", scope); - - com.google.protobuf.Value bool = com.google.protobuf.Value.newBuilder().setBoolValue(true).build(); - metadataInput.put("boolean", bool); - - com.google.protobuf.Value number = com.google.protobuf.Value.newBuilder().setNumberValue(1).build(); - metadataInput.put("number", number); - - final Struct metadataStruct = Struct.newBuilder().putAllFields(metadataInput).build(); - - ResolveBooleanResponse booleanResponse = ResolveBooleanResponse.newBuilder() - .setValue(true) - .setVariant(BOOL_VARIANT) - .setReason(DEFAULT.toString()) - .setMetadata(metadataStruct) - .build(); - - ServiceBlockingStub serviceBlockingStubMock = mock(ServiceBlockingStub.class); - - when(serviceBlockingStubMock.withDeadlineAfter(anyLong(), any(TimeUnit.class))).thenReturn( - serviceBlockingStubMock); - when(serviceBlockingStubMock - .resolveBoolean(argThat(x -> FLAG_KEY_BOOLEAN.equals(x.getFlagKey())))).thenReturn(booleanResponse); - - GrpcConnector grpc = mock(GrpcConnector.class); - when(grpc.getResolver()).thenReturn(serviceBlockingStubMock); - OpenFeatureAPI.getInstance().setProviderAndWait(createProvider(grpc)); - - // when - FlagEvaluationDetails booleanDetails = api.getClient().getBooleanDetails(FLAG_KEY_BOOLEAN, false); - - // then - final ImmutableMetadata metadata = booleanDetails.getFlagMetadata(); - - assertEquals("flagd-scope", metadata.getString("scope")); - assertEquals(true, metadata.getBoolean("boolean")); - assertEquals(1, metadata.getDouble("number")); - } - - @Test - void resolvers_cache_responses_if_static_and_event_stream_alive() { - do_resolvers_cache_responses(STATIC_REASON, true, true); - } - - @Test - void resolvers_should_not_cache_responses_if_not_static() { - do_resolvers_cache_responses(DEFAULT.toString(), true, false); - } - - @Test - void resolvers_should_not_cache_responses_if_event_stream_not_alive() { - do_resolvers_cache_responses(STATIC_REASON, false, false); - } - - @Test - void context_is_parsed_and_passed_to_grpc_service() { - final String BOOLEAN_ATTR_KEY = "bool-attr"; - final String INT_ATTR_KEY = "int-attr"; - final String STRING_ATTR_KEY = "string-attr"; - final String STRUCT_ATTR_KEY = "struct-attr"; - final String DOUBLE_ATTR_KEY = "double-attr"; - final String LIST_ATTR_KEY = "list-attr"; - final String STRUCT_ATTR_INNER_KEY = "struct-inner-key"; - - final Boolean BOOLEAN_ATTR_VALUE = true; - final int INT_ATTR_VALUE = 1; - final String STRING_ATTR_VALUE = "str"; - final double DOUBLE_ATTR_VALUE = 0.5d; - final List LIST_ATTR_VALUE = new ArrayList() { - { - add(new Value(1)); - } - }; - final String STRUCT_ATTR_INNER_VALUE = "struct-inner-value"; - final Structure STRUCT_ATTR_VALUE = new MutableStructure().add(STRUCT_ATTR_INNER_KEY, STRUCT_ATTR_INNER_VALUE); - final String DEFAULT_STRING = "DEFAULT"; - - ResolveBooleanResponse booleanResponse = ResolveBooleanResponse.newBuilder() - .setValue(true) - .setVariant(BOOL_VARIANT) - .setReason(DEFAULT_STRING.toString()) - .build(); - - ServiceBlockingStub serviceBlockingStubMock = mock(ServiceBlockingStub.class); - when(serviceBlockingStubMock.withDeadlineAfter(anyLong(), any(TimeUnit.class))) - .thenReturn(serviceBlockingStubMock); - when(serviceBlockingStubMock.resolveBoolean(argThat( - x -> { - final Struct struct = x.getContext(); - final Map valueMap = struct.getFieldsMap(); - - return STRING_ATTR_VALUE.equals(valueMap.get(STRING_ATTR_KEY).getStringValue()) - && INT_ATTR_VALUE == valueMap.get(INT_ATTR_KEY).getNumberValue() - && DOUBLE_ATTR_VALUE == valueMap.get(DOUBLE_ATTR_KEY).getNumberValue() - && valueMap.get(BOOLEAN_ATTR_KEY).getBoolValue() - && "MY_TARGETING_KEY".equals(valueMap.get("targetingKey").getStringValue()) - && LIST_ATTR_VALUE.get(0).asInteger() == valueMap.get(LIST_ATTR_KEY).getListValue() - .getValuesList().get(0).getNumberValue() - && STRUCT_ATTR_INNER_VALUE.equals( - valueMap.get(STRUCT_ATTR_KEY).getStructValue().getFieldsMap() - .get(STRUCT_ATTR_INNER_KEY).getStringValue()); - }))).thenReturn(booleanResponse); - - GrpcConnector grpc = mock(GrpcConnector.class); - when(grpc.getResolver()).thenReturn(serviceBlockingStubMock); - - OpenFeatureAPI.getInstance().setProviderAndWait(createProvider(grpc)); - - final MutableContext context = new MutableContext("MY_TARGETING_KEY"); - context.add(BOOLEAN_ATTR_KEY, BOOLEAN_ATTR_VALUE); - context.add(INT_ATTR_KEY, INT_ATTR_VALUE); - context.add(DOUBLE_ATTR_KEY, DOUBLE_ATTR_VALUE); - context.add(LIST_ATTR_KEY, LIST_ATTR_VALUE); - context.add(STRING_ATTR_KEY, STRING_ATTR_VALUE); - context.add(STRUCT_ATTR_KEY, STRUCT_ATTR_VALUE); - - FlagEvaluationDetails booleanDetails = api.getClient().getBooleanDetails(FLAG_KEY, false, context); - assertTrue(booleanDetails.getValue()); - assertEquals(BOOL_VARIANT, booleanDetails.getVariant()); - assertEquals(DEFAULT.toString(), booleanDetails.getReason()); - } - - // Validates null handling - - // https://github.com/open-feature/java-sdk-contrib/issues/258 - @Test - void null_context_handling() { - // given - final String flagA = "flagA"; - final boolean defaultVariant = false; - final boolean expectedVariant = true; - - final MutableContext context = new MutableContext(); - context.add("key", (String) null); - - final ServiceBlockingStub serviceBlockingStubMock = mock(ServiceBlockingStub.class); - - // when - when(serviceBlockingStubMock.withDeadlineAfter(anyLong(), any(TimeUnit.class))) - .thenReturn(serviceBlockingStubMock); - when(serviceBlockingStubMock.resolveBoolean(any())) - .thenReturn(ResolveBooleanResponse.newBuilder().setValue(expectedVariant).build()); - - GrpcConnector grpc = mock(GrpcConnector.class); - when(grpc.getResolver()).thenReturn(serviceBlockingStubMock); - - OpenFeatureAPI.getInstance().setProviderAndWait(createProvider(grpc)); - - // then - final Boolean evaluation = api.getClient().getBooleanValue(flagA, defaultVariant, context); - - assertNotEquals(evaluation, defaultVariant); - assertEquals(evaluation, expectedVariant); - } - - @Test - void reason_mapped_correctly_if_unknown() { - ResolveBooleanResponse badReasonResponse = ResolveBooleanResponse.newBuilder() - .setValue(true) - .setVariant(BOOL_VARIANT) - .setReason("UNKNOWN") // set an invalid reason string - .build(); - - ServiceBlockingStub serviceBlockingStubMock = mock(ServiceBlockingStub.class); - when(serviceBlockingStubMock.withDeadlineAfter(anyLong(), any(TimeUnit.class))) - .thenReturn(serviceBlockingStubMock); - when(serviceBlockingStubMock.resolveBoolean(any(ResolveBooleanRequest.class))).thenReturn(badReasonResponse); - - GrpcConnector grpc = mock(GrpcConnector.class); - when(grpc.getResolver()).thenReturn(serviceBlockingStubMock); - - OpenFeatureAPI.getInstance().setProviderAndWait(createProvider(grpc)); - - FlagEvaluationDetails booleanDetails = api.getClient() - .getBooleanDetails(FLAG_KEY, false, new MutableContext()); - assertEquals(Reason.UNKNOWN.toString(), booleanDetails.getReason()); // reason should be converted to UNKNOWN - } - - @Test - void invalidate_cache() { - ResolveBooleanResponse booleanResponse = ResolveBooleanResponse.newBuilder() - .setValue(true) - .setVariant(BOOL_VARIANT) - .setReason(STATIC_REASON) - .build(); - - ResolveStringResponse stringResponse = ResolveStringResponse.newBuilder() - .setValue(STRING_VALUE) - .setVariant(STRING_VARIANT) - .setReason(STATIC_REASON) - .build(); - - ResolveIntResponse intResponse = ResolveIntResponse.newBuilder() - .setValue(INT_VALUE) - .setVariant(INT_VARIANT) - .setReason(STATIC_REASON) - .build(); - - ResolveFloatResponse floatResponse = ResolveFloatResponse.newBuilder() - .setValue(DOUBLE_VALUE) - .setVariant(DOUBLE_VARIANT) - .setReason(STATIC_REASON) - .build(); - - ResolveObjectResponse objectResponse = ResolveObjectResponse.newBuilder() - .setValue(PROTOBUF_STRUCTURE_VALUE) - .setVariant(OBJECT_VARIANT) - .setReason(STATIC_REASON) - .build(); - - ServiceBlockingStub serviceBlockingStubMock = mock(ServiceBlockingStub.class); - ServiceStub serviceStubMock = mock(ServiceStub.class); - when(serviceStubMock.withWaitForReady()).thenReturn(serviceStubMock); - doNothing().when(serviceStubMock).eventStream(any(), any()); - when(serviceStubMock.withDeadline(any(Deadline.class))) - .thenReturn(serviceStubMock); - when(serviceBlockingStubMock.withWaitForReady()).thenReturn(serviceBlockingStubMock); - when(serviceBlockingStubMock - .withDeadline(any(Deadline.class))) - .thenReturn(serviceBlockingStubMock); - when(serviceBlockingStubMock.withDeadlineAfter(anyLong(), any(TimeUnit.class))) - .thenReturn(serviceBlockingStubMock); - when(serviceBlockingStubMock - .resolveBoolean(argThat(x -> FLAG_KEY_BOOLEAN.equals(x.getFlagKey())))).thenReturn(booleanResponse); - when(serviceBlockingStubMock - .resolveFloat(argThat(x -> FLAG_KEY_DOUBLE.equals(x.getFlagKey())))).thenReturn(floatResponse); - when(serviceBlockingStubMock - .resolveInt(argThat(x -> FLAG_KEY_INTEGER.equals(x.getFlagKey())))).thenReturn(intResponse); - when(serviceBlockingStubMock - .resolveString(argThat(x -> FLAG_KEY_STRING.equals(x.getFlagKey())))).thenReturn(stringResponse); - when(serviceBlockingStubMock - .resolveObject(argThat(x -> FLAG_KEY_OBJECT.equals(x.getFlagKey())))).thenReturn(objectResponse); - - GrpcConnector grpc; - try (MockedStatic mockStaticService = mockStatic(ServiceGrpc.class)) { - mockStaticService.when(() -> ServiceGrpc.newBlockingStub(any(Channel.class))) - .thenReturn(serviceBlockingStubMock); - mockStaticService.when(() -> ServiceGrpc.newStub(any())) - .thenReturn(serviceStubMock); - - final Cache cache = new Cache("lru", 5); - - class NoopInitGrpcConnector extends GrpcConnector { - public NoopInitGrpcConnector(FlagdOptions options, Cache cache, Supplier connectedSupplier, BiConsumer> onResolverConnectionChanged) { - super(options, cache, connectedSupplier, onResolverConnectionChanged); - } + private static final String FLAG_KEY = "some-key"; + private static final String FLAG_KEY_BOOLEAN = "some-key-boolean"; + private static final String FLAG_KEY_INTEGER = "some-key-integer"; + private static final String FLAG_KEY_DOUBLE = "some-key-double"; + private static final String FLAG_KEY_STRING = "some-key-string"; + private static final String FLAG_KEY_OBJECT = "some-key-object"; + private static final String BOOL_VARIANT = "on"; + private static final String DOUBLE_VARIANT = "half"; + private static final String INT_VARIANT = "one-hundred"; + private static final String STRING_VARIANT = "greeting"; + private static final String OBJECT_VARIANT = "obj"; + private static final Reason DEFAULT = Reason.DEFAULT; + private static final Integer INT_VALUE = 100; + private static final Double DOUBLE_VALUE = .5d; + private static final String INNER_STRUCT_KEY = "inner_key"; + private static final String INNER_STRUCT_VALUE = "inner_value"; + private static final com.google.protobuf.Struct PROTOBUF_STRUCTURE_VALUE = Struct.newBuilder() + .putFields(INNER_STRUCT_KEY, + com.google.protobuf.Value.newBuilder().setStringValue(INNER_STRUCT_VALUE) + .build()) + .build(); + private static final String STRING_VALUE = "hi!"; + + private static OpenFeatureAPI api; + + @BeforeAll + public static void init() { + api = OpenFeatureAPI.getInstance(); + } + + @AfterAll + public static void cleanUp() { + api.shutdown(); + } + + @Test + void resolvers_call_grpc_service_and_return_details() { + ResolveBooleanResponse booleanResponse = ResolveBooleanResponse.newBuilder() + .setValue(true) + .setVariant(BOOL_VARIANT) + .setReason(DEFAULT.toString()) + .build(); + + ResolveStringResponse stringResponse = ResolveStringResponse.newBuilder() + .setValue(STRING_VALUE) + .setVariant(STRING_VARIANT) + .setReason(DEFAULT.toString()) + .build(); + + ResolveIntResponse intResponse = ResolveIntResponse.newBuilder() + .setValue(INT_VALUE) + .setVariant(INT_VARIANT) + .setReason(DEFAULT.toString()) + .build(); + + ResolveFloatResponse floatResponse = ResolveFloatResponse.newBuilder() + .setValue(DOUBLE_VALUE) + .setVariant(DOUBLE_VARIANT) + .setReason(DEFAULT.toString()) + .build(); + + ResolveObjectResponse objectResponse = ResolveObjectResponse.newBuilder() + .setValue(PROTOBUF_STRUCTURE_VALUE) + .setVariant(OBJECT_VARIANT) + .setReason(DEFAULT.toString()) + .build(); + + ServiceBlockingStub serviceBlockingStubMock = mock(ServiceBlockingStub.class); + + when(serviceBlockingStubMock.withDeadlineAfter(anyLong(), any(TimeUnit.class))) + .thenReturn(serviceBlockingStubMock); + when(serviceBlockingStubMock + .resolveBoolean(argThat(x -> FLAG_KEY_BOOLEAN.equals(x.getFlagKey())))) + .thenReturn(booleanResponse); + when(serviceBlockingStubMock + .resolveFloat(argThat(x -> FLAG_KEY_DOUBLE.equals(x.getFlagKey())))) + .thenReturn(floatResponse); + when(serviceBlockingStubMock + .resolveInt(argThat(x -> FLAG_KEY_INTEGER.equals(x.getFlagKey())))) + .thenReturn(intResponse); + when(serviceBlockingStubMock + .resolveString(argThat(x -> FLAG_KEY_STRING.equals(x.getFlagKey())))) + .thenReturn(stringResponse); + when(serviceBlockingStubMock + .resolveObject(argThat(x -> FLAG_KEY_OBJECT.equals(x.getFlagKey())))) + .thenReturn(objectResponse); + + GrpcConnector grpc = mock(GrpcConnector.class); + when(grpc.getResolver()).thenReturn(serviceBlockingStubMock); + + OpenFeatureAPI.getInstance().setProviderAndWait(createProvider(grpc)); + + FlagEvaluationDetails booleanDetails = api.getClient().getBooleanDetails(FLAG_KEY_BOOLEAN, + false); + assertTrue(booleanDetails.getValue()); + assertEquals(BOOL_VARIANT, booleanDetails.getVariant()); + assertEquals(DEFAULT.toString(), booleanDetails.getReason()); + + FlagEvaluationDetails stringDetails = api.getClient().getStringDetails(FLAG_KEY_STRING, + "wrong"); + assertEquals(STRING_VALUE, stringDetails.getValue()); + assertEquals(STRING_VARIANT, stringDetails.getVariant()); + assertEquals(DEFAULT.toString(), stringDetails.getReason()); + + FlagEvaluationDetails intDetails = api.getClient().getIntegerDetails(FLAG_KEY_INTEGER, 0); + assertEquals(INT_VALUE, intDetails.getValue()); + assertEquals(INT_VARIANT, intDetails.getVariant()); + assertEquals(DEFAULT.toString(), intDetails.getReason()); + + FlagEvaluationDetails floatDetails = api.getClient().getDoubleDetails(FLAG_KEY_DOUBLE, 0.1); + assertEquals(DOUBLE_VALUE, floatDetails.getValue()); + assertEquals(DOUBLE_VARIANT, floatDetails.getVariant()); + assertEquals(DEFAULT.toString(), floatDetails.getReason()); + + FlagEvaluationDetails objectDetails = api.getClient().getObjectDetails(FLAG_KEY_OBJECT, + new Value()); + assertEquals(INNER_STRUCT_VALUE, objectDetails.getValue().asStructure() + .asMap().get(INNER_STRUCT_KEY).asString()); + assertEquals(OBJECT_VARIANT, objectDetails.getVariant()); + assertEquals(DEFAULT.toString(), objectDetails.getReason()); + } + + @Test + void zero_value() { + ResolveBooleanResponse booleanResponse = ResolveBooleanResponse.newBuilder() + .setVariant(BOOL_VARIANT) + .setReason(DEFAULT.toString()) + .build(); + + ResolveStringResponse stringResponse = ResolveStringResponse.newBuilder() + .setVariant(STRING_VARIANT) + .setReason(DEFAULT.toString()) + .build(); + + ResolveIntResponse intResponse = ResolveIntResponse.newBuilder() + .setVariant(INT_VARIANT) + .setReason(DEFAULT.toString()) + .build(); + + ResolveFloatResponse floatResponse = ResolveFloatResponse.newBuilder() + .setVariant(DOUBLE_VARIANT) + .setReason(DEFAULT.toString()) + .build(); + + ResolveObjectResponse objectResponse = ResolveObjectResponse.newBuilder() + .setVariant(OBJECT_VARIANT) + .setReason(DEFAULT.toString()) + .build(); + + ServiceBlockingStub serviceBlockingStubMock = mock(ServiceBlockingStub.class); + when(serviceBlockingStubMock.withDeadlineAfter(anyLong(), any(TimeUnit.class))) + .thenReturn(serviceBlockingStubMock); + when(serviceBlockingStubMock + .resolveBoolean(argThat(x -> FLAG_KEY_BOOLEAN.equals(x.getFlagKey())))) + .thenReturn(booleanResponse); + when(serviceBlockingStubMock + .resolveFloat(argThat(x -> FLAG_KEY_DOUBLE.equals(x.getFlagKey())))) + .thenReturn(floatResponse); + when(serviceBlockingStubMock + .resolveInt(argThat(x -> FLAG_KEY_INTEGER.equals(x.getFlagKey())))) + .thenReturn(intResponse); + when(serviceBlockingStubMock + .resolveString(argThat(x -> FLAG_KEY_STRING.equals(x.getFlagKey())))) + .thenReturn(stringResponse); + when(serviceBlockingStubMock + .resolveObject(argThat(x -> FLAG_KEY_OBJECT.equals(x.getFlagKey())))) + .thenReturn(objectResponse); + + GrpcConnector grpc = mock(GrpcConnector.class); + when(grpc.getResolver()).thenReturn(serviceBlockingStubMock); + + OpenFeatureAPI.getInstance().setProviderAndWait(createProvider(grpc)); + + FlagEvaluationDetails booleanDetails = api.getClient().getBooleanDetails(FLAG_KEY_BOOLEAN, + false); + assertEquals(false, booleanDetails.getValue()); + assertEquals(BOOL_VARIANT, booleanDetails.getVariant()); + assertEquals(DEFAULT.toString(), booleanDetails.getReason()); + + FlagEvaluationDetails stringDetails = api.getClient().getStringDetails(FLAG_KEY_STRING, + "wrong"); + assertEquals("", stringDetails.getValue()); + assertEquals(STRING_VARIANT, stringDetails.getVariant()); + assertEquals(DEFAULT.toString(), stringDetails.getReason()); + + FlagEvaluationDetails intDetails = api.getClient().getIntegerDetails(FLAG_KEY_INTEGER, 0); + assertEquals(0, intDetails.getValue()); + assertEquals(INT_VARIANT, intDetails.getVariant()); + assertEquals(DEFAULT.toString(), intDetails.getReason()); + + FlagEvaluationDetails floatDetails = api.getClient().getDoubleDetails(FLAG_KEY_DOUBLE, 0.1); + assertEquals(0.0, floatDetails.getValue()); + assertEquals(DOUBLE_VARIANT, floatDetails.getVariant()); + assertEquals(DEFAULT.toString(), floatDetails.getReason()); + + FlagEvaluationDetails objectDetails = api.getClient().getObjectDetails(FLAG_KEY_OBJECT, + new Value()); + assertEquals(new MutableStructure(), objectDetails.getValue().asObject()); + assertEquals(OBJECT_VARIANT, objectDetails.getVariant()); + assertEquals(DEFAULT.toString(), objectDetails.getReason()); + } + + @Test + void test_metadata_from_grpc_response() { + // given + final Map metadataInput = new HashMap<>(); + + com.google.protobuf.Value scope = com.google.protobuf.Value.newBuilder().setStringValue("flagd-scope") + .build(); + metadataInput.put("scope", scope); + + com.google.protobuf.Value bool = com.google.protobuf.Value.newBuilder().setBoolValue(true).build(); + metadataInput.put("boolean", bool); + + com.google.protobuf.Value number = com.google.protobuf.Value.newBuilder().setNumberValue(1).build(); + metadataInput.put("number", number); + + final Struct metadataStruct = Struct.newBuilder().putAllFields(metadataInput).build(); + + ResolveBooleanResponse booleanResponse = ResolveBooleanResponse.newBuilder() + .setValue(true) + .setVariant(BOOL_VARIANT) + .setReason(DEFAULT.toString()) + .setMetadata(metadataStruct) + .build(); + + ServiceBlockingStub serviceBlockingStubMock = mock(ServiceBlockingStub.class); + + when(serviceBlockingStubMock.withDeadlineAfter(anyLong(), any(TimeUnit.class))).thenReturn( + serviceBlockingStubMock); + when(serviceBlockingStubMock + .resolveBoolean(argThat(x -> FLAG_KEY_BOOLEAN.equals(x.getFlagKey())))) + .thenReturn(booleanResponse); + + GrpcConnector grpc = mock(GrpcConnector.class); + when(grpc.getResolver()).thenReturn(serviceBlockingStubMock); + OpenFeatureAPI.getInstance().setProviderAndWait(createProvider(grpc)); + + // when + FlagEvaluationDetails booleanDetails = api.getClient().getBooleanDetails(FLAG_KEY_BOOLEAN, + false); + + // then + final ImmutableMetadata metadata = booleanDetails.getFlagMetadata(); - public void initialize() throws Exception {}; - } + assertEquals("flagd-scope", metadata.getString("scope")); + assertEquals(true, metadata.getBoolean("boolean")); + assertEquals(1, metadata.getDouble("number")); + } + + @Test + void resolvers_cache_responses_if_static_and_event_stream_alive() { + do_resolvers_cache_responses(STATIC_REASON, true, true); + } - grpc = new NoopInitGrpcConnector(FlagdOptions.builder().build(), cache, () -> true, (state, changedFlagKeys) -> { - }); + @Test + void resolvers_should_not_cache_responses_if_not_static() { + do_resolvers_cache_responses(DEFAULT.toString(), true, false); } - FlagdProvider provider = createProvider(grpc); - OpenFeatureAPI.getInstance().setProviderAndWait(provider); - - HashMap flagsMap = new HashMap(); - HashMap structMap = new HashMap(); - - flagsMap.put(FLAG_KEY_BOOLEAN, com.google.protobuf.Value.newBuilder().setStringValue("foo").build()); - flagsMap.put(FLAG_KEY_STRING, com.google.protobuf.Value.newBuilder().setStringValue("foo").build()); - flagsMap.put(FLAG_KEY_INTEGER, com.google.protobuf.Value.newBuilder().setStringValue("foo").build()); - flagsMap.put(FLAG_KEY_DOUBLE, com.google.protobuf.Value.newBuilder().setStringValue("foo").build()); - flagsMap.put(FLAG_KEY_OBJECT, com.google.protobuf.Value.newBuilder().setStringValue("foo").build()); - - structMap.put("flags", com.google.protobuf.Value.newBuilder() - .setStructValue(Struct.newBuilder().putAllFields(flagsMap)).build()); - - // should cache results - FlagEvaluationDetails booleanDetails; - FlagEvaluationDetails stringDetails; - FlagEvaluationDetails intDetails; - FlagEvaluationDetails floatDetails; - FlagEvaluationDetails objectDetails; - - // assert cache has been invalidated - booleanDetails = api.getClient().getBooleanDetails(FLAG_KEY_BOOLEAN, false); - assertTrue(booleanDetails.getValue()); - assertEquals(BOOL_VARIANT, booleanDetails.getVariant()); - assertEquals(STATIC_REASON, booleanDetails.getReason()); - - stringDetails = api.getClient().getStringDetails(FLAG_KEY_STRING, "wrong"); - assertEquals(STRING_VALUE, stringDetails.getValue()); - assertEquals(STRING_VARIANT, stringDetails.getVariant()); - assertEquals(STATIC_REASON, stringDetails.getReason()); - - intDetails = api.getClient().getIntegerDetails(FLAG_KEY_INTEGER, 0); - assertEquals(INT_VALUE, intDetails.getValue()); - assertEquals(INT_VARIANT, intDetails.getVariant()); - assertEquals(STATIC_REASON, intDetails.getReason()); - - floatDetails = api.getClient().getDoubleDetails(FLAG_KEY_DOUBLE, 0.1); - assertEquals(DOUBLE_VALUE, floatDetails.getValue()); - assertEquals(DOUBLE_VARIANT, floatDetails.getVariant()); - assertEquals(STATIC_REASON, floatDetails.getReason()); - - objectDetails = api.getClient().getObjectDetails(FLAG_KEY_OBJECT, new Value()); - assertEquals(INNER_STRUCT_VALUE, objectDetails.getValue().asStructure() - .asMap().get(INNER_STRUCT_KEY).asString()); - assertEquals(OBJECT_VARIANT, objectDetails.getVariant()); - assertEquals(STATIC_REASON, objectDetails.getReason()); - } - - private void do_resolvers_cache_responses(String reason, Boolean eventStreamAlive, Boolean shouldCache) { - String expectedReason = CACHED_REASON; - if (!shouldCache) { - expectedReason = reason; + @Test + void resolvers_should_not_cache_responses_if_event_stream_not_alive() { + do_resolvers_cache_responses(STATIC_REASON, false, false); } - ResolveBooleanResponse booleanResponse = ResolveBooleanResponse.newBuilder() - .setValue(true) - .setVariant(BOOL_VARIANT) - .setReason(reason) - .build(); - - ResolveStringResponse stringResponse = ResolveStringResponse.newBuilder() - .setValue(STRING_VALUE) - .setVariant(STRING_VARIANT) - .setReason(reason) - .build(); - - ResolveIntResponse intResponse = ResolveIntResponse.newBuilder() - .setValue(INT_VALUE) - .setVariant(INT_VARIANT) - .setReason(reason) - .build(); - - ResolveFloatResponse floatResponse = ResolveFloatResponse.newBuilder() - .setValue(DOUBLE_VALUE) - .setVariant(DOUBLE_VARIANT) - .setReason(reason) - .build(); - - ResolveObjectResponse objectResponse = ResolveObjectResponse.newBuilder() - .setValue(PROTOBUF_STRUCTURE_VALUE) - .setVariant(OBJECT_VARIANT) - .setReason(reason) - .build(); - - ServiceBlockingStub serviceBlockingStubMock = mock(ServiceBlockingStub.class); - when(serviceBlockingStubMock.withDeadlineAfter(anyLong(), any(TimeUnit.class))) - .thenReturn(serviceBlockingStubMock); - when(serviceBlockingStubMock - .resolveBoolean(argThat(x -> FLAG_KEY_BOOLEAN.equals(x.getFlagKey())))).thenReturn(booleanResponse); - when(serviceBlockingStubMock - .resolveFloat(argThat(x -> FLAG_KEY_DOUBLE.equals(x.getFlagKey())))).thenReturn(floatResponse); - when(serviceBlockingStubMock - .resolveInt(argThat(x -> FLAG_KEY_INTEGER.equals(x.getFlagKey())))).thenReturn(intResponse); - when(serviceBlockingStubMock - .resolveString(argThat(x -> FLAG_KEY_STRING.equals(x.getFlagKey())))).thenReturn(stringResponse); - when(serviceBlockingStubMock - .resolveObject(argThat(x -> FLAG_KEY_OBJECT.equals(x.getFlagKey())))).thenReturn(objectResponse); - - GrpcConnector grpc = mock(GrpcConnector.class); - when(grpc.getResolver()).thenReturn(serviceBlockingStubMock); - FlagdProvider provider = createProvider(grpc, () -> eventStreamAlive); - // provider.setState(eventStreamAlive); // caching only available when event - // stream is alive - OpenFeatureAPI.getInstance().setProviderAndWait(provider); - - FlagEvaluationDetails booleanDetails = api.getClient().getBooleanDetails(FLAG_KEY_BOOLEAN, false); - booleanDetails = api.getClient() - .getBooleanDetails(FLAG_KEY_BOOLEAN, false); // should retrieve from cache on second invocation - assertTrue(booleanDetails.getValue()); - assertEquals(BOOL_VARIANT, booleanDetails.getVariant()); - assertEquals(expectedReason, booleanDetails.getReason()); - - FlagEvaluationDetails stringDetails = api.getClient().getStringDetails(FLAG_KEY_STRING, "wrong"); - stringDetails = api.getClient().getStringDetails(FLAG_KEY_STRING, "wrong"); - assertEquals(STRING_VALUE, stringDetails.getValue()); - assertEquals(STRING_VARIANT, stringDetails.getVariant()); - assertEquals(expectedReason, stringDetails.getReason()); - - FlagEvaluationDetails intDetails = api.getClient().getIntegerDetails(FLAG_KEY_INTEGER, 0); - intDetails = api.getClient().getIntegerDetails(FLAG_KEY_INTEGER, 0); - assertEquals(INT_VALUE, intDetails.getValue()); - assertEquals(INT_VARIANT, intDetails.getVariant()); - assertEquals(expectedReason, intDetails.getReason()); - - FlagEvaluationDetails floatDetails = api.getClient().getDoubleDetails(FLAG_KEY_DOUBLE, 0.1); - floatDetails = api.getClient().getDoubleDetails(FLAG_KEY_DOUBLE, 0.1); - assertEquals(DOUBLE_VALUE, floatDetails.getValue()); - assertEquals(DOUBLE_VARIANT, floatDetails.getVariant()); - assertEquals(expectedReason, floatDetails.getReason()); - - FlagEvaluationDetails objectDetails = api.getClient().getObjectDetails(FLAG_KEY_OBJECT, new Value()); - objectDetails = api.getClient().getObjectDetails(FLAG_KEY_OBJECT, new Value()); - assertEquals(INNER_STRUCT_VALUE, objectDetails.getValue().asStructure() - .asMap().get(INNER_STRUCT_KEY).asString()); - assertEquals(OBJECT_VARIANT, objectDetails.getVariant()); - assertEquals(expectedReason, objectDetails.getReason()); - } - - @Test - void disabled_cache() { - ResolveBooleanResponse booleanResponse = ResolveBooleanResponse.newBuilder() - .setValue(true) - .setVariant(BOOL_VARIANT) - .setReason(STATIC_REASON) - .build(); - - ResolveStringResponse stringResponse = ResolveStringResponse.newBuilder() - .setValue(STRING_VALUE) - .setVariant(STRING_VARIANT) - .setReason(STATIC_REASON) - .build(); - - ResolveIntResponse intResponse = ResolveIntResponse.newBuilder() - .setValue(INT_VALUE) - .setVariant(INT_VARIANT) - .setReason(STATIC_REASON) - .build(); - - ResolveFloatResponse floatResponse = ResolveFloatResponse.newBuilder() - .setValue(DOUBLE_VALUE) - .setVariant(DOUBLE_VARIANT) - .setReason(STATIC_REASON) - .build(); - - ResolveObjectResponse objectResponse = ResolveObjectResponse.newBuilder() - .setValue(PROTOBUF_STRUCTURE_VALUE) - .setVariant(OBJECT_VARIANT) - .setReason(STATIC_REASON) - .build(); - - ServiceBlockingStub serviceBlockingStubMock = mock(ServiceBlockingStub.class); - ServiceStub serviceStubMock = mock(ServiceStub.class); - when(serviceStubMock.withWaitForReady()).thenReturn(serviceStubMock); - when(serviceStubMock.withDeadline(any(Deadline.class))) - .thenReturn(serviceStubMock); - when(serviceBlockingStubMock.withWaitForReady()).thenReturn(serviceBlockingStubMock); - when(serviceBlockingStubMock.withDeadline(any(Deadline.class))) - .thenReturn(serviceBlockingStubMock); - when(serviceBlockingStubMock.withDeadlineAfter(anyLong(), any(TimeUnit.class))) - .thenReturn(serviceBlockingStubMock); - when(serviceBlockingStubMock - .resolveBoolean(argThat(x -> FLAG_KEY_BOOLEAN.equals(x.getFlagKey())))).thenReturn(booleanResponse); - when(serviceBlockingStubMock - .resolveFloat(argThat(x -> FLAG_KEY_DOUBLE.equals(x.getFlagKey())))).thenReturn(floatResponse); - when(serviceBlockingStubMock - .resolveInt(argThat(x -> FLAG_KEY_INTEGER.equals(x.getFlagKey())))).thenReturn(intResponse); - when(serviceBlockingStubMock - .resolveString(argThat(x -> FLAG_KEY_STRING.equals(x.getFlagKey())))).thenReturn(stringResponse); - when(serviceBlockingStubMock - .resolveObject(argThat(x -> FLAG_KEY_OBJECT.equals(x.getFlagKey())))).thenReturn(objectResponse); - - // disabled cache - final Cache cache = new Cache("disabled", 0); - - GrpcConnector grpc; - try (MockedStatic mockStaticService = mockStatic(ServiceGrpc.class)) { - mockStaticService.when(() -> ServiceGrpc.newBlockingStub(any(Channel.class))) + @Test + void context_is_parsed_and_passed_to_grpc_service() { + final String BOOLEAN_ATTR_KEY = "bool-attr"; + final String INT_ATTR_KEY = "int-attr"; + final String STRING_ATTR_KEY = "string-attr"; + final String STRUCT_ATTR_KEY = "struct-attr"; + final String DOUBLE_ATTR_KEY = "double-attr"; + final String LIST_ATTR_KEY = "list-attr"; + final String STRUCT_ATTR_INNER_KEY = "struct-inner-key"; + + final Boolean BOOLEAN_ATTR_VALUE = true; + final int INT_ATTR_VALUE = 1; + final String STRING_ATTR_VALUE = "str"; + final double DOUBLE_ATTR_VALUE = 0.5d; + final List LIST_ATTR_VALUE = new ArrayList() { + { + add(new Value(1)); + } + }; + final String STRUCT_ATTR_INNER_VALUE = "struct-inner-value"; + final Structure STRUCT_ATTR_VALUE = new MutableStructure().add(STRUCT_ATTR_INNER_KEY, + STRUCT_ATTR_INNER_VALUE); + final String DEFAULT_STRING = "DEFAULT"; + + ResolveBooleanResponse booleanResponse = ResolveBooleanResponse.newBuilder() + .setValue(true) + .setVariant(BOOL_VARIANT) + .setReason(DEFAULT_STRING.toString()) + .build(); + + ServiceBlockingStub serviceBlockingStubMock = mock(ServiceBlockingStub.class); + when(serviceBlockingStubMock.withDeadlineAfter(anyLong(), any(TimeUnit.class))) .thenReturn(serviceBlockingStubMock); - mockStaticService.when(() -> ServiceGrpc.newStub(any())) + when(serviceBlockingStubMock.resolveBoolean(argThat( + x -> { + final Struct struct = x.getContext(); + final Map valueMap = struct.getFieldsMap(); + + return STRING_ATTR_VALUE.equals(valueMap.get(STRING_ATTR_KEY).getStringValue()) + && INT_ATTR_VALUE == valueMap.get(INT_ATTR_KEY).getNumberValue() + && DOUBLE_ATTR_VALUE == valueMap.get(DOUBLE_ATTR_KEY) + .getNumberValue() + && valueMap.get(BOOLEAN_ATTR_KEY).getBoolValue() + && "MY_TARGETING_KEY".equals( + valueMap.get("targetingKey").getStringValue()) + && LIST_ATTR_VALUE.get(0).asInteger() == valueMap + .get(LIST_ATTR_KEY).getListValue() + .getValuesList().get(0).getNumberValue() + && STRUCT_ATTR_INNER_VALUE.equals( + valueMap.get(STRUCT_ATTR_KEY).getStructValue() + .getFieldsMap() + .get(STRUCT_ATTR_INNER_KEY) + .getStringValue()); + }))).thenReturn(booleanResponse); + + GrpcConnector grpc = mock(GrpcConnector.class); + when(grpc.getResolver()).thenReturn(serviceBlockingStubMock); + + OpenFeatureAPI.getInstance().setProviderAndWait(createProvider(grpc)); + + final MutableContext context = new MutableContext("MY_TARGETING_KEY"); + context.add(BOOLEAN_ATTR_KEY, BOOLEAN_ATTR_VALUE); + context.add(INT_ATTR_KEY, INT_ATTR_VALUE); + context.add(DOUBLE_ATTR_KEY, DOUBLE_ATTR_VALUE); + context.add(LIST_ATTR_KEY, LIST_ATTR_VALUE); + context.add(STRING_ATTR_KEY, STRING_ATTR_VALUE); + context.add(STRUCT_ATTR_KEY, STRUCT_ATTR_VALUE); + + FlagEvaluationDetails booleanDetails = api.getClient().getBooleanDetails(FLAG_KEY, false, + context); + assertTrue(booleanDetails.getValue()); + assertEquals(BOOL_VARIANT, booleanDetails.getVariant()); + assertEquals(DEFAULT.toString(), booleanDetails.getReason()); + } + + // Validates null handling - + // https://github.com/open-feature/java-sdk-contrib/issues/258 + @Test + void null_context_handling() { + // given + final String flagA = "flagA"; + final boolean defaultVariant = false; + final boolean expectedVariant = true; + + final MutableContext context = new MutableContext(); + context.add("key", (String) null); + + final ServiceBlockingStub serviceBlockingStubMock = mock(ServiceBlockingStub.class); + + // when + when(serviceBlockingStubMock.withDeadlineAfter(anyLong(), any(TimeUnit.class))) + .thenReturn(serviceBlockingStubMock); + when(serviceBlockingStubMock.resolveBoolean(any())) + .thenReturn(ResolveBooleanResponse.newBuilder().setValue(expectedVariant).build()); + + GrpcConnector grpc = mock(GrpcConnector.class); + when(grpc.getResolver()).thenReturn(serviceBlockingStubMock); + + OpenFeatureAPI.getInstance().setProviderAndWait(createProvider(grpc)); + + // then + final Boolean evaluation = api.getClient().getBooleanValue(flagA, defaultVariant, context); + + assertNotEquals(evaluation, defaultVariant); + assertEquals(evaluation, expectedVariant); + } + + @Test + void reason_mapped_correctly_if_unknown() { + ResolveBooleanResponse badReasonResponse = ResolveBooleanResponse.newBuilder() + .setValue(true) + .setVariant(BOOL_VARIANT) + .setReason("UNKNOWN") // set an invalid reason string + .build(); + + ServiceBlockingStub serviceBlockingStubMock = mock(ServiceBlockingStub.class); + when(serviceBlockingStubMock.withDeadlineAfter(anyLong(), any(TimeUnit.class))) + .thenReturn(serviceBlockingStubMock); + when(serviceBlockingStubMock.resolveBoolean(any(ResolveBooleanRequest.class))) + .thenReturn(badReasonResponse); + + GrpcConnector grpc = mock(GrpcConnector.class); + when(grpc.getResolver()).thenReturn(serviceBlockingStubMock); + + OpenFeatureAPI.getInstance().setProviderAndWait(createProvider(grpc)); + + FlagEvaluationDetails booleanDetails = api.getClient() + .getBooleanDetails(FLAG_KEY, false, new MutableContext()); + assertEquals(Reason.UNKNOWN.toString(), booleanDetails.getReason()); // reason should be converted to + // UNKNOWN + } + + @Test + void invalidate_cache() { + ResolveBooleanResponse booleanResponse = ResolveBooleanResponse.newBuilder() + .setValue(true) + .setVariant(BOOL_VARIANT) + .setReason(STATIC_REASON) + .build(); + + ResolveStringResponse stringResponse = ResolveStringResponse.newBuilder() + .setValue(STRING_VALUE) + .setVariant(STRING_VARIANT) + .setReason(STATIC_REASON) + .build(); + + ResolveIntResponse intResponse = ResolveIntResponse.newBuilder() + .setValue(INT_VALUE) + .setVariant(INT_VARIANT) + .setReason(STATIC_REASON) + .build(); + + ResolveFloatResponse floatResponse = ResolveFloatResponse.newBuilder() + .setValue(DOUBLE_VALUE) + .setVariant(DOUBLE_VARIANT) + .setReason(STATIC_REASON) + .build(); + + ResolveObjectResponse objectResponse = ResolveObjectResponse.newBuilder() + .setValue(PROTOBUF_STRUCTURE_VALUE) + .setVariant(OBJECT_VARIANT) + .setReason(STATIC_REASON) + .build(); + + ServiceBlockingStub serviceBlockingStubMock = mock(ServiceBlockingStub.class); + ServiceStub serviceStubMock = mock(ServiceStub.class); + when(serviceStubMock.withWaitForReady()).thenReturn(serviceStubMock); + doNothing().when(serviceStubMock).eventStream(any(), any()); + when(serviceStubMock.withDeadline(any(Deadline.class))) .thenReturn(serviceStubMock); + when(serviceBlockingStubMock.withWaitForReady()).thenReturn(serviceBlockingStubMock); + when(serviceBlockingStubMock + .withDeadline(any(Deadline.class))) + .thenReturn(serviceBlockingStubMock); + when(serviceBlockingStubMock.withDeadlineAfter(anyLong(), any(TimeUnit.class))) + .thenReturn(serviceBlockingStubMock); + when(serviceBlockingStubMock + .resolveBoolean(argThat(x -> FLAG_KEY_BOOLEAN.equals(x.getFlagKey())))) + .thenReturn(booleanResponse); + when(serviceBlockingStubMock + .resolveFloat(argThat(x -> FLAG_KEY_DOUBLE.equals(x.getFlagKey())))) + .thenReturn(floatResponse); + when(serviceBlockingStubMock + .resolveInt(argThat(x -> FLAG_KEY_INTEGER.equals(x.getFlagKey())))) + .thenReturn(intResponse); + when(serviceBlockingStubMock + .resolveString(argThat(x -> FLAG_KEY_STRING.equals(x.getFlagKey())))) + .thenReturn(stringResponse); + when(serviceBlockingStubMock + .resolveObject(argThat(x -> FLAG_KEY_OBJECT.equals(x.getFlagKey())))) + .thenReturn(objectResponse); + + GrpcConnector grpc; + try (MockedStatic mockStaticService = mockStatic(ServiceGrpc.class)) { + mockStaticService.when(() -> ServiceGrpc.newBlockingStub(any(Channel.class))) + .thenReturn(serviceBlockingStubMock); + mockStaticService.when(() -> ServiceGrpc.newStub(any())) + .thenReturn(serviceStubMock); + + final Cache cache = new Cache("lru", 5); + + class NoopInitGrpcConnector extends GrpcConnector { + public NoopInitGrpcConnector(FlagdOptions options, Cache cache, + Supplier connectedSupplier, + Consumer onConnectionEvent) { + super(options, cache, connectedSupplier, onConnectionEvent); + } + + public void initialize() throws Exception { + }; + } + + grpc = new NoopInitGrpcConnector(FlagdOptions.builder().build(), cache, () -> true, + (connectionEvent) -> { + }); + } + + FlagdProvider provider = createProvider(grpc); + OpenFeatureAPI.getInstance().setProviderAndWait(provider); + + HashMap flagsMap = new HashMap(); + HashMap structMap = new HashMap(); + + flagsMap.put(FLAG_KEY_BOOLEAN, com.google.protobuf.Value.newBuilder().setStringValue("foo").build()); + flagsMap.put(FLAG_KEY_STRING, com.google.protobuf.Value.newBuilder().setStringValue("foo").build()); + flagsMap.put(FLAG_KEY_INTEGER, com.google.protobuf.Value.newBuilder().setStringValue("foo").build()); + flagsMap.put(FLAG_KEY_DOUBLE, com.google.protobuf.Value.newBuilder().setStringValue("foo").build()); + flagsMap.put(FLAG_KEY_OBJECT, com.google.protobuf.Value.newBuilder().setStringValue("foo").build()); + + structMap.put("flags", com.google.protobuf.Value.newBuilder() + .setStructValue(Struct.newBuilder().putAllFields(flagsMap)).build()); + + // should cache results + FlagEvaluationDetails booleanDetails; + FlagEvaluationDetails stringDetails; + FlagEvaluationDetails intDetails; + FlagEvaluationDetails floatDetails; + FlagEvaluationDetails objectDetails; + + // assert cache has been invalidated + booleanDetails = api.getClient().getBooleanDetails(FLAG_KEY_BOOLEAN, false); + assertTrue(booleanDetails.getValue()); + assertEquals(BOOL_VARIANT, booleanDetails.getVariant()); + assertEquals(STATIC_REASON, booleanDetails.getReason()); + + stringDetails = api.getClient().getStringDetails(FLAG_KEY_STRING, "wrong"); + assertEquals(STRING_VALUE, stringDetails.getValue()); + assertEquals(STRING_VARIANT, stringDetails.getVariant()); + assertEquals(STATIC_REASON, stringDetails.getReason()); + + intDetails = api.getClient().getIntegerDetails(FLAG_KEY_INTEGER, 0); + assertEquals(INT_VALUE, intDetails.getValue()); + assertEquals(INT_VARIANT, intDetails.getVariant()); + assertEquals(STATIC_REASON, intDetails.getReason()); + + floatDetails = api.getClient().getDoubleDetails(FLAG_KEY_DOUBLE, 0.1); + assertEquals(DOUBLE_VALUE, floatDetails.getValue()); + assertEquals(DOUBLE_VARIANT, floatDetails.getVariant()); + assertEquals(STATIC_REASON, floatDetails.getReason()); + + objectDetails = api.getClient().getObjectDetails(FLAG_KEY_OBJECT, new Value()); + assertEquals(INNER_STRUCT_VALUE, objectDetails.getValue().asStructure() + .asMap().get(INNER_STRUCT_KEY).asString()); + assertEquals(OBJECT_VARIANT, objectDetails.getVariant()); + assertEquals(STATIC_REASON, objectDetails.getReason()); + } + + private void do_resolvers_cache_responses(String reason, Boolean eventStreamAlive, Boolean shouldCache) { + String expectedReason = CACHED_REASON; + if (!shouldCache) { + expectedReason = reason; + } - class NoopInitGrpcConnector extends GrpcConnector { - public NoopInitGrpcConnector(FlagdOptions options, Cache cache, - Supplier connectedSupplier, - BiConsumer> onResolverConnectionChanged) { - super(options, cache, connectedSupplier, onResolverConnectionChanged); + ResolveBooleanResponse booleanResponse = ResolveBooleanResponse.newBuilder() + .setValue(true) + .setVariant(BOOL_VARIANT) + .setReason(reason) + .build(); + + ResolveStringResponse stringResponse = ResolveStringResponse.newBuilder() + .setValue(STRING_VALUE) + .setVariant(STRING_VARIANT) + .setReason(reason) + .build(); + + ResolveIntResponse intResponse = ResolveIntResponse.newBuilder() + .setValue(INT_VALUE) + .setVariant(INT_VARIANT) + .setReason(reason) + .build(); + + ResolveFloatResponse floatResponse = ResolveFloatResponse.newBuilder() + .setValue(DOUBLE_VALUE) + .setVariant(DOUBLE_VARIANT) + .setReason(reason) + .build(); + + ResolveObjectResponse objectResponse = ResolveObjectResponse.newBuilder() + .setValue(PROTOBUF_STRUCTURE_VALUE) + .setVariant(OBJECT_VARIANT) + .setReason(reason) + .build(); + + ServiceBlockingStub serviceBlockingStubMock = mock(ServiceBlockingStub.class); + when(serviceBlockingStubMock.withDeadlineAfter(anyLong(), any(TimeUnit.class))) + .thenReturn(serviceBlockingStubMock); + when(serviceBlockingStubMock + .resolveBoolean(argThat(x -> FLAG_KEY_BOOLEAN.equals(x.getFlagKey())))) + .thenReturn(booleanResponse); + when(serviceBlockingStubMock + .resolveFloat(argThat(x -> FLAG_KEY_DOUBLE.equals(x.getFlagKey())))) + .thenReturn(floatResponse); + when(serviceBlockingStubMock + .resolveInt(argThat(x -> FLAG_KEY_INTEGER.equals(x.getFlagKey())))) + .thenReturn(intResponse); + when(serviceBlockingStubMock + .resolveString(argThat(x -> FLAG_KEY_STRING.equals(x.getFlagKey())))) + .thenReturn(stringResponse); + when(serviceBlockingStubMock + .resolveObject(argThat(x -> FLAG_KEY_OBJECT.equals(x.getFlagKey())))) + .thenReturn(objectResponse); + + GrpcConnector grpc = mock(GrpcConnector.class); + when(grpc.getResolver()).thenReturn(serviceBlockingStubMock); + FlagdProvider provider = createProvider(grpc, () -> eventStreamAlive); + // provider.setState(eventStreamAlive); // caching only available when event + // stream is alive + OpenFeatureAPI.getInstance().setProviderAndWait(provider); + + FlagEvaluationDetails booleanDetails = api.getClient().getBooleanDetails(FLAG_KEY_BOOLEAN, + false); + booleanDetails = api.getClient() + .getBooleanDetails(FLAG_KEY_BOOLEAN, false); // should retrieve from cache on second + // invocation + assertTrue(booleanDetails.getValue()); + assertEquals(BOOL_VARIANT, booleanDetails.getVariant()); + assertEquals(expectedReason, booleanDetails.getReason()); + + FlagEvaluationDetails stringDetails = api.getClient().getStringDetails(FLAG_KEY_STRING, + "wrong"); + stringDetails = api.getClient().getStringDetails(FLAG_KEY_STRING, "wrong"); + assertEquals(STRING_VALUE, stringDetails.getValue()); + assertEquals(STRING_VARIANT, stringDetails.getVariant()); + assertEquals(expectedReason, stringDetails.getReason()); + + FlagEvaluationDetails intDetails = api.getClient().getIntegerDetails(FLAG_KEY_INTEGER, 0); + intDetails = api.getClient().getIntegerDetails(FLAG_KEY_INTEGER, 0); + assertEquals(INT_VALUE, intDetails.getValue()); + assertEquals(INT_VARIANT, intDetails.getVariant()); + assertEquals(expectedReason, intDetails.getReason()); + + FlagEvaluationDetails floatDetails = api.getClient().getDoubleDetails(FLAG_KEY_DOUBLE, 0.1); + floatDetails = api.getClient().getDoubleDetails(FLAG_KEY_DOUBLE, 0.1); + assertEquals(DOUBLE_VALUE, floatDetails.getValue()); + assertEquals(DOUBLE_VARIANT, floatDetails.getVariant()); + assertEquals(expectedReason, floatDetails.getReason()); + + FlagEvaluationDetails objectDetails = api.getClient().getObjectDetails(FLAG_KEY_OBJECT, + new Value()); + objectDetails = api.getClient().getObjectDetails(FLAG_KEY_OBJECT, new Value()); + assertEquals(INNER_STRUCT_VALUE, objectDetails.getValue().asStructure() + .asMap().get(INNER_STRUCT_KEY).asString()); + assertEquals(OBJECT_VARIANT, objectDetails.getVariant()); + assertEquals(expectedReason, objectDetails.getReason()); + } + + @Test + void disabled_cache() { + ResolveBooleanResponse booleanResponse = ResolveBooleanResponse.newBuilder() + .setValue(true) + .setVariant(BOOL_VARIANT) + .setReason(STATIC_REASON) + .build(); + + ResolveStringResponse stringResponse = ResolveStringResponse.newBuilder() + .setValue(STRING_VALUE) + .setVariant(STRING_VARIANT) + .setReason(STATIC_REASON) + .build(); + + ResolveIntResponse intResponse = ResolveIntResponse.newBuilder() + .setValue(INT_VALUE) + .setVariant(INT_VARIANT) + .setReason(STATIC_REASON) + .build(); + + ResolveFloatResponse floatResponse = ResolveFloatResponse.newBuilder() + .setValue(DOUBLE_VALUE) + .setVariant(DOUBLE_VARIANT) + .setReason(STATIC_REASON) + .build(); + + ResolveObjectResponse objectResponse = ResolveObjectResponse.newBuilder() + .setValue(PROTOBUF_STRUCTURE_VALUE) + .setVariant(OBJECT_VARIANT) + .setReason(STATIC_REASON) + .build(); + + ServiceBlockingStub serviceBlockingStubMock = mock(ServiceBlockingStub.class); + ServiceStub serviceStubMock = mock(ServiceStub.class); + when(serviceStubMock.withWaitForReady()).thenReturn(serviceStubMock); + when(serviceStubMock.withDeadline(any(Deadline.class))) + .thenReturn(serviceStubMock); + when(serviceBlockingStubMock.withWaitForReady()).thenReturn(serviceBlockingStubMock); + when(serviceBlockingStubMock.withDeadline(any(Deadline.class))) + .thenReturn(serviceBlockingStubMock); + when(serviceBlockingStubMock.withDeadlineAfter(anyLong(), any(TimeUnit.class))) + .thenReturn(serviceBlockingStubMock); + when(serviceBlockingStubMock + .resolveBoolean(argThat(x -> FLAG_KEY_BOOLEAN.equals(x.getFlagKey())))) + .thenReturn(booleanResponse); + when(serviceBlockingStubMock + .resolveFloat(argThat(x -> FLAG_KEY_DOUBLE.equals(x.getFlagKey())))) + .thenReturn(floatResponse); + when(serviceBlockingStubMock + .resolveInt(argThat(x -> FLAG_KEY_INTEGER.equals(x.getFlagKey())))) + .thenReturn(intResponse); + when(serviceBlockingStubMock + .resolveString(argThat(x -> FLAG_KEY_STRING.equals(x.getFlagKey())))) + .thenReturn(stringResponse); + when(serviceBlockingStubMock + .resolveObject(argThat(x -> FLAG_KEY_OBJECT.equals(x.getFlagKey())))) + .thenReturn(objectResponse); + + // disabled cache + final Cache cache = new Cache("disabled", 0); + + GrpcConnector grpc; + try (MockedStatic mockStaticService = mockStatic(ServiceGrpc.class)) { + mockStaticService.when(() -> ServiceGrpc.newBlockingStub(any(Channel.class))) + .thenReturn(serviceBlockingStubMock); + mockStaticService.when(() -> ServiceGrpc.newStub(any())) + .thenReturn(serviceStubMock); + + class NoopInitGrpcConnector extends GrpcConnector { + public NoopInitGrpcConnector(FlagdOptions options, Cache cache, + Supplier connectedSupplier, + Consumer onConnectionEvent) { + super(options, cache, connectedSupplier, onConnectionEvent); + } + + public void initialize() throws Exception { + }; } - public void initialize() throws Exception { - }; + grpc = new NoopInitGrpcConnector(FlagdOptions.builder().build(), cache, () -> true, + (connectionEvent) -> { + }); } - grpc = new NoopInitGrpcConnector(FlagdOptions.builder().build(), cache, () -> true, (state, changedFlagKeys) -> { - }); + FlagdProvider provider = createProvider(grpc, cache, () -> true); + + try { + provider.initialize(null); + } catch (Exception e) { + // ignore exception if any + } + + OpenFeatureAPI.getInstance().setProviderAndWait(provider); + + HashMap flagsMap = new HashMap<>(); + HashMap structMap = new HashMap<>(); + + flagsMap.put("foo", com.google.protobuf.Value.newBuilder().setStringValue("foo") + .build()); // assert that a configuration_change event works + + structMap.put("flags", com.google.protobuf.Value.newBuilder() + .setStructValue(Struct.newBuilder().putAllFields(flagsMap)).build()); + + // should not cache results + FlagEvaluationDetails booleanDetails = api.getClient().getBooleanDetails(FLAG_KEY_BOOLEAN, + false); + FlagEvaluationDetails stringDetails = api.getClient().getStringDetails(FLAG_KEY_STRING, + "wrong"); + FlagEvaluationDetails intDetails = api.getClient().getIntegerDetails(FLAG_KEY_INTEGER, 0); + FlagEvaluationDetails floatDetails = api.getClient().getDoubleDetails(FLAG_KEY_DOUBLE, 0.1); + FlagEvaluationDetails objectDetails = api.getClient().getObjectDetails(FLAG_KEY_OBJECT, + new Value()); + + // assert values are not cached + booleanDetails = api.getClient().getBooleanDetails(FLAG_KEY_BOOLEAN, false); + assertTrue(booleanDetails.getValue()); + assertEquals(BOOL_VARIANT, booleanDetails.getVariant()); + assertEquals(STATIC_REASON, booleanDetails.getReason()); + + stringDetails = api.getClient().getStringDetails(FLAG_KEY_STRING, "wrong"); + assertEquals(STRING_VALUE, stringDetails.getValue()); + assertEquals(STRING_VARIANT, stringDetails.getVariant()); + assertEquals(STATIC_REASON, stringDetails.getReason()); + + intDetails = api.getClient().getIntegerDetails(FLAG_KEY_INTEGER, 0); + assertEquals(INT_VALUE, intDetails.getValue()); + assertEquals(INT_VARIANT, intDetails.getVariant()); + assertEquals(STATIC_REASON, intDetails.getReason()); + + floatDetails = api.getClient().getDoubleDetails(FLAG_KEY_DOUBLE, 0.1); + assertEquals(DOUBLE_VALUE, floatDetails.getValue()); + assertEquals(DOUBLE_VARIANT, floatDetails.getVariant()); + assertEquals(STATIC_REASON, floatDetails.getReason()); + + objectDetails = api.getClient().getObjectDetails(FLAG_KEY_OBJECT, new Value()); + assertEquals(INNER_STRUCT_VALUE, objectDetails.getValue().asStructure() + .asMap().get(INNER_STRUCT_KEY).asString()); + assertEquals(OBJECT_VARIANT, objectDetails.getVariant()); + assertEquals(STATIC_REASON, objectDetails.getReason()); } - FlagdProvider provider = createProvider(grpc, cache, () -> true); + @Test + void contextMerging() throws Exception { + // given + final FlagdProvider provider = new FlagdProvider(); + + final Resolver resolverMock = mock(Resolver.class); - try { - provider.initialize(null); - } catch (Exception e) { - // ignore exception if any + Field flagResolver = FlagdProvider.class.getDeclaredField("flagResolver"); + flagResolver.setAccessible(true); + flagResolver.set(provider, resolverMock); + + final HashMap globalCtxMap = new HashMap<>(); + globalCtxMap.put("id", new Value("GlobalID")); + globalCtxMap.put("env", new Value("A")); + + final HashMap localCtxMap = new HashMap<>(); + localCtxMap.put("id", new Value("localID")); + localCtxMap.put("client", new Value("999")); + + final HashMap expectedCtx = new HashMap<>(); + expectedCtx.put("id", new Value("localID")); + expectedCtx.put("env", new Value("A")); + localCtxMap.put("client", new Value("999")); + + // when + provider.initialize(new ImmutableContext(globalCtxMap)); + provider.getBooleanEvaluation("ket", false, new ImmutableContext(localCtxMap)); + + // then + verify(resolverMock).booleanEvaluation(any(), any(), argThat( + ctx -> ctx.asMap().entrySet().containsAll(expectedCtx.entrySet()))); } - OpenFeatureAPI.getInstance().setProviderAndWait(provider); + @Test + void initializationAndShutdown() throws Exception { + // given + final FlagdProvider provider = new FlagdProvider(); + final EvaluationContext ctx = new ImmutableContext(); + + final Resolver resolverMock = mock(Resolver.class); + + Field flagResolver = FlagdProvider.class.getDeclaredField("flagResolver"); + flagResolver.setAccessible(true); + flagResolver.set(provider, resolverMock); + + // when + + // validate multiple initialization + provider.initialize(ctx); + provider.initialize(ctx); + + // validate multiple shutdowns + provider.shutdown(); + provider.shutdown(); - HashMap flagsMap = new HashMap<>(); - HashMap structMap = new HashMap<>(); - - flagsMap.put("foo", com.google.protobuf.Value.newBuilder().setStringValue("foo") - .build()); // assert that a configuration_change event works + // then + verify(resolverMock, times(1)).init(); + verify(resolverMock, times(1)).shutdown(); + } - structMap.put("flags", com.google.protobuf.Value.newBuilder() - .setStructValue(Struct.newBuilder().putAllFields(flagsMap)).build()); - - // should not cache results - FlagEvaluationDetails booleanDetails = api.getClient().getBooleanDetails(FLAG_KEY_BOOLEAN, false); - FlagEvaluationDetails stringDetails = api.getClient().getStringDetails(FLAG_KEY_STRING, "wrong"); - FlagEvaluationDetails intDetails = api.getClient().getIntegerDetails(FLAG_KEY_INTEGER, 0); - FlagEvaluationDetails floatDetails = api.getClient().getDoubleDetails(FLAG_KEY_DOUBLE, 0.1); - FlagEvaluationDetails objectDetails = api.getClient().getObjectDetails(FLAG_KEY_OBJECT, new Value()); + @Test + void updatesSyncMetadataWithCallback() throws Exception { + + final EvaluationContext ctx = new ImmutableContext(); + String key = "key1"; + String val = "val1"; + Map metadata = new HashMap<>(); + metadata.put(key, val); + + // mock a resolver + try (MockedConstruction mockResolver = mockConstruction(GrpcResolver.class, + (mock, context) -> { + Consumer onConnectionEvent; + + // get a reference to the onConnectionEvent callback + onConnectionEvent = (Consumer) context + .arguments().get(3); + + // when our mock resolver initializes, it runs the passed onConnectionEvent + // callback + doAnswer(invocation -> { + onConnectionEvent.accept( + new ConnectionEvent(true, metadata)); + return null; + }).when(mock).init(); + })) { + + FlagdProvider provider = new FlagdProvider(); + provider.initialize(ctx); + + // the onConnectionEvent should have updated the sync metadata + assertEquals(val, provider.getSyncMetadata().get(key)); + } + } - // assert values are not cached - booleanDetails = api.getClient().getBooleanDetails(FLAG_KEY_BOOLEAN, false); - assertTrue(booleanDetails.getValue()); - assertEquals(BOOL_VARIANT, booleanDetails.getVariant()); - assertEquals(STATIC_REASON, booleanDetails.getReason()); - - stringDetails = api.getClient().getStringDetails(FLAG_KEY_STRING, "wrong"); - assertEquals(STRING_VALUE, stringDetails.getValue()); - assertEquals(STRING_VARIANT, stringDetails.getVariant()); - assertEquals(STATIC_REASON, stringDetails.getReason()); + // test helper + + // create provider with given grpc connector + private FlagdProvider createProvider(GrpcConnector grpc) { + return createProvider(grpc, () -> true); + } - intDetails = api.getClient().getIntegerDetails(FLAG_KEY_INTEGER, 0); - assertEquals(INT_VALUE, intDetails.getValue()); - assertEquals(INT_VARIANT, intDetails.getVariant()); - assertEquals(STATIC_REASON, intDetails.getReason()); + // create provider with given grpc provider and state supplier + private FlagdProvider createProvider(GrpcConnector grpc, Supplier getConnected) { + final Cache cache = new Cache("lru", 5); - floatDetails = api.getClient().getDoubleDetails(FLAG_KEY_DOUBLE, 0.1); - assertEquals(DOUBLE_VALUE, floatDetails.getValue()); - assertEquals(DOUBLE_VARIANT, floatDetails.getVariant()); - assertEquals(STATIC_REASON, floatDetails.getReason()); - - objectDetails = api.getClient().getObjectDetails(FLAG_KEY_OBJECT, new Value()); - assertEquals(INNER_STRUCT_VALUE, objectDetails.getValue().asStructure() - .asMap().get(INNER_STRUCT_KEY).asString()); - assertEquals(OBJECT_VARIANT, objectDetails.getVariant()); - assertEquals(STATIC_REASON, objectDetails.getReason()); - } - - @Test - void contextMerging() throws Exception { - // given - final FlagdProvider provider = new FlagdProvider(); - - final Resolver resolverMock = mock(Resolver.class); - - Field flagResolver = FlagdProvider.class.getDeclaredField("flagResolver"); - flagResolver.setAccessible(true); - flagResolver.set(provider, resolverMock); - - final HashMap globalCtxMap = new HashMap<>(); - globalCtxMap.put("id", new Value("GlobalID")); - globalCtxMap.put("env", new Value("A")); - - final HashMap localCtxMap = new HashMap<>(); - localCtxMap.put("id", new Value("localID")); - localCtxMap.put("client", new Value("999")); - - final HashMap expectedCtx = new HashMap<>(); - expectedCtx.put("id", new Value("localID")); - expectedCtx.put("env", new Value("A")); - localCtxMap.put("client", new Value("999")); - - // when - provider.initialize(new ImmutableContext(globalCtxMap)); - provider.getBooleanEvaluation("ket", false, new ImmutableContext(localCtxMap)); - - // then - verify(resolverMock).booleanEvaluation(any(), any(), argThat( - ctx -> ctx.asMap().entrySet().containsAll(expectedCtx.entrySet()))); - } - - @Test - void initializationAndShutdown() throws Exception { - // given - final FlagdProvider provider = new FlagdProvider(); - final EvaluationContext ctx = new ImmutableContext(); - - final Resolver resolverMock = mock(Resolver.class); - - Field flagResolver = FlagdProvider.class.getDeclaredField("flagResolver"); - flagResolver.setAccessible(true); - flagResolver.set(provider, resolverMock); - - // when - - // validate multiple initialization - provider.initialize(ctx); - provider.initialize(ctx); - - // validate multiple shutdowns - provider.shutdown(); - provider.shutdown(); - - // then - verify(resolverMock, times(1)).init(); - verify(resolverMock, times(1)).shutdown(); - } - - // test helper - - // create provider with given grpc connector - private FlagdProvider createProvider(GrpcConnector grpc) { - return createProvider(grpc, () -> true); - } - - // create provider with given grpc provider and state supplier - private FlagdProvider createProvider(GrpcConnector grpc, Supplier getConnected) { - final Cache cache = new Cache("lru", 5); - - return createProvider(grpc, cache, getConnected); - } - - // create provider with given grpc provider, cache and state supplier - private FlagdProvider createProvider(GrpcConnector grpc, Cache cache, Supplier getConnected) { - final FlagdOptions flagdOptions = FlagdOptions.builder().build(); - final GrpcResolver grpcResolver = new GrpcResolver(flagdOptions, cache, getConnected, - (providerState, changedFlagKeys) -> { - }); - - final FlagdProvider provider = new FlagdProvider(); - - try { - Field connector = GrpcResolver.class.getDeclaredField("connector"); - connector.setAccessible(true); - connector.set(grpcResolver, grpc); - - Field flagResolver = FlagdProvider.class.getDeclaredField("flagResolver"); - flagResolver.setAccessible(true); - flagResolver.set(provider, grpcResolver); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new RuntimeException(e); + return createProvider(grpc, cache, getConnected); } - return provider; - } - - // Create an in process provider - private FlagdProvider createInProcessProvider() { - - final FlagdOptions flagdOptions = FlagdOptions.builder() - .resolverType(Config.Resolver.IN_PROCESS) - .deadline(1000) - .build(); - final FlagdProvider provider = new FlagdProvider(flagdOptions); - final MockStorage mockStorage = new MockStorage(new HashMap(), - new LinkedBlockingQueue(Arrays.asList(new StorageStateChange(StorageState.OK)))); - - try { - final Field flagResolver = FlagdProvider.class.getDeclaredField("flagResolver"); - flagResolver.setAccessible(true); - final Resolver resolver = (Resolver) flagResolver.get(provider); - - final Field flagStore = InProcessResolver.class.getDeclaredField("flagStore"); - flagStore.setAccessible(true); - flagStore.set(resolver, mockStorage); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new RuntimeException(e); + // create provider with given grpc provider, cache and state supplier + private FlagdProvider createProvider(GrpcConnector grpc, Cache cache, Supplier getConnected) { + final FlagdOptions flagdOptions = FlagdOptions.builder().build(); + final GrpcResolver grpcResolver = new GrpcResolver(flagdOptions, cache, getConnected, + (connectionEvent) -> { + }); + + final FlagdProvider provider = new FlagdProvider(); + + try { + Field connector = GrpcResolver.class.getDeclaredField("connector"); + connector.setAccessible(true); + connector.set(grpcResolver, grpc); + + Field flagResolver = FlagdProvider.class.getDeclaredField("flagResolver"); + flagResolver.setAccessible(true); + flagResolver.set(provider, grpcResolver); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + + return provider; } - return provider; - } + // Create an in process provider + private FlagdProvider createInProcessProvider() { + + final FlagdOptions flagdOptions = FlagdOptions.builder() + .resolverType(Config.Resolver.IN_PROCESS) + .deadline(1000) + .build(); + final FlagdProvider provider = new FlagdProvider(flagdOptions); + final MockStorage mockStorage = new MockStorage(new HashMap(), + new LinkedBlockingQueue( + Arrays.asList(new StorageStateChange(StorageState.OK)))); + + try { + final Field flagResolver = FlagdProvider.class.getDeclaredField("flagResolver"); + flagResolver.setAccessible(true); + final Resolver resolver = (Resolver) flagResolver.get(provider); + + final Field flagStore = InProcessResolver.class.getDeclaredField("flagStore"); + flagStore.setAccessible(true); + flagStore.set(resolver, mockStorage); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + + return provider; + } } diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/process/FlagdInProcessSetup.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/process/FlagdInProcessSetup.java index a101a631d..6217e4830 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/process/FlagdInProcessSetup.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/process/FlagdInProcessSetup.java @@ -26,6 +26,7 @@ public static void setup() throws InterruptedException { flagdContainer.start(); FlagdInProcessSetup.provider = new FlagdProvider(FlagdOptions.builder() .resolverType(Config.Resolver.IN_PROCESS) + // set a generous deadline, to prevent timeouts in actions .deadline(3000) .port(flagdContainer.getFirstMappedPort()) .build()); diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/reconnect/process/FlagdInProcessSetup.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/reconnect/process/FlagdInProcessSetup.java index 3ad3ac5f4..1140c04c7 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/reconnect/process/FlagdInProcessSetup.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/reconnect/process/FlagdInProcessSetup.java @@ -23,8 +23,9 @@ public static void setup() throws InterruptedException { flagdContainer.start(); FeatureProvider workingProvider = new FlagdProvider(FlagdOptions.builder() .resolverType(Config.Resolver.IN_PROCESS) - .deadline(3000) .port(flagdContainer.getFirstMappedPort()) + // set a generous deadline, to prevent timeouts in actions + .deadline(3000) .build()); StepDefinitions.setUnstableProvider(workingProvider); diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/reconnect/rpc/FlagdRpcSetup.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/reconnect/rpc/FlagdRpcSetup.java index 945baa3cc..e323cfc7c 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/reconnect/rpc/FlagdRpcSetup.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/reconnect/rpc/FlagdRpcSetup.java @@ -37,7 +37,6 @@ public static void setup() throws InterruptedException { FeatureProvider unavailableProvider = new FlagdProvider(FlagdOptions.builder() .resolverType(Config.Resolver.RPC) .port(8015) // this port isn't serving anything, error expected - // set a generous deadline, to prevent timeouts in actions .deadline(100) .cacheType(CacheType.DISABLED.getValue()) .build()); diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/EventStreamObserverTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/EventStreamObserverTest.java index d07c03259..525ee23f1 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/EventStreamObserverTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/EventStreamObserverTest.java @@ -93,7 +93,7 @@ public void cacheBustingForKnownKeys() { Value flagsValue = mock(Value.class); Struct flagsStruct = mock(Struct.class); HashMap fields = new HashMap<>(); - fields.put(EventStreamObserver.FLAGS_KEY, flagsValue); + fields.put(Constants.FLAGS_KEY, flagsValue); HashMap flags = new HashMap<>(); flags.put(key1, null); flags.put(key2, null); diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcConnectorTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcConnectorTest.java index 3325341ed..59e7b6897 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcConnectorTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcConnectorTest.java @@ -18,7 +18,7 @@ import static org.mockito.Mockito.when; import java.lang.reflect.Field; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledOnOs; @@ -27,9 +27,12 @@ import org.junit.jupiter.params.provider.ValueSource; import org.mockito.MockedConstruction; import org.mockito.MockedStatic; +import org.mockito.invocation.InvocationOnMock; import dev.openfeature.contrib.providers.flagd.FlagdOptions; +import dev.openfeature.contrib.providers.flagd.resolver.common.ConnectionEvent; import dev.openfeature.contrib.providers.flagd.resolver.grpc.cache.Cache; +import dev.openfeature.flagd.grpc.evaluation.Evaluation.EventStreamResponse; import dev.openfeature.flagd.grpc.evaluation.ServiceGrpc; import dev.openfeature.flagd.grpc.evaluation.ServiceGrpc.ServiceBlockingStub; import dev.openfeature.flagd.grpc.evaluation.ServiceGrpc.ServiceStub; @@ -43,7 +46,7 @@ public class GrpcConnectorTest { @ParameterizedTest - @ValueSource(ints = {1, 2, 3}) + @ValueSource(ints = { 1, 2, 3 }) void validate_retry_calls(int retries) throws NoSuchFieldException, IllegalAccessException { final int backoffMs = 100; @@ -58,8 +61,9 @@ void validate_retry_calls(int retries) throws NoSuchFieldException, IllegalAcces final ServiceGrpc.ServiceStub mockStub = mock(ServiceGrpc.ServiceStub.class); doAnswer(invocation -> null).when(mockStub).eventStream(any(), any()); - final GrpcConnector connector = new GrpcConnector(options, cache, () -> true, (state,changedFlagKeys) -> { - }); + final GrpcConnector connector = new GrpcConnector(options, cache, () -> true, + (connectionEvent) -> { + }); Field serviceStubField = GrpcConnector.class.getDeclaredField("serviceStub"); serviceStubField.setAccessible(true); @@ -90,29 +94,69 @@ void validate_retry_calls(int retries) throws NoSuchFieldException, IllegalAcces @Test void initialization_succeed_with_connected_status() throws NoSuchFieldException, IllegalAccessException { final Cache cache = new Cache("disabled", 0); - final ServiceGrpc.ServiceStub mockStub = mock(ServiceGrpc.ServiceStub.class); - doAnswer(invocation -> null).when(mockStub).eventStream(any(), any()); + Consumer onConnectionEvent = mock(Consumer.class); + doAnswer((InvocationOnMock invocation) -> { + EventStreamObserver eventStreamObserver = (EventStreamObserver) invocation.getArgument(1); + eventStreamObserver + .onNext(EventStreamResponse.newBuilder().setType(Constants.PROVIDER_READY).build()); + return null; + }).when(mockStub).eventStream(any(), any()); - // pass true in connected lambda - final GrpcConnector connector = new GrpcConnector(FlagdOptions.builder().build(), cache, () -> true, (state, changedFlagKeys) -> { - }); + try (MockedStatic mockStaticService = mockStatic(ServiceGrpc.class)) { + mockStaticService.when(() -> ServiceGrpc.newStub(any())) + .thenReturn(mockStub); + + // pass true in connected lambda + final GrpcConnector connector = new GrpcConnector(FlagdOptions.builder().build(), cache, () -> { + try { + Thread.sleep(100); + return true; + } catch (Exception e) { + } + return false; - assertDoesNotThrow(connector::initialize); + }, + onConnectionEvent); + + assertDoesNotThrow(connector::initialize); + // assert that onConnectionEvent is connected + verify(onConnectionEvent).accept(argThat(arg -> arg.isConnected())); + } } @Test void initialization_fail_with_timeout() throws Exception { final Cache cache = new Cache("disabled", 0); - final ServiceGrpc.ServiceStub mockStub = mock(ServiceGrpc.ServiceStub.class); - doAnswer(invocation -> null).when(mockStub).eventStream(any(), any()); + Consumer onConnectionEvent = mock(Consumer.class); + doAnswer((InvocationOnMock invocation) -> { + EventStreamObserver eventStreamObserver = (EventStreamObserver) invocation.getArgument(1); + eventStreamObserver + .onError(new Exception("fake")); + return null; + }).when(mockStub).eventStream(any(), any()); - // pass false in connected lambda - final GrpcConnector connector = new GrpcConnector(FlagdOptions.builder().build(), cache, () -> false, (state, changedFlagKeys) -> { - }); + try (MockedStatic mockStaticService = mockStatic(ServiceGrpc.class)) { + mockStaticService.when(() -> ServiceGrpc.newStub(any())) + .thenReturn(mockStub); - assertThrows(RuntimeException.class, connector::initialize); + // pass true in connected lambda + final GrpcConnector connector = new GrpcConnector(FlagdOptions.builder().build(), cache, () -> { + try { + Thread.sleep(100); + return true; + } catch (Exception e) { + } + return false; + + }, + onConnectionEvent); + + assertDoesNotThrow(connector::initialize); + // assert that onConnectionEvent is connected + verify(onConnectionEvent).accept(argThat(arg -> !arg.isConnected())); + } } @Test @@ -170,17 +214,16 @@ void no_args_host_and_port_env_set_should_build_tcp_socket() throws Exception { new GrpcConnector(FlagdOptions.builder().build(), null, null, null); // verify host/port matches & called times(= 1 as we rely on reusable channel) - mockStaticChannelBuilder.verify(() -> NettyChannelBuilder. - forAddress(host, port), times(1)); + mockStaticChannelBuilder.verify(() -> NettyChannelBuilder.forAddress(host, port), times(1)); } } }); } - /** - * OS Specific test - This test is valid only on Linux system as it rely on epoll availability - * */ + * OS Specific test - This test is valid only on Linux system as it rely on + * epoll availability + */ @Test @EnabledOnOs(OS.LINUX) void path_arg_should_build_domain_socket_with_correct_path() { @@ -218,8 +261,9 @@ void path_arg_should_build_domain_socket_with_correct_path() { } /** - * OS Specific test - This test is valid only on Linux system as it rely on epoll availability - * */ + * OS Specific test - This test is valid only on Linux system as it rely on + * epoll availability + */ @Test @EnabledOnOs(OS.LINUX) void no_args_socket_env_should_build_domain_socket_with_correct_path() throws Exception { @@ -249,7 +293,7 @@ void no_args_socket_env_should_build_domain_socket_with_correct_path() throws Ex new GrpcConnector(FlagdOptions.builder().build(), null, null, null); - //verify path matches & called times(= 1 as we rely on reusable channel) + // verify path matches & called times(= 1 as we rely on reusable channel) mockStaticChannelBuilder.verify(() -> NettyChannelBuilder .forAddress(argThat((DomainSocketAddress d) -> { return d.path() == path; 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 f3c2a5773..b66c65d3c 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 @@ -2,6 +2,7 @@ import dev.openfeature.contrib.providers.flagd.Config; import dev.openfeature.contrib.providers.flagd.FlagdOptions; +import dev.openfeature.contrib.providers.flagd.resolver.common.ConnectionEvent; import dev.openfeature.contrib.providers.flagd.resolver.process.model.FeatureFlag; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.MockConnector; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageState; @@ -19,6 +20,7 @@ import dev.openfeature.sdk.exceptions.FlagNotFoundError; import dev.openfeature.sdk.exceptions.ParseError; import dev.openfeature.sdk.exceptions.TypeMismatchError; + import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -32,6 +34,7 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; +import java.util.function.Consumer; import static dev.openfeature.contrib.providers.flagd.resolver.process.MockFlags.BOOLEAN_FLAG; import static dev.openfeature.contrib.providers.flagd.resolver.process.MockFlags.DISABLED_FLAG; @@ -73,10 +76,16 @@ public void eventHandling() throws Throwable { // given // note - queues with adequate capacity final BlockingQueue sender = new LinkedBlockingQueue<>(5); - final BlockingQueue receiver = new LinkedBlockingQueue<>(5); + final BlockingQueue receiver = new LinkedBlockingQueue<>(5); + final String key = "key1"; + final String val = "val1"; + final Map syncMetadata = new HashMap<>(); + syncMetadata.put(key, val); InProcessResolver inProcessResolver = getInProcessResolverWth(new MockStorage(new HashMap<>(), sender), - (connectedState, changedFlagKeys) -> receiver.offer(connectedState)); + (connectionEvent) -> receiver.offer(new StorageStateChange( + connectionEvent.isConnected() ? StorageState.OK : StorageState.ERROR, + connectionEvent.getFlagsChanged(), connectionEvent.getSyncMetadata()))); // when - init and emit events Thread initThread = new Thread(() -> { @@ -86,7 +95,7 @@ public void eventHandling() throws Throwable { } }); initThread.start(); - if (!sender.offer(new StorageStateChange(StorageState.OK, Collections.EMPTY_LIST), 100, + if (!sender.offer(new StorageStateChange(StorageState.OK, Collections.emptyList(), syncMetadata), 100, TimeUnit.MILLISECONDS)) { Assertions.fail("failed to send the event"); } @@ -96,11 +105,13 @@ public void eventHandling() throws Throwable { // then - receive events in order assertTimeoutPreemptively(Duration.ofMillis(200), () -> { - Assertions.assertTrue(receiver.take()); + StorageStateChange storageState = receiver.take(); + assertEquals(StorageState.OK, storageState.getStorageState()); + assertEquals(val, storageState.getSyncMetadata().get(key)); }); assertTimeoutPreemptively(Duration.ofMillis(200), () -> { - Assertions.assertFalse(receiver.take()); + assertEquals(StorageState.ERROR, receiver.take().getStorageState()); }); } @@ -111,7 +122,7 @@ public void simpleBooleanResolving() throws Exception { flagMap.put("booleanFlag", BOOLEAN_FLAG); InProcessResolver inProcessResolver = getInProcessResolverWth(new MockStorage(flagMap), - (providerState, changedFlagKeys) -> { + (connectionEvent) -> { }); // when @@ -132,7 +143,7 @@ public void simpleDoubleResolving() throws Exception { flagMap.put("doubleFlag", DOUBLE_FLAG); InProcessResolver inProcessResolver = getInProcessResolverWth(new MockStorage(flagMap), - (providerState, changedFlagKeys) -> { + (connectionEvent) -> { }); // when @@ -152,7 +163,7 @@ public void fetchIntegerAsDouble() throws Exception { flagMap.put("doubleFlag", DOUBLE_FLAG); InProcessResolver inProcessResolver = getInProcessResolverWth(new MockStorage(flagMap), - (providerState, changedFlagKeys) -> { + (connectionEvent) -> { }); // when @@ -172,7 +183,7 @@ public void fetchDoubleAsInt() throws Exception { flagMap.put("integerFlag", INT_FLAG); InProcessResolver inProcessResolver = getInProcessResolverWth(new MockStorage(flagMap), - (providerState, changedFlagKeys) -> { + (connectionEvent) -> { }); // when @@ -192,7 +203,7 @@ public void simpleIntResolving() throws Exception { flagMap.put("integerFlag", INT_FLAG); InProcessResolver inProcessResolver = getInProcessResolverWth(new MockStorage(flagMap), - (providerState, changedFlagKeys) -> { + (connectionEvent) -> { }); // when @@ -212,7 +223,7 @@ public void simpleObjectResolving() throws Exception { flagMap.put("objectFlag", OBJECT_FLAG); InProcessResolver inProcessResolver = getInProcessResolverWth(new MockStorage(flagMap), - (providerState, changedFlagKeys) -> { + (connectionEvent) -> { }); Map typeDefault = new HashMap<>(); @@ -239,7 +250,7 @@ public void missingFlag() throws Exception { final Map flagMap = new HashMap<>(); InProcessResolver inProcessResolver = getInProcessResolverWth(new MockStorage(flagMap), - (providerState, changedFlagKeys) -> { + (connectionEvent) -> { }); // when/then @@ -255,7 +266,7 @@ public void disabledFlag() throws Exception { flagMap.put("disabledFlag", DISABLED_FLAG); InProcessResolver inProcessResolver = getInProcessResolverWth(new MockStorage(flagMap), - (providerState, changedFlagKeys) -> { + (connectionEvent) -> { }); // when/then @@ -271,7 +282,7 @@ public void variantMismatchFlag() throws Exception { flagMap.put("mismatchFlag", VARIANT_MISMATCH_FLAG); InProcessResolver inProcessResolver = getInProcessResolverWth(new MockStorage(flagMap), - (providerState, changedFlagKeys) -> { + (connectionEvent) -> { }); // when/then @@ -287,7 +298,7 @@ public void typeMismatchEvaluation() throws Exception { flagMap.put("stringFlag", BOOLEAN_FLAG); InProcessResolver inProcessResolver = getInProcessResolverWth(new MockStorage(flagMap), - (providerState, changedFlagKeys) -> { + (connectionEvent) -> { }); // when/then @@ -303,7 +314,7 @@ public void booleanShorthandEvaluation() throws Exception { flagMap.put("shorthand", FLAG_WIH_SHORTHAND_TARGETING); InProcessResolver inProcessResolver = getInProcessResolverWth(new MockStorage(flagMap), - (providerState, changedFlagKeys) -> { + (connectionEvent) -> { }); ProviderEvaluation providerEvaluation = inProcessResolver.booleanEvaluation("shorthand", false, @@ -322,7 +333,7 @@ public void targetingMatchedEvaluationFlag() throws Exception { flagMap.put("stringFlag", FLAG_WIH_IF_IN_TARGET); InProcessResolver inProcessResolver = getInProcessResolverWth(new MockStorage(flagMap), - (providerState, changedFlagKeys) -> { + (connectionEvent) -> { }); // when @@ -343,7 +354,7 @@ public void targetingUnmatchedEvaluationFlag() throws Exception { flagMap.put("stringFlag", FLAG_WIH_IF_IN_TARGET); InProcessResolver inProcessResolver = getInProcessResolverWth(new MockStorage(flagMap), - (providerState, changedFlagKeys) -> { + (connectionEvent) -> { }); // when @@ -364,7 +375,7 @@ public void explicitTargetingKeyHandling() throws NoSuchFieldException, IllegalA flagMap.put("stringFlag", FLAG_WITH_TARGETING_KEY); InProcessResolver inProcessResolver = getInProcessResolverWth(new MockStorage(flagMap), - (providerState, changedFlagKeys) -> { + (connectionEvent) -> { }); // when @@ -384,7 +395,7 @@ public void targetingErrorEvaluationFlag() throws Exception { flagMap.put("targetingErrorFlag", FLAG_WIH_INVALID_TARGET); InProcessResolver inProcessResolver = getInProcessResolverWth(new MockStorage(flagMap), - (providerState, changedFlagKeys) -> { + (connectionEvent) -> { }); // when/then @@ -419,17 +430,17 @@ private InProcessResolver getInProcessResolverWth(final FlagdOptions options, fi throws NoSuchFieldException, IllegalAccessException { final InProcessResolver resolver = new InProcessResolver(options, () -> true, - (providerState, changedFlagKeys) -> { + (connectionEvent) -> { }); return injectFlagStore(resolver, storage); } private InProcessResolver getInProcessResolverWth(final MockStorage storage, - final BiConsumer> stateConsumer) + final Consumer onConnectionEvent) throws NoSuchFieldException, IllegalAccessException { final InProcessResolver resolver = new InProcessResolver( - FlagdOptions.builder().deadline(1000).build(), () -> true, stateConsumer); + FlagdOptions.builder().deadline(1000).build(), () -> true, onConnectionEvent); return injectFlagStore(resolver, storage); } 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 3e69d48c5..571ffdb5e 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 @@ -1,11 +1,11 @@ package dev.openfeature.contrib.providers.flagd.resolver.process.storage; -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.storage.connector.StreamPayload; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.StreamPayloadType; -import org.junit.Assert; -import org.junit.jupiter.api.Test; +import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.INVALID_FLAG; +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.getFlagsFromResource; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import java.time.Duration; import java.util.HashSet; @@ -14,20 +14,22 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.stream.Collectors; -import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.INVALID_FLAG; -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.getFlagsFromResource; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; +import org.junit.Assert; +import org.junit.jupiter.api.Test; + +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.storage.connector.QueuePayload; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayloadType; +import dev.openfeature.flagd.grpc.sync.Sync.GetMetadataResponse; class FlagStoreTest { @Test public void connectorHandling() throws Exception { - final int maxDelay = 500; + final int maxDelay = 1000; - final BlockingQueue payload = new LinkedBlockingQueue<>(); + final BlockingQueue payload = new LinkedBlockingQueue<>(); FlagStore store = new FlagStore(new MockConnector(payload), true); store.init(); @@ -35,7 +37,7 @@ public void connectorHandling() throws Exception { // OK for simple flag assertTimeoutPreemptively(Duration.ofMillis(maxDelay), ()-> { - payload.offer(new StreamPayload(StreamPayloadType.DATA, getFlagsFromResource(VALID_SIMPLE))); + payload.offer(new QueuePayload(QueuePayloadType.DATA, getFlagsFromResource(VALID_SIMPLE), GetMetadataResponse.getDefaultInstance())); }); assertTimeoutPreemptively(Duration.ofMillis(maxDelay), ()-> { @@ -44,7 +46,7 @@ public void connectorHandling() throws Exception { // STALE for invalid flag assertTimeoutPreemptively(Duration.ofMillis(maxDelay), ()-> { - payload.offer(new StreamPayload(StreamPayloadType.DATA, getFlagsFromResource(INVALID_FLAG))); + payload.offer(new QueuePayload(QueuePayloadType.DATA, getFlagsFromResource(INVALID_FLAG), GetMetadataResponse.getDefaultInstance())); }); assertTimeoutPreemptively(Duration.ofMillis(maxDelay), ()-> { @@ -53,7 +55,7 @@ public void connectorHandling() throws Exception { // OK again for next payload assertTimeoutPreemptively(Duration.ofMillis(maxDelay), ()-> { - payload.offer(new StreamPayload(StreamPayloadType.DATA, getFlagsFromResource(VALID_LONG))); + payload.offer(new QueuePayload(QueuePayloadType.DATA, getFlagsFromResource(VALID_LONG), GetMetadataResponse.getDefaultInstance())); }); assertTimeoutPreemptively(Duration.ofMillis(maxDelay), ()-> { @@ -62,7 +64,7 @@ public void connectorHandling() throws Exception { // ERROR is propagated correctly assertTimeoutPreemptively(Duration.ofMillis(maxDelay), ()-> { - payload.offer(new StreamPayload(StreamPayloadType.ERROR, null)); + payload.offer(new QueuePayload(QueuePayloadType.ERROR, null, GetMetadataResponse.getDefaultInstance())); }); assertTimeoutPreemptively(Duration.ofMillis(maxDelay), ()-> { @@ -80,13 +82,13 @@ public void connectorHandling() throws Exception { @Test public void changedFlags() throws Exception { final int maxDelay = 500; - final BlockingQueue payload = new LinkedBlockingQueue<>(); + final BlockingQueue payload = new LinkedBlockingQueue<>(); FlagStore store = new FlagStore(new MockConnector(payload), true); store.init(); final BlockingQueue storageStateDTOS = store.getStateQueue(); assertTimeoutPreemptively(Duration.ofMillis(maxDelay), ()-> { - payload.offer(new StreamPayload(StreamPayloadType.DATA, getFlagsFromResource(VALID_SIMPLE))); + payload.offer(new QueuePayload(QueuePayloadType.DATA, getFlagsFromResource(VALID_SIMPLE), GetMetadataResponse.getDefaultInstance())); }); // flags changed for first time assertEquals(FlagParser.parseString( @@ -94,7 +96,7 @@ public void changedFlags() throws Exception { storageStateDTOS.take().getChangedFlagsKeys()); assertTimeoutPreemptively(Duration.ofMillis(maxDelay), ()-> { - payload.offer(new StreamPayload(StreamPayloadType.DATA, getFlagsFromResource(VALID_LONG))); + payload.offer(new QueuePayload(QueuePayloadType.DATA, getFlagsFromResource(VALID_LONG), GetMetadataResponse.getDefaultInstance())); }); Map expectedChangedFlags = FlagParser.parseString(getFlagsFromResource(VALID_LONG),true); diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/MockConnector.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/MockConnector.java index 495b69778..e94d7ed3f 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/MockConnector.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/MockConnector.java @@ -1,18 +1,19 @@ package dev.openfeature.contrib.providers.flagd.resolver.process.storage; +import java.util.concurrent.BlockingQueue; + import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.Connector; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.StreamPayload; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.StreamPayloadType; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayloadType; +import dev.openfeature.flagd.grpc.sync.Sync.GetMetadataResponse; import lombok.extern.slf4j.Slf4j; -import java.util.concurrent.BlockingQueue; - @Slf4j public class MockConnector implements Connector { - private BlockingQueue mockQueue; + private BlockingQueue mockQueue; - public MockConnector(final BlockingQueue mockQueue) { + public MockConnector(final BlockingQueue mockQueue) { this.mockQueue = mockQueue; } @@ -20,13 +21,13 @@ public void init() { // no-op } - public BlockingQueue getStream() { + public BlockingQueue getStream() { return mockQueue; } public void shutdown() { // Emit error mocking closed connection scenario - if (!mockQueue.offer(new StreamPayload(StreamPayloadType.ERROR, "shutdown invoked"))) { + if (!mockQueue.offer(new QueuePayload(QueuePayloadType.ERROR, "shutdown invoked", GetMetadataResponse.getDefaultInstance()))) { log.warn("Failed to offer shutdown status"); } } diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/file/FileConnectorTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/file/FileConnectorTest.java index 0f3b2f379..a2ad795c4 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/file/FileConnectorTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/file/FileConnectorTest.java @@ -1,7 +1,7 @@ package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.file; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.StreamPayload; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.StreamPayloadType; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayloadType; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -31,16 +31,16 @@ void readAndExposeFeatureFlagsFromSource() throws IOException { connector.init(); // then - final BlockingQueue stream = connector.getStream(); - final StreamPayload[] payload = new StreamPayload[1]; + final BlockingQueue stream = connector.getStream(); + final QueuePayload[] payload = new QueuePayload[1]; assertNotNull(stream); assertTimeoutPreemptively(Duration.ofMillis(200), () -> { payload[0] = stream.take(); }); - assertNotNull(payload[0].getData()); - assertEquals(StreamPayloadType.DATA, payload[0].getType()); + assertNotNull(payload[0].getFlagData()); + assertEquals(QueuePayloadType.DATA, payload[0].getType()); } @Test @@ -52,16 +52,16 @@ void emitErrorStateForInvalidPath() throws IOException { connector.init(); // then - final BlockingQueue stream = connector.getStream(); + final BlockingQueue stream = connector.getStream(); // Must emit an error within considerable time - final StreamPayload[] payload = new StreamPayload[1]; + final QueuePayload[] payload = new QueuePayload[1]; assertTimeoutPreemptively(Duration.ofMillis(200), () -> { payload[0] = stream.take(); }); - assertNotNull(payload[0].getData()); - assertEquals(StreamPayloadType.ERROR, payload[0].getType()); + assertNotNull(payload[0].getFlagData()); + assertEquals(QueuePayloadType.ERROR, payload[0].getType()); } @Test @@ -80,15 +80,15 @@ void watchForFileUpdatesAndEmitThem() throws IOException { connector.init(); // then - final BlockingQueue stream = connector.getStream(); - final StreamPayload[] payload = new StreamPayload[1]; + final BlockingQueue stream = connector.getStream(); + final QueuePayload[] payload = new QueuePayload[1]; // first validate the initial payload assertTimeoutPreemptively(Duration.ofMillis(200), () -> { payload[0] = stream.take(); }); - assertEquals(initial, payload[0].getData()); + assertEquals(initial, payload[0].getFlagData()); // then update the flags Files.write(updPath, updatedFlags.getBytes(), StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); @@ -98,7 +98,7 @@ void watchForFileUpdatesAndEmitThem() throws IOException { payload[0] = stream.take(); }); - assertEquals(updatedFlags, payload[0].getData()); + assertEquals(updatedFlags, payload[0].getFlagData()); } } diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/grpc/GrpcStreamConnectorTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/grpc/GrpcStreamConnectorTest.java index 10232f1c1..c19259807 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/grpc/GrpcStreamConnectorTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/grpc/GrpcStreamConnectorTest.java @@ -1,12 +1,18 @@ package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.grpc; +import static dev.openfeature.contrib.providers.flagd.resolver.common.Convert.convertProtobufMapToStructure; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.lang.reflect.Field; import java.time.Duration; @@ -14,12 +20,15 @@ import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; + +import com.google.protobuf.Struct; import dev.openfeature.contrib.providers.flagd.FlagdOptions; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.StreamPayload; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.StreamPayloadType; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayloadType; +import dev.openfeature.flagd.grpc.sync.FlagSyncServiceGrpc.FlagSyncServiceBlockingStub; import dev.openfeature.flagd.grpc.sync.FlagSyncServiceGrpc.FlagSyncServiceStub; +import dev.openfeature.flagd.grpc.sync.Sync.GetMetadataResponse; import dev.openfeature.flagd.grpc.sync.Sync.SyncFlagsRequest; import dev.openfeature.flagd.grpc.sync.Sync.SyncFlagsResponse; @@ -32,21 +41,23 @@ public void connectionParameters() throws Throwable { // given final FlagdOptions options = FlagdOptions.builder() .selector("selector") + .deadline(1337) .build(); final GrpcStreamConnector connector = new GrpcStreamConnector(options); final FlagSyncServiceStub stubMock = mockStubAndReturn(connector); - + final FlagSyncServiceBlockingStub blockingStubMock = mockBlockingStubAndReturn(connector); final SyncFlagsRequest[] request = new SyncFlagsRequest[1]; - Mockito.doAnswer(invocation -> { + doAnswer(invocation -> { request[0] = invocation.getArgument(0, SyncFlagsRequest.class); return null; }).when(stubMock).syncFlags(any(), any()); // when connector.init(); - verify(stubMock, Mockito.timeout(MAX_WAIT_MS.toMillis()).times(1)).syncFlags(any(), any()); + verify(stubMock, timeout(MAX_WAIT_MS.toMillis()).times(1)).syncFlags(any(), any()); + verify(blockingStubMock).withDeadlineAfter(1337, TimeUnit.MILLISECONDS); // then final SyncFlagsRequest flagsRequest = request[0]; @@ -57,12 +68,23 @@ public void connectionParameters() throws Throwable { @Test public void grpcConnectionStatus() throws Throwable { // given + final String key = "key1"; + final String val = "value1"; final GrpcStreamConnector connector = new GrpcStreamConnector(FlagdOptions.builder().build()); final FlagSyncServiceStub stubMock = mockStubAndReturn(connector); + final FlagSyncServiceBlockingStub blockingStubMock = mockBlockingStubAndReturn(connector); + final Struct metadata = Struct.newBuilder() + .putFields(key, + com.google.protobuf.Value.newBuilder().setStringValue(val).build()) + .build(); + + when(blockingStubMock.withDeadlineAfter(anyLong(), any())).thenReturn(blockingStubMock); + when(blockingStubMock.getMetadata(any())) + .thenReturn(GetMetadataResponse.newBuilder().setMetadata(metadata).build()); final GrpcStreamHandler[] injectedHandler = new GrpcStreamHandler[1]; - Mockito.doAnswer(invocation -> { + doAnswer(invocation -> { injectedHandler[0] = invocation.getArgument(1, GrpcStreamHandler.class); return null; }).when(stubMock).syncFlags(any(), any()); @@ -70,13 +92,14 @@ public void grpcConnectionStatus() throws Throwable { // when connector.init(); // verify and wait for initialization - verify(stubMock, Mockito.timeout(MAX_WAIT_MS.toMillis()).times(1)).syncFlags(any(), any()); + verify(stubMock, timeout(MAX_WAIT_MS.toMillis()).times(1)).syncFlags(any(), any()); + verify(blockingStubMock).getMetadata(any()); // then final GrpcStreamHandler grpcStreamHandler = injectedHandler[0]; assertNotNull(grpcStreamHandler); - final BlockingQueue streamPayloads = connector.getStream(); + final BlockingQueue streamPayloads = connector.getStream(); // accepted data grpcStreamHandler.onNext( @@ -84,8 +107,9 @@ public void grpcConnectionStatus() throws Throwable { .build()); assertTimeoutPreemptively(MAX_WAIT_MS, () -> { - StreamPayload payload = streamPayloads.take(); - assertEquals(StreamPayloadType.DATA, payload.getType()); + QueuePayload payload = streamPayloads.take(); + assertEquals(QueuePayloadType.DATA, payload.getType()); + assertEquals(val ,convertProtobufMapToStructure(payload.getMetadataResponse().getMetadata().getFieldsMap()).asObjectMap().get(key)); }); // ping must be ignored @@ -99,20 +123,26 @@ public void grpcConnectionStatus() throws Throwable { .build()); assertTimeoutPreemptively(MAX_WAIT_MS, () -> { - StreamPayload payload = streamPayloads.take(); - assertEquals(StreamPayloadType.DATA, payload.getType()); + QueuePayload payload = streamPayloads.take(); + assertEquals(QueuePayloadType.DATA, payload.getType()); }); } @Test + public void listenerExitOnShutdown() throws Throwable { // given final GrpcStreamConnector connector = new GrpcStreamConnector(FlagdOptions.builder().build()); final FlagSyncServiceStub stubMock = mockStubAndReturn(connector); - + final FlagSyncServiceBlockingStub blockingStubMock = mockBlockingStubAndReturn(connector); final GrpcStreamHandler[] injectedHandler = new GrpcStreamHandler[1]; + final Struct metadata = Struct.newBuilder().build(); - Mockito.doAnswer(invocation -> { + when(blockingStubMock.withDeadlineAfter(anyLong(), any())).thenReturn(blockingStubMock); + when(blockingStubMock.getMetadata(any())) + .thenReturn(GetMetadataResponse.newBuilder().setMetadata(metadata).build()); + when(stubMock.withDeadlineAfter(anyLong(), any())).thenReturn(stubMock); + doAnswer(invocation -> { injectedHandler[0] = invocation.getArgument(1, GrpcStreamHandler.class); return null; }).when(stubMock).syncFlags(any(), any()); @@ -120,7 +150,8 @@ public void listenerExitOnShutdown() throws Throwable { // when connector.init(); // verify and wait for initialization - verify(stubMock, Mockito.timeout(MAX_WAIT_MS.toMillis()).times(1)).syncFlags(any(), any()); + verify(stubMock, timeout(MAX_WAIT_MS.toMillis()).times(1)).syncFlags(any(), any()); + verify(blockingStubMock).getMetadata(any()); // then final GrpcStreamHandler grpcStreamHandler = injectedHandler[0]; @@ -132,8 +163,8 @@ public void listenerExitOnShutdown() throws Throwable { grpcStreamHandler.onError(new Exception("Channel closed, exiting")); assertTimeoutPreemptively(MAX_WAIT_MS, () -> { - StreamPayload payload = connector.getStream().take(); - assertEquals(StreamPayloadType.ERROR, payload.getType()); + QueuePayload payload = connector.getStream().take(); + assertEquals(QueuePayloadType.ERROR, payload.getType()); }); // Validate mock calls & no more event propagation @@ -154,11 +185,23 @@ private static FlagSyncServiceStub mockStubAndReturn(final GrpcStreamConnector c final Field serviceStubField = GrpcStreamConnector.class.getDeclaredField("serviceStub"); serviceStubField.setAccessible(true); - final FlagSyncServiceStub stubMock = Mockito.mock(FlagSyncServiceStub.class); + final FlagSyncServiceStub stubMock = mock(FlagSyncServiceStub.class); serviceStubField.set(connector, stubMock); return stubMock; } + private static FlagSyncServiceBlockingStub mockBlockingStubAndReturn(final GrpcStreamConnector connector) + throws Throwable { + final Field blockingStubField = GrpcStreamConnector.class.getDeclaredField("serviceBlockingStub"); + blockingStubField.setAccessible(true); + + final FlagSyncServiceBlockingStub blockingStubMock = mock(FlagSyncServiceBlockingStub.class); + + blockingStubField.set(connector, blockingStubMock); + + return blockingStubMock; + } + }