diff --git a/providers/go-feature-flag/README.md b/providers/go-feature-flag/README.md index cc1d6cb0fa..a936e52706 100644 --- a/providers/go-feature-flag/README.md +++ b/providers/go-feature-flag/README.md @@ -68,21 +68,21 @@ The `targetingKey` is mandatory for GO Feature Flag in order to evaluate the fea You can configure the provider with several options to customize its behavior. The following options are available: -| name | mandatory | Description | -|-----------------------------------|-----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **`endpoint`** | `true` | endpoint contains the DNS of your GO Feature Flag relay proxy _(ex: https://mydomain.com/gofeatureflagproxy/)_ | -| **`evaluationType`** | `false` | evaluationType is the type of evaluation you want to use.Default: IN_PROCESS
| -| **`timeout`** | `false` | timeout in millisecond we are waiting when calling the relay proxy API. _(default: `10000`)_ | -| **`maxIdleConnections`** | `false` | maxIdleConnections is the maximum number of connections in the connection pool. _(default: `1000`)_ | -| **`keepAliveDuration`** | `false` | keepAliveDuration is the time in millisecond we keep the connection open. _(default: `7200000` (2 hours))_ | -| **`apiKey`** | `false` | If the relay proxy is configured to authenticate the requests, you should provide an API Key to the provider. Please ask the administrator of the relay proxy to provide an API Key. (This feature is available only if you are using GO Feature Flag relay proxy v1.7.0 or above). _(default: null)_ | -| **`flushIntervalMs`** | `false` | interval time we publish statistics collection data to the proxy. The parameter is used only if the cache is enabled, otherwise the collection of the data is done directly when calling the evaluation API. default: `1000` ms | -| **`maxPendingEvents`** | `false` | max pending events aggregated before publishing for collection data to the proxy. When event is added while events collection is full, event is omitted. _(default: `10000`)_ | -| **`disableDataCollection`** | `false` | set to true if you don't want to collect the usage of flags retrieved in the cache. _(default: `false`)_ | -| **`exporterMetadata`** | `false` | exporterMetadata is the metadata we send to the GO Feature Flag relay proxy when we report the evaluation data usage. | -| **`evaluationFlagList`** | `false` | If you are using in process evaluation, by default we will load in memory all the flags available in the relay proxy. If you want to limit the number of flags loaded in memory, you can use this parameter. By setting this parameter, you will only load the flags available in the list.

If null or empty, all the flags available in the relay proxy will be loaded.

| -| **`flagChangePollingIntervalMs`** | `false` | interval time we poll the proxy to check if the configuration has changed. It is used for the in process evaluation to check if we should refresh our internal cache. default: `120000` | -| **`wasmEvaluatorPoolSize`** | `false` | _(IN_PROCESS only)_ Number of WASM instances kept in the evaluation pool. Each instance owns independent memory, allowing fully concurrent flag evaluations without serialisation. Must be `>= 1`. _(default: number of available CPU cores)_ | +| name | mandatory | Description | +|-----------------------------------|-----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **`endpoint`** | `true` | endpoint contains the DNS of your GO Feature Flag relay proxy _(ex: https://mydomain.com/gofeatureflagproxy/)_ | +| **`evaluationType`** | `false` | evaluationType is the type of evaluation you want to use.Default: IN_PROCESS
| +| **`timeout`** | `false` | timeout in millisecond we are waiting when calling the relay proxy API. _(default: `10000`)_ | +| **`apiKey`** | `false` | If the relay proxy is configured to authenticate the requests, you should provide an API Key to the provider. Please ask the administrator of the relay proxy to provide an API Key. (This feature is available only if you are using GO Feature Flag relay proxy v1.7.0 or above). _(default: null)_ | +| **`dataCollectorBaseUrl`** | `false` | base URL used to publish the evaluation and tracking events, when the data collector is not served by the relay proxy itself. It replaces the whole base of the collector route, scheme, host, port and path prefix included; the flag configuration and the evaluations keep using `endpoint`. _(default: `endpoint`)_ | +| **`customHeaders`** | `false` | Extra HTTP headers added to every request the provider makes (flag configuration, evaluation and data collection, including `dataCollectorBaseUrl`), for deployments behind a gateway that needs its own authentication. A configured `apiKey` always wins over a custom `X-API-Key`. `Content-Type` and `If-None-Match` are set by the provider and refused here, as are the headers the Java HTTP client restricts (`Host`, `Connection`, `Content-Length`, `Expect`, `Upgrade`). _(default: none)_ | +| **`flushIntervalMs`** | `false` | interval time in millisecond we publish the collected evaluation and tracking events to the data collector. _(default: `60000` (1 minute))_ | +| **`maxPendingEvents`** | `false` | max pending events aggregated before publishing for collection data to the proxy. Once that many events are pending they are published without waiting for `flushIntervalMs`. If they cannot be published, at most twice that many are kept and the oldest are dropped. _(default: `10000`)_ | +| **`disableDataCollection`** | `false` | set to true if you don't want to send the evaluation and tracking events to the data collector. _(default: `false`)_ | +| **`exporterMetadata`** | `false` | exporterMetadata is the metadata we send to the GO Feature Flag relay proxy when we report the evaluation data usage. | +| **`evaluationFlagList`** | `false` | If you are using in process evaluation, by default we will load in memory all the flags available in the relay proxy. If you want to limit the number of flags loaded in memory, you can use this parameter. By setting this parameter, you will only load the flags available in the list.

If null or empty, all the flags available in the relay proxy will be loaded.

| +| **`flagChangePollingIntervalMs`** | `false` | interval time we poll the proxy to check if the configuration has changed. It is used for the in process evaluation to check if the flag configuration it holds should be refreshed. Each poll is randomly shortened or lengthened by up to 10%, so that providers started together do not poll in lockstep. default: `120000` | +| **`wasmEvaluatorPoolSize`** | `false` | _(IN_PROCESS only)_ Number of WASM instances kept in the evaluation pool. Each instance owns independent memory, allowing fully concurrent flag evaluations without serialisation. Each instance holds about 2.3 MiB of memory once warm. Must be `>= 1`. _(default: number of available CPU cores)_ | ### Evaluate a feature flag The OpenFeature client is used to retrieve values for the current `EvaluationContext`. For example, retrieving a boolean value for the flag **"my-flag"**: @@ -116,11 +116,23 @@ client.getObjectDetails("my-flag",Value.objectToValue(new MutableStructure().add When the provider is configured to use in process evaluation, it will fetch the flag configuration from the GO Feature Flag relay-proxy API and evaluate the flags directly in the provider. The evaluation is done inside the provider using a webassembly module that is compiled from the GO Feature Flag source code. -The `wasm` module is used to evaluate the flags and the source code is available in the [thomaspoignant/go-feature-flag](https://github.com/thomaspoignant/go-feature-flag/tree/main/wasm) repository. +The `wasm` module is used to evaluate the flags and the source code is available in the [thomaspoignant/go-feature-flag](https://github.com/thomaspoignant/go-feature-flag/tree/main/cmd/wasm) repository. The provider will call the GO Feature Flag relay-proxy API to fetch the flag configuration and then evaluate the flags using the `wasm` module. +The `wasm` module is compiled into Java bytecode when the provider is built, so it ships as classes inside the provider jar. There is no `.wasm` file to locate at runtime, and repackaging the provider (shaded or fat jars) keeps the engine with it. The engine version is pinned by the provider release. + +#### Performance +Some of the engine's functions compile to Java methods larger than the JVM's default JIT limit (`HugeMethodLimit`, 8000 bytes of bytecode), and HotSpot leaves such methods to the bytecode interpreter. This includes the evaluation path itself, so the engine runs about 3x slower than it could: roughly 350 µs instead of 110 µs per evaluation on an Apple M4 Pro with JDK 21. + +If this matters to you, start the JVM with `-XX:-DontCompileHugeMethods`. The flag is process-wide and lifts the limit for every class, not only the engine's. + ### Remote evaluation When the provider is configured to use remote evaluation, it will call the GO Feature Flag relay-proxy for each flag evaluation. It will perform an HTTP request to the GO Feature Flag relay-proxy API with the flag name and the evaluation context for each flag evaluation. + +### Logging +The provider logs through [SLF4J](https://www.slf4j.org/), so its diagnostics go wherever your SLF4J backend sends them. Every logger it uses sits under `dev.openfeature.contrib.providers.gofeatureflag`, and remote evaluation also logs under `dev.openfeature.contrib.providers.ofrep`. + +What the evaluation engine prints, such as the panic it reports before a trap, is logged too, at error level by `dev.openfeature.contrib.providers.gofeatureflag.wasm.EvaluationWasm`, rather than written to the process's standard streams. diff --git a/providers/go-feature-flag/pom.xml b/providers/go-feature-flag/pom.xml index 78359cf5e1..693f49eea2 100644 --- a/providers/go-feature-flag/pom.xml +++ b/providers/go-feature-flag/pom.xml @@ -20,7 +20,9 @@ ${groupId}.gofeatureflag - 0.2.3 + 0.2.4 + + **/e2e/*.java @@ -72,7 +74,7 @@ org.apache.logging.log4j log4j-slf4j2-impl - 2.25.2 + 2.26.1 test @@ -103,12 +105,38 @@ test + + org.testcontainers + testcontainers + 2.0.4 + test + + + + org.testcontainers + testcontainers-junit-jupiter + 2.0.4 + test + + com.github.spotbugs spotbugs-annotations 4.9.8 provided + + + dev.openfeature.contrib.providers + ofrep + 0.0.2 + + + + com.google.guava + guava + 33.4.8-jre + @@ -131,4 +159,14 @@ + + + + + e2e + + + + + diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java index 75dba19ec8..709f692f03 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java @@ -11,7 +11,6 @@ import dev.openfeature.contrib.providers.gofeatureflag.hook.DataCollectorHook; import dev.openfeature.contrib.providers.gofeatureflag.hook.DataCollectorHookOptions; import dev.openfeature.contrib.providers.gofeatureflag.hook.EnrichEvaluationContextHook; -import dev.openfeature.contrib.providers.gofeatureflag.service.EvaluationService; import dev.openfeature.contrib.providers.gofeatureflag.service.EventsPublisher; import dev.openfeature.contrib.providers.gofeatureflag.util.Const; import dev.openfeature.contrib.providers.gofeatureflag.util.EvaluationContextUtil; @@ -20,6 +19,7 @@ import dev.openfeature.sdk.Hook; import dev.openfeature.sdk.Metadata; import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEvent; import dev.openfeature.sdk.ProviderEventDetails; import dev.openfeature.sdk.Tracking; import dev.openfeature.sdk.TrackingEventDetails; @@ -29,6 +29,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.BiConsumer; import java.util.function.Consumer; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -41,7 +42,7 @@ public final class GoFeatureFlagProvider extends EventProvider implements Tracki /** Options to configure the provider. */ private final GoFeatureFlagProviderOptions options; /** Service to evaluate the flags. */ - private final EvaluationService evalService; + private final IEvaluator evaluator; /** List of the hooks used by the provider. */ private final List hooks = new ArrayList<>(); /** API layer to contact GO Feature Flag. */ @@ -50,8 +51,6 @@ public final class GoFeatureFlagProvider extends EventProvider implements Tracki private final EventsPublisher eventsPublisher; /** exporter metadata contains the metadata that we want to send to the exporter. */ private final Map exporterMetadata; - /** DataCollectorHook is the hook to send usage of the flags. */ - private DataCollectorHook dataCollectorHook; /** * Constructor of the provider. @@ -66,24 +65,16 @@ public GoFeatureFlagProvider(final GoFeatureFlagProviderOptions options) throws options.validate(); this.options = options; this.api = GoFeatureFlagApi.builder().options(options).build(); - this.evalService = new EvaluationService(getEvaluator(this.api)); + this.evaluator = getEvaluator(); - long flushIntervalMs = - (options.getFlushIntervalMs() == null) ? Const.DEFAULT_FLUSH_INTERVAL_MS : options.getFlushIntervalMs(); - int maxPendingEvents = (options.getMaxPendingEvents() == null) - ? Const.DEFAULT_MAX_PENDING_EVENTS - : options.getMaxPendingEvents(); Consumer> publisher = this::publishEvents; - this.eventsPublisher = new EventsPublisher<>(publisher, flushIntervalMs, maxPendingEvents); - - if (options.getExporterMetadata() == null) { - this.exporterMetadata = new HashMap<>(); - } else { - val exp = new HashMap<>(options.getExporterMetadata()); - exp.put("provider", "java"); - exp.put("openfeature", true); - this.exporterMetadata = exp; - } + this.eventsPublisher = + new EventsPublisher<>(publisher, options.getFlushIntervalMs(), options.getMaxPendingEvents()); + + val exp = new HashMap<>(options.getExporterMetadata()); + exp.put(Const.METADATA_PROVIDER, "java"); + exp.put(Const.METADATA_OPENFEATURE, true); + this.exporterMetadata = exp; } @Override @@ -99,48 +90,54 @@ public List getProviderHooks() { @Override public ProviderEvaluation getBooleanEvaluation( String key, Boolean defaultValue, EvaluationContext evaluationContext) { - return this.evalService.getEvaluation(key, defaultValue, evaluationContext, Boolean.class); + return this.evaluator.getBooleanEvaluation(key, defaultValue, evaluationContext); } @Override public ProviderEvaluation getStringEvaluation( String key, String defaultValue, EvaluationContext evaluationContext) { - return this.evalService.getEvaluation(key, defaultValue, evaluationContext, String.class); + return this.evaluator.getStringEvaluation(key, defaultValue, evaluationContext); } @Override public ProviderEvaluation getIntegerEvaluation( String key, Integer defaultValue, EvaluationContext evaluationContext) { - return this.evalService.getEvaluation(key, defaultValue, evaluationContext, Integer.class); + return this.evaluator.getIntegerEvaluation(key, defaultValue, evaluationContext); } @Override public ProviderEvaluation getDoubleEvaluation( String key, Double defaultValue, EvaluationContext evaluationContext) { - return this.evalService.getEvaluation(key, defaultValue, evaluationContext, Double.class); + return this.evaluator.getDoubleEvaluation(key, defaultValue, evaluationContext); } @Override public ProviderEvaluation getObjectEvaluation( String key, Value defaultValue, EvaluationContext evaluationContext) { - return this.evalService.getEvaluation(key, defaultValue, evaluationContext, Value.class); + return this.evaluator.getObjectEvaluation(key, defaultValue, evaluationContext); } @Override public void initialize(EvaluationContext evaluationContext) throws Exception { + this.initialize(evaluationContext, ""); + } + + @Override + public void initialize(EvaluationContext evaluationContext, String domain) throws Exception { super.initialize(evaluationContext); - this.evalService.init(); - this.hooks.add(new EnrichEvaluationContextHook(this.options.getExporterMetadata())); + // re-initialization must reset the publisher: its shutdown flag and its scheduler are both + // one-shot, so without this a provider that is shut down and initialized again never flushes. + this.eventsPublisher.start(); + this.evaluator.initialize(evaluationContext, domain); + this.hooks.clear(); + this.hooks.add(new EnrichEvaluationContextHook(this.exporterMetadata)); // In case of remote evaluation, we don't need to send the data to the collector // because the relay-proxy will collect events directly server side. if (!this.options.isDisableDataCollection() && this.options.getEvaluationType() != EvaluationType.REMOTE) { - this.dataCollectorHook = new DataCollectorHook(DataCollectorHookOptions.builder() + this.hooks.add(new DataCollectorHook(DataCollectorHookOptions.builder() .eventsPublisher(this.eventsPublisher) - .collectUnCachedEvaluation(true) - .evalService(this.evalService) - .build()); - - this.hooks.add(this.dataCollectorHook); + .evaluator(this.evaluator) + .build())); } log.info("finishing initializing provider"); } @@ -148,10 +145,8 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { @Override public void shutdown() { super.shutdown(); - this.evalService.destroy(); - if (this.dataCollectorHook != null) { - this.dataCollectorHook.shutdown(); - } + this.evaluator.shutdown(); + this.eventsPublisher.shutdown(); } @Override @@ -171,10 +166,14 @@ public void track(final String eventName, final TrackingEventDetails trackingEve @Override public void track(final String eventName, final EvaluationContext context, final TrackingEventDetails details) { + if (this.options.isDisableDataCollection()) { + return; + } + val trackingEvent = TrackingEvent.builder() .evaluationContext((context != null) ? context.asObjectMap() : Collections.emptyMap()) - .userKey(context != null ? context.getTargetingKey() : "undefined-targetingKey") - .contextKind(EvaluationContextUtil.isAnonymousUser(context) ? "anonymousUser" : "user") + .userKey(EvaluationContextUtil.userKey(context)) + .contextKind(EvaluationContextUtil.contextKind(context)) .kind("tracking") .key(eventName) .trackingEventDetails(details != null ? details.asObjectMap() : Collections.emptyMap()) @@ -185,17 +184,16 @@ public void track(final String eventName, final EvaluationContext context, final /** * Get the evaluator based on the evaluation type. - * It will initialize the evaluator based on the evaluation type. * * @return the evaluator */ - private IEvaluator getEvaluator(GoFeatureFlagApi api) { + private IEvaluator getEvaluator() { // Select the evaluator based on the evaluation type + BiConsumer emitter = this::emit; if (options.getEvaluationType() == null || options.getEvaluationType() == EvaluationType.IN_PROCESS) { - Consumer emitProviderConfigurationChanged = this::emitProviderConfigurationChanged; - return new InProcessEvaluator(api, this.options, emitProviderConfigurationChanged); + return new InProcessEvaluator(this.api, this.options, emitter); } - return new RemoteEvaluator(api); + return new RemoteEvaluator(this.options, emitter); } /** diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java index 9cd620553b..187515bfb6 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java @@ -4,20 +4,31 @@ import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidEndpoint; import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidExporterMetadata; import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; import java.net.MalformedURLException; import java.net.URL; +import java.net.http.HttpRequest; +import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import lombok.Builder; import lombok.Getter; import lombok.val; /** * GoFeatureFlagProviderOptions contains the options to initialise the provider. + * + *

Every optional field is unset by default, and its getter resolves the documented default value, + * so the provider and the evaluators can read the options without repeating the fallbacks.

*/ @Builder @Getter public class GoFeatureFlagProviderOptions { + /** Default timeout in millisecond when calling the GO Feature Flag relay proxy API. */ + private static final int DEFAULT_TIMEOUT_MS = 10000; + /** * evaluationType is the type of evaluation you want to use. * - If you want to have a local evaluation, you should use IN_PROCESS. @@ -30,21 +41,18 @@ public class GoFeatureFlagProviderOptions { * https://mydomain.com/gofeatureflagproxy/ */ private String endpoint; + /** + * (optional) dataCollectorBaseUrl is the base URL used to publish the evaluation data, when the + * data collector is not served by the relay proxy itself. It replaces the whole base of the + * collector route, scheme, host, port and path prefix included, and applies to that route only: + * the flag configuration and the evaluations keep using endpoint. Default: endpoint + */ + private String dataCollectorBaseUrl; /** * (optional) timeout in millisecond we are waiting when calling the go-feature-flag relay proxy * API. Default: 10000 ms */ private int timeout; - /** - * (optional) maxIdleConnections is the maximum number of connexions in the connexion pool. - * Default: 1000 - */ - private int maxIdleConnections; - /** - * (optional) keepAliveDuration is the time in millisecond we keep the connexion open. Default: - * 7200000 (2 hours) - */ - private Long keepAliveDuration; /** * (optional) If the relay proxy is configured to authenticate the requests, you should provide an * API Key to the provider. Please ask the administrator of the relay proxy to provide an API Key. @@ -53,25 +61,33 @@ public class GoFeatureFlagProviderOptions { */ private String apiKey; /** - * (optional) interval time we publish statistics collection data to the proxy. The parameter is - * used only if the cache is enabled, otherwise the collection of the data is done directly when - * calling the evaluation API. default: 1000 ms + * (optional) customHeaders are extra HTTP headers added to every request the provider makes, to the + * relay proxy and to dataCollectorBaseUrl, for deployments behind a gateway that needs its own + * authentication. A configured apiKey always wins over a custom X-API-Key. Content-Type and + * If-None-Match are set by the provider, and they are refused here, as are the headers the Java HTTP + * client restricts (Host, Connection, Content-Length, Expect, Upgrade). Default: none + */ + private Map customHeaders; + /** + * (optional) interval time in millisecond we publish the collected evaluation and tracking events + * to the data collector. default: 60000 ms (1 minute) */ private Long flushIntervalMs; /** * (optional) max pending events aggregated before publishing for collection data to the proxy. - * When an event is added while an events collection is full, the event is omitted. default: 10000 + * Once that many events are pending they are published without waiting for flushIntervalMs. If + * they cannot be published, at most twice that many are kept and the oldest are dropped. default: 10000 */ private Integer maxPendingEvents; /** - * (optional) disableDataCollection set to true if you don't want to collect the usage of flags - * retrieved in the cache. default: false + * (optional) disableDataCollection set to true if you don't want to send the evaluation and + * tracking events to the data collector. default: false */ private boolean disableDataCollection; /** * (optional) exporterMetadata is the metadata we send to the GO Feature Flag relay proxy when we report the - * evaluation data usage. + * evaluation data usage. default: empty */ private Map exporterMetadata; @@ -85,9 +101,9 @@ public class GoFeatureFlagProviderOptions { private List evaluationFlagList; /** - * (optional) interval time we poll the proxy to check if the configuration has changed. If the - * cache is enabled, we will poll the relay-proxy every X milliseconds to check if the - * configuration has changed. default: 120000 + * (optional) interval time in millisecond we poll the relay proxy to check if the flag + * configuration has changed, for in process evaluation. Each poll is randomly shortened or + * lengthened by up to 10%. default: 120000 */ private Long flagChangePollingIntervalMs; @@ -99,29 +115,133 @@ public class GoFeatureFlagProviderOptions { */ private Integer wasmEvaluatorPoolSize; + /** + * Get the type of evaluation to use. + * + * @return the configured evaluation type, IN_PROCESS if none was set + */ + public EvaluationType getEvaluationType() { + return evaluationType == null ? EvaluationType.IN_PROCESS : evaluationType; + } + + /** + * Get the base URL used to publish the evaluation data. + * + * @return the configured data collector base URL, the endpoint if none was set + */ + public String getDataCollectorBaseUrl() { + return dataCollectorBaseUrl == null || dataCollectorBaseUrl.isEmpty() ? endpoint : dataCollectorBaseUrl; + } + + /** + * Get the timeout in millisecond when calling the GO Feature Flag relay proxy API. + * + * @return the configured timeout, 10000 ms if none was set + */ + public int getTimeout() { + return timeout == 0 ? DEFAULT_TIMEOUT_MS : timeout; + } + + /** + * Get the extra HTTP headers added to every request to the relay proxy. + * + * @return the configured headers, an empty map if none was set + */ + public Map getCustomHeaders() { + return customHeaders == null ? Collections.emptyMap() : customHeaders; + } + + /** + * Get the interval time we publish the collected events to the data collector. + * + * @return the configured interval, 60000 ms if none was set + */ + public Long getFlushIntervalMs() { + return Objects.requireNonNullElse(flushIntervalMs, Const.DEFAULT_FLUSH_INTERVAL_MS); + } + + /** + * Get the maximum number of events aggregated before publishing them to the proxy. + * + * @return the configured maximum, 10000 if none was set + */ + public Integer getMaxPendingEvents() { + return Objects.requireNonNullElse(maxPendingEvents, Const.DEFAULT_MAX_PENDING_EVENTS); + } + + /** + * Get the metadata sent to the relay proxy when reporting the evaluation data usage. + * + * @return the configured metadata, an empty map if none was set + */ + public Map getExporterMetadata() { + return exporterMetadata == null ? Collections.emptyMap() : exporterMetadata; + } + + /** + * Get the list of flags to load for in process evaluation. + * + * @return the configured list, an empty list if none was set, meaning all the flags are loaded + */ + public List getEvaluationFlagList() { + return evaluationFlagList == null ? Collections.emptyList() : evaluationFlagList; + } + + /** + * Get the interval time we poll the proxy to check if the configuration has changed. + * + * @return the configured interval, 120000 ms if none was set + */ + public Long getFlagChangePollingIntervalMs() { + return Objects.requireNonNullElse( + flagChangePollingIntervalMs, Const.DEFAULT_POLLING_CONFIG_FLAG_CHANGE_INTERVAL_MS); + } + + /** + * Get the number of WASM instances kept in the evaluation pool. + * + * @return the configured pool size, the number of available CPU cores if none was set + */ + public Integer getWasmEvaluatorPoolSize() { + return Objects.requireNonNullElse(wasmEvaluatorPoolSize, Const.DEFAULT_WASM_EVALUATOR_POOL_SIZE); + } + /** * Validate the options provided to the provider. * + *

Validation reads the fields directly and not the getters, to check what the caller has + * really set and not the resolved default values.

+ * * @throws InvalidOptions - if options are invalid */ public void validate() throws InvalidOptions { - if (getEndpoint() == null || getEndpoint().isEmpty()) { + if (endpoint == null || endpoint.isEmpty()) { throw new InvalidEndpoint("endpoint is a mandatory field when initializing the provider"); } try { - new URL(getEndpoint()); + new URL(endpoint); } catch (MalformedURLException e) { - throw new InvalidEndpoint("malformed endpoint: " + getEndpoint()); + throw new InvalidEndpoint("malformed endpoint: " + endpoint); } - if (getWasmEvaluatorPoolSize() != null && getWasmEvaluatorPoolSize() < 1) { + if (dataCollectorBaseUrl != null && !dataCollectorBaseUrl.isEmpty()) { + try { + new URL(dataCollectorBaseUrl); + } catch (MalformedURLException e) { + throw new InvalidEndpoint("malformed dataCollectorBaseUrl: " + dataCollectorBaseUrl); + } + } + + validateCustomHeaders(customHeaders); + + if (wasmEvaluatorPoolSize != null && wasmEvaluatorPoolSize < 1) { throw new InvalidOptions("wasmEvaluatorPoolSize must be at least 1"); } - if (getExporterMetadata() != null) { + if (exporterMetadata != null) { val acceptableExporterMetadataTypes = List.of("String", "Boolean", "Integer", "Double"); - for (Map.Entry entry : getExporterMetadata().entrySet()) { + for (Map.Entry entry : exporterMetadata.entrySet()) { if (!acceptableExporterMetadataTypes.contains( entry.getValue().getClass().getSimpleName())) { throw new InvalidExporterMetadata( @@ -130,4 +250,42 @@ public void validate() throws InvalidOptions { } } } + + /** + * validateCustomHeaders rejects, at construction, a custom header the provider would otherwise + * send wrongly or fail on at every request. The messages name the header but never carry its + * value, which is typically a gateway credential. + */ + private static void validateCustomHeaders(final Map headers) throws InvalidOptions { + if (headers == null || headers.isEmpty()) { + return; + } + + val probe = HttpRequest.newBuilder(); + val headersSet = new HashSet(); + for (Map.Entry header : headers.entrySet()) { + val name = header.getKey(); + if (name == null) { + throw new InvalidOptions("customHeaders cannot contain a null header name"); + } + if (Const.HTTP_HEADER_CONTENT_TYPE.equalsIgnoreCase(name) + || Const.HTTP_HEADER_IF_NONE_MATCH.equalsIgnoreCase(name)) { + throw new InvalidOptions("custom header " + name + " is set by the provider itself"); + } + try { + probe.header(name, "value"); + } catch (IllegalArgumentException e) { + throw new InvalidOptions("invalid custom header name, " + e.getMessage()); + } + + if (header.getValue() == null) { + throw new InvalidOptions("null value for header: " + name); + } + + val setSuccess = headersSet.add(name.toLowerCase()); + if (!setSuccess) { + throw new InvalidOptions("more than one header configured with the name, " + name); + } + } + } } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java index 591a05338e..1ee692f25f 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java @@ -1,28 +1,23 @@ package dev.openfeature.contrib.providers.gofeatureflag.api; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; import dev.openfeature.contrib.providers.gofeatureflag.api.bean.ExporterRequest; import dev.openfeature.contrib.providers.gofeatureflag.api.bean.FlagConfigApiRequest; import dev.openfeature.contrib.providers.gofeatureflag.api.bean.FlagConfigApiResponse; -import dev.openfeature.contrib.providers.gofeatureflag.api.bean.OfrepRequest; -import dev.openfeature.contrib.providers.gofeatureflag.api.bean.OfrepResponse; import dev.openfeature.contrib.providers.gofeatureflag.bean.FlagConfigResponse; -import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.bean.IEvent; +import dev.openfeature.contrib.providers.gofeatureflag.exception.AuthenticationFailure; import dev.openfeature.contrib.providers.gofeatureflag.exception.FlagConfigurationEndpointNotFound; import dev.openfeature.contrib.providers.gofeatureflag.exception.ImpossibleToRetrieveConfiguration; import dev.openfeature.contrib.providers.gofeatureflag.exception.ImpossibleToSendEventsException; import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidEndpoint; import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; import dev.openfeature.contrib.providers.gofeatureflag.util.Const; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.exceptions.FlagNotFoundError; import dev.openfeature.sdk.exceptions.GeneralError; -import dev.openfeature.sdk.exceptions.InvalidContextError; -import dev.openfeature.sdk.exceptions.OpenFeatureError; import java.io.IOException; import java.net.HttpURLConnection; import java.net.URI; @@ -37,6 +32,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; import lombok.Builder; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -55,9 +51,15 @@ public final class GoFeatureFlagApi { /** endpoint is the endpoint of the GO Feature Flag relay proxy. */ private final URI endpoint; + /** dataCollectorBaseUrl is the base of the data collector route, the endpoint unless overridden. */ + private final URI dataCollectorBaseUrl; + /** timeout is the timeout in milliseconds for the HTTP requests. */ private final int timeout; + /** customHeaders are the extra headers added to every request, before the provider's own. */ + private final Map customHeaders; + /** * GoFeatureFlagController is the constructor of the controller to contact the GO Feature Flag * relay proxy. @@ -72,9 +74,11 @@ private GoFeatureFlagApi(final GoFeatureFlagProviderOptions options) throws Inva } options.validate(); this.apiKey = options.getApiKey(); + this.customHeaders = Map.copyOf(options.getCustomHeaders()); try { - this.endpoint = new URI(options.getEndpoint()); + this.endpoint = asBaseUri(options.getEndpoint()); + this.dataCollectorBaseUrl = asBaseUri(options.getDataCollectorBaseUrl()); } catch (URISyntaxException e) { throw new InvalidEndpoint(e); } @@ -90,110 +94,39 @@ private GoFeatureFlagApi(final GoFeatureFlagProviderOptions options) throws Inva .build(); } - /** - * evaluateFlag is calling the GO Feature Flag relay proxy to evaluate the feature flag. - * - * @param key - name of the flag - * @param evaluationContext - context of the evaluation - * @return EvaluationResponse with the evaluation of the flag - * @throws OpenFeatureError - if an error occurred while evaluating the flag - */ - public GoFeatureFlagResponse evaluateFlag(final String key, final EvaluationContext evaluationContext) - throws OpenFeatureError { - return this.evaluateFlag(key, evaluationContext, 0); - } - - /** - * evaluateFlag is calling the GO Feature Flag relay proxy to evaluate the feature flag.\ - * It will retry once if the relay proxy is unavailable. - * - * @param key - name of the flag - * @param evaluationContext - context of the evaluation - * @param retryCount - number of retries already done - * @return EvaluationResponse with the evaluation of the flag - * @throws OpenFeatureError - if an error occurred while evaluating the flag - */ - private GoFeatureFlagResponse evaluateFlag( - final String key, final EvaluationContext evaluationContext, final int retryCount) throws OpenFeatureError { - try { - URI url = this.endpoint.resolve("/ofrep/v1/evaluate/flags/" + key); - - val requestBody = OfrepRequest.builder() - .context(evaluationContext.asObjectMap()) - .build(); - - HttpRequest request = prepareHttpRequest(url, requestBody); - - HttpResponse response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - String body = response.body(); - - switch (response.statusCode()) { - case HttpURLConnection.HTTP_OK: - val goffResp = Const.DESERIALIZE_OBJECT_MAPPER.readValue(body, OfrepResponse.class); - return goffResp.toGoFeatureFlagResponse(); - case HttpURLConnection.HTTP_UNAUTHORIZED: - case HttpURLConnection.HTTP_FORBIDDEN: - throw new GeneralError("authentication/authorization error"); - case HttpURLConnection.HTTP_BAD_REQUEST: - throw new InvalidContextError("Invalid context: " + body); - case HttpURLConnection.HTTP_UNAVAILABLE: - // If the relay proxy is unavailable, we can retry once. - if (retryCount < 1) { - log.warn("GO Feature Flag relay proxy is unavailable, retrying evaluation for flag: {}", key); - return this.evaluateFlag(key, evaluationContext, retryCount + 1); - } - throw new GeneralError("Service Unavailable: " + body); - case HttpURLConnection.HTTP_NOT_FOUND: - throw new FlagNotFoundError("Flag " + key + " not found"); - default: - throw new GeneralError("Unknown error while retrieving flag " + body); - } - } catch (IOException | InterruptedException e) { - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - throw new GeneralError("unknown error while retrieving flag " + key, e); - } - } - /** * retrieveFlagConfiguration is calling the GO Feature Flag relay proxy to retrieve the flags' * configuration. * - * @param etag - etag of the request - * @return FlagConfigResponse with the flag configuration + *

A {@code 304 Not Modified} response is reported as an empty Optional rather than as an + * empty configuration object, so that the not-modified branch is structurally incapable of + * carrying a configuration and cannot be mistaken for one downstream.

+ * + * @param etag - etag of the request + * @param flags - flags to retrieve, empty for all of them + * @return the flag configuration, or empty if the configuration has not been modified */ - public FlagConfigResponse retrieveFlagConfiguration(final String etag, final List flags) { + public Optional retrieveFlagConfiguration(final String etag, final List flags) { try { val request = new FlagConfigApiRequest(flags == null ? Collections.emptyList() : flags); - final URI url = this.endpoint.resolve("/v1/flag/configuration"); - - HttpRequest.Builder reqBuilder = - HttpRequest.newBuilder().uri(url).header(Const.HTTP_HEADER_CONTENT_TYPE, Const.APPLICATION_JSON); + final URI url = route(this.endpoint, Const.PATH_FLAG_CONFIGURATION); - if (this.apiKey != null && !this.apiKey.isEmpty()) { - reqBuilder.header(Const.HTTP_HEADER_AUTHORIZATION, Const.BEARER_TOKEN + this.apiKey); - } + final HttpRequest httpRequest = etag != null && !etag.isEmpty() + ? prepareHttpRequest(url, request, Const.HTTP_HEADER_IF_NONE_MATCH, etag) + : prepareHttpRequest(url, request); - if (etag != null && !etag.isEmpty()) { - reqBuilder.header(Const.HTTP_HEADER_IF_NONE_MATCH, etag); - } - - reqBuilder.POST( - HttpRequest.BodyPublishers.ofByteArray(Const.SERIALIZE_OBJECT_MAPPER.writeValueAsBytes(request))); - - HttpResponse response = - this.httpClient.send(reqBuilder.build(), HttpResponse.BodyHandlers.ofString()); + HttpResponse response = this.httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()); String body = response.body(); switch (response.statusCode()) { case HttpURLConnection.HTTP_OK: + return Optional.of(handleFlagConfigurationSuccess(response, body)); case HttpURLConnection.HTTP_NOT_MODIFIED: - return handleFlagConfigurationSuccess(response, body); + return Optional.empty(); case HttpURLConnection.HTTP_NOT_FOUND: throw new FlagConfigurationEndpointNotFound(); case HttpURLConnection.HTTP_UNAUTHORIZED: case HttpURLConnection.HTTP_FORBIDDEN: - throw new ImpossibleToRetrieveConfiguration( + throw new AuthenticationFailure( "retrieve flag configuration error: authentication/authorization error"); case HttpURLConnection.HTTP_BAD_REQUEST: throw new ImpossibleToRetrieveConfiguration( @@ -221,7 +154,7 @@ public FlagConfigResponse retrieveFlagConfiguration(final String etag, final Lis public void sendEventToDataCollector(final List eventsList, final Map exporterMetadata) { try { ExporterRequest requestBody = new ExporterRequest(eventsList, exporterMetadata); - URI url = this.endpoint.resolve("/v1/data/collector"); + URI url = route(this.dataCollectorBaseUrl, Const.PATH_DATA_COLLECTOR); HttpRequest request = prepareHttpRequest(url, requestBody); @@ -250,28 +183,39 @@ public void sendEventToDataCollector(final List eventsList, final Map response, final String body) throws JsonProcessingException { - var result = FlagConfigResponse.builder() + // without FAIL_ON_TRAILING_TOKENS, a body cut or garbled after a complete JSON value would + // be read as that value, and {"flags":{}}} would wipe every flag and advance the ETag. + final FlagConfigApiResponse goffResp = Const.DESERIALIZE_OBJECT_MAPPER + .readerFor(FlagConfigApiResponse.class) + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readValue(body); + + // A 200 that decodes to no flag map is a failed refresh, not an empty configuration: + // accepting it would wipe every flag and advance the ETag, making the empty state permanent. + // A null evaluationContextEnrichment is NOT the same case - the relay proxy builds that field + // from a Go map and a nil map marshals to null - so it is accepted as "no enrichment". + if (goffResp == null || goffResp.getFlags() == null) { + throw new ImpossibleToRetrieveConfiguration( + "retrieve flag configuration error: the response contains no flag map"); + } + + return FlagConfigResponse.builder() .etag(response.headers().firstValue(Const.HTTP_HEADER_ETAG).orElse(null)) .lastUpdated(extractLastUpdatedFromHeaders(response)) + .flags(goffResp.getFlags()) + .evaluationContextEnrichment(goffResp.getEvaluationContextEnrichment()) .build(); - - if (response.statusCode() == HttpURLConnection.HTTP_OK) { - val goffResp = Const.DESERIALIZE_OBJECT_MAPPER.readValue(body, FlagConfigApiResponse.class); - result.setFlags(goffResp.getFlags()); - result.setEvaluationContextEnrichment(goffResp.getEvaluationContextEnrichment()); - } - - return result; } /** @@ -294,6 +238,31 @@ private Date extractLastUpdatedFromHeaders(final HttpResponse response) } } + /** + * route builds the URL of an API route from an arbitrary base, so that the data collector can be + * addressed somewhere other than the relay proxy. + * + * @param base - base URL of the route, already normalised by {@link #asBaseUri(String)} + * @param path - route path, relative to the base and without a leading slash + * @return the URL to call + */ + private static URI route(final URI base, final String path) { + return base.resolve(path.startsWith("/") ? path.substring(1) : path); + } + + /** + * asBaseUri normalises a configured URL into a base other paths can be resolved against. The + * trailing slash makes it directory-like, so resolving a relative path appends to any prefix it + * carries instead of replacing it. + * + * @param url - the configured URL + * @return the URL as a base + * @throws URISyntaxException - if the URL is not a valid URI + */ + private static URI asBaseUri(final String url) throws URISyntaxException { + return new URI(url.endsWith("/") ? url : url + "/"); + } + /** * prepareHttpRequest is preparing the request to be sent to the GO Feature Flag relay proxy. * @@ -302,18 +271,22 @@ private Date extractLastUpdatedFromHeaders(final HttpResponse response) * @return HttpRequest ready to be sent * @throws JsonProcessingException - if an error occurred while processing the json */ - private HttpRequest prepareHttpRequest(final URI url, final T requestBody) throws JsonProcessingException { + private HttpRequest prepareHttpRequest(final URI url, final T requestBody, final String... requestHeaders) + throws JsonProcessingException { HttpRequest.Builder reqBuilder = HttpRequest.newBuilder() .uri(url) .timeout(Duration.ofMillis(timeout)) - .header(Const.HTTP_HEADER_CONTENT_TYPE, Const.APPLICATION_JSON) .POST(HttpRequest.BodyPublishers.ofByteArray( Const.SERIALIZE_OBJECT_MAPPER.writeValueAsBytes(requestBody))); + this.customHeaders.forEach(reqBuilder::header); + reqBuilder.setHeader(Const.HTTP_HEADER_CONTENT_TYPE, Const.APPLICATION_JSON); + for (int i = 0; i + 1 < requestHeaders.length; i += 2) { + reqBuilder.setHeader(requestHeaders[i], requestHeaders[i + 1]); + } if (this.apiKey != null && !this.apiKey.isEmpty()) { - reqBuilder.header(Const.HTTP_HEADER_AUTHORIZATION, Const.BEARER_TOKEN + this.apiKey); + reqBuilder.setHeader(Const.HTTP_HEADER_API_KEY, this.apiKey); } - return reqBuilder.build(); } } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/FlagConfigApiResponse.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/FlagConfigApiResponse.java index 4d5c9d6bd9..5125779093 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/FlagConfigApiResponse.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/FlagConfigApiResponse.java @@ -1,17 +1,21 @@ package dev.openfeature.contrib.providers.gofeatureflag.api.bean; import com.fasterxml.jackson.annotation.JsonProperty; -import dev.openfeature.contrib.providers.gofeatureflag.bean.Flag; +import com.fasterxml.jackson.databind.JsonNode; import java.util.Map; import lombok.Data; /** * Represents the response body for the flag configuration API. + * + *

Flags are kept as raw JSON: the evaluation engine owns the flag schema, so deserialising it + * into a typed model here would silently drop any field a newer engine adds and hand a truncated + * flag to evaluation.

*/ @Data public class FlagConfigApiResponse { @JsonProperty("flags") - private Map flags; + private Map flags; @JsonProperty("evaluationContextEnrichment") private Map evaluationContextEnrichment; diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepRequest.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepRequest.java deleted file mode 100644 index 479e5f79e8..0000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.api.bean; - -import java.util.Map; -import lombok.Builder; -import lombok.Data; - -/** - * Represents the request body for the OFREP API request. - */ -@Data -@Builder -public class OfrepRequest { - private Map context; -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepResponse.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepResponse.java deleted file mode 100644 index e1246aa26b..0000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepResponse.java +++ /dev/null @@ -1,53 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.api.bean; - -import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; -import java.util.Map; -import lombok.Data; -import lombok.val; - -/** - * This class represents the response from an OFREP response. - */ -@Data -public class OfrepResponse { - private Object value; - private String key; - private String variant; - private String reason; - private boolean cacheable; - private Map metadata; - - private String errorCode; - private String errorDetails; - - /** - * Converts the OFREP response to a GO Feature Flag response. - * - * @return the converted GO Feature Flag response - */ - public GoFeatureFlagResponse toGoFeatureFlagResponse() { - val goff = new GoFeatureFlagResponse(); - goff.setValue(value); - goff.setVariationType(variant); - goff.setReason(reason); - goff.setErrorCode(errorCode); - goff.setErrorDetails(errorDetails); - goff.setFailed(errorCode != null); - - if (metadata != null) { - val cacheable = metadata.get("gofeatureflag_cacheable"); - if (cacheable instanceof Boolean) { - goff.setCacheable((Boolean) cacheable); - metadata.remove("gofeatureflag_cacheable"); - } - - val version = metadata.get("gofeatureflag_version"); - if (version instanceof String) { - goff.setVersion((String) version); - metadata.remove("gofeatureflag_version"); - } - goff.setMetadata(metadata); - } - return goff; - } -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ExperimentationRollout.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ExperimentationRollout.java deleted file mode 100644 index 8e6b3052a9..0000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ExperimentationRollout.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.bean; - -import java.util.Date; -import lombok.Data; - -/** - * This class represents the rollout of an experimentation. - */ -@Data -public class ExperimentationRollout { - private Date start; - private Date end; -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FeatureEvent.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FeatureEvent.java index ea8c48224d..a3fa01b2ed 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FeatureEvent.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FeatureEvent.java @@ -18,6 +18,7 @@ public class FeatureEvent implements IEvent { private String key; private String kind; + private String source; private String userKey; private Object value; private String variation; diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/Flag.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/Flag.java deleted file mode 100644 index e6ed6126fd..0000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/Flag.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.bean; - -import java.util.List; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * Flag is a class that represents a feature flag for GO Feature Flag. - */ -@EqualsAndHashCode(callSuper = true) -@Data -public class Flag extends FlagBase { - private List scheduledRollout; -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FlagBase.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FlagBase.java deleted file mode 100644 index ad301eadd8..0000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FlagBase.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.bean; - -import java.util.List; -import java.util.Map; -import lombok.Data; - -/** - * FlagBase is a class that represents the base structure of a feature flag for GO Feature Flag. - */ -@Data -public abstract class FlagBase { - private Map variations; - private List targeting; - private String bucketingKey; - private Rule defaultRule; - private ExperimentationRollout experimentation; - private Boolean trackEvents; - private Boolean disable; - private String version; - private Map metadata; -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FlagConfigResponse.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FlagConfigResponse.java index a373135aa1..4143f6e6cc 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FlagConfigResponse.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FlagConfigResponse.java @@ -1,5 +1,6 @@ package dev.openfeature.contrib.providers.gofeatureflag.bean; +import com.fasterxml.jackson.databind.JsonNode; import java.util.Date; import java.util.Map; import lombok.Builder; @@ -11,7 +12,7 @@ @Data @Builder public class FlagConfigResponse { - private Map flags; + private Map flags; private Map evaluationContextEnrichment; private String etag; private Date lastUpdated; diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ProgressiveRollout.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ProgressiveRollout.java deleted file mode 100644 index 6c39471e13..0000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ProgressiveRollout.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.bean; - -import lombok.Data; - -/** - * ProgressiveRollout is a class that represents the progressive rollout of a feature flag. - */ -@Data -public class ProgressiveRollout { - private ProgressiveRolloutStep initial; - private ProgressiveRolloutStep end; -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ProgressiveRolloutStep.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ProgressiveRolloutStep.java deleted file mode 100644 index 843becfd75..0000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ProgressiveRolloutStep.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.bean; - -import java.util.Date; -import lombok.Data; - -/** - * ProgressiveRolloutStep is a class that represents a step in the progressive rollout of a feature flag. - */ -@Data -public class ProgressiveRolloutStep { - private String variation; - private Float percentage; - private Date date; -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/Rule.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/Rule.java deleted file mode 100644 index dc7a59ad28..0000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/Rule.java +++ /dev/null @@ -1,17 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.bean; - -import java.util.Map; -import lombok.Data; - -/** - * This class represents a rule in the GO Feature Flag system. - */ -@Data -public class Rule { - private String name; - private String query; - private String variation; - private Map percentage; - private Boolean disable; - private ProgressiveRollout progressiveRollout; -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ScheduledStep.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ScheduledStep.java deleted file mode 100644 index ca9a5d219b..0000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ScheduledStep.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.bean; - -import java.util.Date; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * ScheduledStep is a class that represents a scheduled step in the rollout of a feature flag. - */ -@EqualsAndHashCode(callSuper = true) -@Data -public class ScheduledStep extends FlagBase { - private Date date; -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/TrackingEvent.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/TrackingEvent.java index b03fb02bb8..49299681d2 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/TrackingEvent.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/TrackingEvent.java @@ -33,7 +33,7 @@ public class TrackingEvent implements IEvent { private String userKey; /** - * CreationDate When the feature flag was requested at Unix epoch time in milliseconds. + * CreationDate When the event happened, at Unix epoch time in seconds. */ private Long creationDate; diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/IEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/IEvaluator.java index 78f1d7aed7..aeabe6fd01 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/IEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/IEvaluator.java @@ -1,7 +1,8 @@ package dev.openfeature.contrib.providers.gofeatureflag.evaluator; -import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; /** * IEvaluator is an interface that represents the evaluation of a feature flag. @@ -9,27 +10,79 @@ */ public interface IEvaluator { /** - * Initialize the evaluator. + * initialize is called when the provider is initialized for a specific domain. + * + * @param ctx - evaluation context + * @param domain - domain the provider is bound to + * @throws Exception - if the evaluator cannot be initialized + */ + void initialize(final EvaluationContext ctx, final String domain) throws Exception; + + /** + * initialize is called when the provider is initialized. + * + * @param ctx - evaluation context + * @throws Exception - if the evaluator cannot be initialized */ - void init(); + void initialize(final EvaluationContext ctx) throws Exception; /** - * Destroy the evaluator. + * shutdown releases everything the evaluator holds, so that it stops doing background work. + */ + void shutdown(); + + /** + * getBooleanEvaluation resolves the value of a boolean flag. + * + * @param key - name of the flag + * @param defaultValue - default value provided by the caller + * @param ctx - evaluation context + * @return the evaluation result for this flag + */ + ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx); + + /** + * getStringEvaluation resolves the value of a string flag. + * + * @param key - name of the flag + * @param defaultValue - default value provided by the caller + * @param ctx - evaluation context + * @return the evaluation result for this flag + */ + ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx); + + /** + * getIntegerEvaluation resolves the value of an integer flag. + * + * @param key - name of the flag + * @param defaultValue - default value provided by the caller + * @param ctx - evaluation context + * @return the evaluation result for this flag + */ + ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx); + + /** + * getDoubleEvaluation resolves the value of a float flag. + * + * @param key - name of the flag + * @param defaultValue - default value provided by the caller + * @param ctx - evaluation context + * @return the evaluation result for this flag */ - void destroy(); + ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx); /** - * Evaluate the flag. + * getObjectEvaluation resolves the value of a flag holding a structure. * - * @param key - name of the flag - * @param defaultValue - default value - * @param evaluationContext - evaluation context - * @return the evaluation response + * @param key - name of the flag + * @param defaultValue - default value provided by the caller + * @param ctx - evaluation context + * @return the evaluation result for this flag */ - GoFeatureFlagResponse evaluate(String key, Object defaultValue, EvaluationContext evaluationContext); + ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx); /** - * Check if the flag is trackable or not. + * isFlagTrackable returns true if we should collect the usage of this flag. * * @param flagKey - name of the flag * @return true if the flag is trackable, false otherwise diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index 4a63cb3adf..f81132f0ee 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -1,28 +1,45 @@ package dev.openfeature.contrib.providers.gofeatureflag.evaluator; +import static dev.openfeature.sdk.Value.objectToValue; + +import com.fasterxml.jackson.databind.JsonNode; import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; import dev.openfeature.contrib.providers.gofeatureflag.api.GoFeatureFlagApi; -import dev.openfeature.contrib.providers.gofeatureflag.bean.Flag; import dev.openfeature.contrib.providers.gofeatureflag.bean.FlagConfigResponse; import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import dev.openfeature.contrib.providers.gofeatureflag.util.JsonValueUtil; +import dev.openfeature.contrib.providers.gofeatureflag.util.MetadataUtil; import dev.openfeature.contrib.providers.gofeatureflag.wasm.WasmEvaluatorPool; import dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.FlagContext; import dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.WasmInput; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEvent; import dev.openfeature.sdk.ProviderEventDetails; import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.ExceptionUtils; +import dev.openfeature.sdk.exceptions.TypeMismatchError; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; import java.util.ArrayList; import java.util.Collections; import java.util.Date; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -32,61 +49,105 @@ */ @Slf4j public class InProcessEvaluator implements IEvaluator { + /** + * Raw engine error codes that make the engine, rather than the flag, the suspect: the relay proxy + * runs the same engine against the same flag and may well answer correctly. FLAG_CONFIG is + * deliberately absent, being a misconfiguration the relay proxy would reproduce identically. + */ + private static final Set FALLBACK_TRIGGERS = Set.of(ErrorCode.PARSE_ERROR.name(), ErrorCode.GENERAL.name()); + /** API to contact GO Feature Flag. */ private final GoFeatureFlagApi api; /** Pool of WASM evaluation engine instances for thread-safe concurrent evaluation. */ - private final WasmEvaluatorPool evaluationPool; + private volatile WasmEvaluatorPool evaluationPool; /** Options to configure the provider. */ private final GoFeatureFlagProviderOptions options; - /** Method to call when we have a configuration change. */ - private final Consumer emitProviderConfigurationChanged; + /** Method to call to emit a provider event to the SDK. */ + private final BiConsumer emitter; /** Immutable snapshot of all flag configuration state; updated atomically by the polling daemon. */ private volatile EvaluatorState state; /** disposable which manage the polling of the flag configurations. */ private Disposable configurationDisposable; + /** Number of refreshes that have failed in a row, reset by any successful one. */ + private final AtomicInteger consecutiveRefreshFailures = new AtomicInteger(); + /** true between the announcement of a stale configuration and the announcement of its end. */ + private final AtomicBoolean staleAnnounced = new AtomicBoolean(); + /** Builds the evaluator a failed local evaluation falls back to. */ + private final IEvaluator fallbackEvaluator; private static final class EvaluatorState { - final Map flags; + final Map flags; final Map evaluationContextEnrichment; final String etag; final Date lastUpdate; + /** + * true once a configuration has been retrieved and applied. A configuration carrying an empty + * flag map counts as loaded: an empty configuration is a valid one, not a missing one. + * The marker lives in the snapshot rather than beside it, so that readiness and the flag map + * it describes can never be read out of step. + */ + final boolean configurationLoaded; + + /** the state held until a configuration has been applied for the first time. */ + static EvaluatorState notLoaded() { + return new EvaluatorState(Collections.emptyMap(), null, "", new Date(0), false); + } EvaluatorState( - Map flags, + Map flags, Map evaluationContextEnrichment, String etag, Date lastUpdate) { + // only reachable from a retrieved configuration, so it is loaded by construction + this(flags, evaluationContextEnrichment, etag, lastUpdate, true); + } + + private EvaluatorState( + Map flags, + Map evaluationContextEnrichment, + String etag, + Date lastUpdate, + boolean configurationLoaded) { this.flags = flags; this.evaluationContextEnrichment = evaluationContextEnrichment; this.etag = etag; this.lastUpdate = lastUpdate; + this.configurationLoaded = configurationLoaded; } } /** * Constructor of the InProcessEvaluator. * - * @param api - API to contact GO Feature Flag - * @param options - options to configure the provider - * @param emitProviderConfigurationChanged - method to call when we have a configuration change + * @param api - API to contact GO Feature Flag + * @param options - options to configure the provider + * @param emitter - method to call to emit a provider event to the SDK */ public InProcessEvaluator( GoFeatureFlagApi api, GoFeatureFlagProviderOptions options, - Consumer emitProviderConfigurationChanged) { + BiConsumer emitter) { this.api = api; this.options = options; - this.emitProviderConfigurationChanged = emitProviderConfigurationChanged; - this.state = new EvaluatorState(Collections.emptyMap(), null, "", new Date(0)); - int poolSize = options.getWasmEvaluatorPoolSize() != null - ? options.getWasmEvaluatorPoolSize() - : Const.DEFAULT_WASM_EVALUATOR_POOL_SIZE; - this.evaluationPool = new WasmEvaluatorPool(poolSize); + this.emitter = emitter; + this.fallbackEvaluator = new RemoteEvaluator(options, emitter); + this.state = EvaluatorState.notLoaded(); + this.evaluationPool = new WasmEvaluatorPool(options.getWasmEvaluatorPoolSize()); } - @Override - public GoFeatureFlagResponse evaluate(String key, Object defaultValue, EvaluationContext evaluationContext) { + private GoFeatureFlagResponse evaluate(String key, Object defaultValue, EvaluationContext evaluationContext) { EvaluatorState current = this.state; + // With no configuration ever loaded every key is absent, and answering FLAG_NOT_FOUND would + // blame the caller's flag key for an infrastructure failure. + if (!current.configurationLoaded) { + val notReady = new GoFeatureFlagResponse(); + notReady.setReason(Reason.ERROR.name()); + notReady.setErrorCode(ErrorCode.PROVIDER_NOT_READY.name()); + notReady.setErrorDetails( + "impossible to evaluate flag " + key + ": no flag configuration has been loaded yet"); + notReady.setValue(defaultValue); + return notReady; + } if (current.flags.get(key) == null) { val err = new GoFeatureFlagResponse(); err.setReason(Reason.ERROR.name()); @@ -108,28 +169,300 @@ public GoFeatureFlagResponse evaluate(String key, Object defaultValue, Evaluatio } @Override - public boolean isFlagTrackable(final String flagKey) { - Flag flag = this.state.flags.get(flagKey); - return flag != null && (flag.getTrackEvents() == null || flag.getTrackEvents()); + public void initialize(EvaluationContext ctx, String domain) throws Exception { + this.initialize(ctx); } @Override - public void init() { - val configFlags = api.retrieveFlagConfiguration(this.state.etag, options.getEvaluationFlagList()); - this.state = new EvaluatorState( - configFlags.getFlags(), - configFlags.getEvaluationContextEnrichment(), - configFlags.getEtag(), - configFlags.getLastUpdated()); + public void initialize(EvaluationContext ctx) throws Exception { + // We ensure that no polling is happening before starting the initialization. + stopPolling(); + + // shutdown() closes the pool and the fallback, and the provider reuses this evaluator on re-init + if (this.evaluationPool.isClosed()) { + this.evaluationPool = new WasmEvaluatorPool(options.getWasmEvaluatorPoolSize()); + } + this.fallbackEvaluator.initialize(ctx); + + // an empty response means the configuration has not been modified, so the state we already + // hold is still current and must not be overwritten. + api.retrieveFlagConfiguration(this.state.etag, options.getEvaluationFlagList()) + .ifPresent(configFlags -> this.state = new EvaluatorState( + configFlags.getFlags(), + configFlags.getEvaluationContextEnrichment(), + configFlags.getEtag(), + configFlags.getLastUpdated())); + // this fetch is a successful refresh too, and it bypasses the polling chain entirely. The + // SDK announces readiness itself once initialize() returns, so nothing is emitted here. + this.consecutiveRefreshFailures.set(0); + this.staleAnnounced.set(false); // start the polling of the flag configuration this.configurationDisposable = startCheckFlagConfigurationChangesDaemon(); } @Override - public void destroy() { + public void shutdown() { + stopPolling(); + this.fallbackEvaluator.shutdown(); + this.evaluationPool.close(); + } + + @Override + public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { + return genericEvaluation(key, defaultValue, ctx, Boolean.class, IEvaluator::getBooleanEvaluation); + } + + @Override + public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { + return genericEvaluation(key, defaultValue, ctx, String.class, IEvaluator::getStringEvaluation); + } + + @Override + public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { + return genericEvaluation(key, defaultValue, ctx, Integer.class, IEvaluator::getIntegerEvaluation); + } + + @Override + public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { + return genericEvaluation(key, defaultValue, ctx, Double.class, IEvaluator::getDoubleEvaluation); + } + + @Override + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { + return genericEvaluation(key, defaultValue, ctx, Value.class, IEvaluator::getObjectEvaluation); + } + + /** + * genericEvaluation evaluates the flag and converts the engine response into the resolution + * structure expected by the OpenFeature SDK. + * + *

When the engine reports a failure that points at this provider rather than at the flag, the + * relay proxy is asked instead and its answer is the one the caller receives.

+ * + * @param key - name of the flag + * @param defaultValue - default value provided by the caller + * @param ctx - evaluation context + * @param expectedType - type the resolver called by the SDK is contracted to return + * @param remoteResolver - resolver of the fallback evaluator matching the type asked for + * @param - type of the flag value + * @return the evaluation result for this flag + */ + private ProviderEvaluation genericEvaluation( + final String key, + final T defaultValue, + final EvaluationContext ctx, + final Class expectedType, + final RemoteResolver remoteResolver) { + val response = this.evaluate(key, defaultValue, ctx); + // the raw code the engine emitted, read before toProviderEvaluation maps it onto the SDK + // enumeration, where GO Feature Flag's own codes are folded into GENERAL and stop being + // distinguishable from an engine failure. + if (FALLBACK_TRIGGERS.contains(response.getErrorCode())) { + log.warn( + "the engine could not evaluate flag {} ({}: {}), asking the relay proxy instead", + key, + response.getErrorCode(), + response.getErrorDetails()); + val remote = evaluateRemotely(key, defaultValue, ctx, remoteResolver); + if (remote.isPresent()) { + return remote.get(); + } + } + return toProviderEvaluation(key, defaultValue, response, expectedType); + } + + /** + * evaluateRemotely asks the relay proxy about a flag the engine could not evaluate. + * + *

A TYPE_MISMATCH is kept: the relay proxy did evaluate the flag, and its value does not fit the + * type asked for. An empty result means the relay proxy could not answer either, in which case the + * caller is owed the engine's error rather than the proxy's: the engine failing is the root cause, + * and the proxy merely failed to make up for it. The remote failure is logged here because it is about to + * disappear from the answer entirely.

+ * + * @param key - name of the flag + * @param defaultValue - default value provided by the caller + * @param ctx - evaluation context + * @param remoteResolver - resolver of the fallback evaluator matching the type asked for + * @param - type of the flag value + * @return the relay proxy's answer, or empty if it could not give one + */ + private Optional> evaluateRemotely( + final String key, + final T defaultValue, + final EvaluationContext ctx, + final RemoteResolver remoteResolver) { + try { + val remote = remoteResolver.resolve(this.fallbackEvaluator, key, defaultValue, ctx); + if (remote.getErrorCode() == null || remote.getErrorCode() == ErrorCode.TYPE_MISMATCH) { + return Optional.of(markEvaluatedRemotely(remote)); + } + log.error( + "the relay proxy could not evaluate flag {} either: {} {}", + key, + remote.getErrorCode(), + remote.getErrorMessage()); + } catch (Exception e) { + // the OFREP client raises on responses it cannot read at all, and an exception escaping + // here would replace the engine's error with one about the recovery attempt. + log.error("the relay proxy could not be asked about flag {}", key, e); + } + return Optional.empty(); + } + + /** + * markEvaluatedRemotely records in the flag metadata that the relay proxy produced this result. + * + *

The relay proxy's own metadata keys are kept: they describe the evaluation, which is the + * proxy's, and only the marker is this provider's to add.

+ * + * @param remote - answer the relay proxy gave + * @param - type of the flag value + * @return the same evaluation, carrying the marker + */ + private static ProviderEvaluation markEvaluatedRemotely(final ProviderEvaluation remote) { + val metadata = new LinkedHashMap(); + if (remote.getFlagMetadata() != null) { + metadata.putAll(remote.getFlagMetadata().asUnmodifiableMap()); + } + metadata.put(Const.METADATA_EVALUATED_REMOTELY, true); + remote.setFlagMetadata(MetadataUtil.convertFlagMetadata(metadata)); + return remote; + } + + @FunctionalInterface + private interface RemoteResolver { + ProviderEvaluation resolve(IEvaluator remote, String key, T defaultValue, EvaluationContext ctx); + } + + /** + * toProviderEvaluation converts an engine response into the resolution structure expected by + * the OpenFeature SDK. + * + *

It is separate from genericEvaluation so that the conversion can be specified against a + * response directly, independently of what the WASM engine can be made to emit. + * + * @param key - name of the flag + * @param defaultValue - default value provided by the caller + * @param response - response returned by the evaluation engine + * @param expectedType - type the resolver called by the SDK is contracted to return + * @param - type of the flag value + * @return the evaluation result for this flag + */ + static ProviderEvaluation toProviderEvaluation( + final String key, final T defaultValue, final GoFeatureFlagResponse response, final Class expectedType) { + if (response.getErrorCode() != null && !response.getErrorCode().isEmpty()) { + throw ExceptionUtils.instantiateErrorByErrorCode( + mapErrorCode(response.getErrorCode()), response.getErrorDetails()); + } + + if (Reason.DISABLED.name().equalsIgnoreCase(response.getReason())) { + // we don't set a variant since we are using the default value, + // and we are not able to know which variant it is. + return ProviderEvaluation.builder() + .value(defaultValue) + .variant(response.getVariationType()) + .reason(Reason.DISABLED.name()) + .flagMetadata(MetadataUtil.convertFlagMetadata(response.getMetadata())) + .build(); + } + + if (response.getValue() == null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(response.getReason()) + .variant(response.getVariationType()) + .flagMetadata(MetadataUtil.convertFlagMetadata(response.getMetadata())) + .build(); + } + + T flagValue = convertValue(response.getValue(), expectedType); + if (flagValue.getClass() != expectedType) { + throw new TypeMismatchError(String.format( + "Flag value %s had unexpected type %s, expected %s.", key, flagValue.getClass(), expectedType)); + } + + return ProviderEvaluation.builder() + .reason(response.getReason()) + .value(flagValue) + .variant(response.getVariationType()) + .flagMetadata(MetadataUtil.convertFlagMetadata(response.getMetadata())) + .build(); + } + + /** + * convertValue is converting the value returned by the evaluation engine in the right type. + * + * @param value - the value we have received + * @param expectedType - the type we expect for this value + * @param - the type we want to convert to + * @return a converted object + */ + // The cast to T is unchecked on purpose: toProviderEvaluation compares the runtime class with + // expectedType right after and raises a TypeMismatchError if the engine returned another type. + @SuppressWarnings("unchecked") + private static T convertValue(final Object value, final Class expectedType) { + boolean isPrimitive = expectedType == Boolean.class + || expectedType == String.class + || expectedType == Integer.class + || expectedType == Double.class; + + if (isPrimitive) { + // JSON does not distinguish 100 from 100.0, and a number too large for an int decodes to + // Long, so the float resolver accepts any number rather than only Integer. + if (expectedType == Double.class && value instanceof Number) { + return (T) Double.valueOf(((Number) value).doubleValue()); + } + return (T) value; + } + return (T) objectToValue(JsonValueUtil.widenBigIntegers(value)); + } + + /** + * mapErrorCode is mapping the error code in string received from the evaluation engine to the + * SDK ErrorCode enum. + * + * @param errorCode - string of the error code received from the evaluation engine + * @return an item from the enum, null if the engine reported no error + */ + private static ErrorCode mapErrorCode(final String errorCode) { + if (errorCode == null || errorCode.isEmpty()) { + return null; + } + + try { + return ErrorCode.valueOf(errorCode); + } catch (IllegalArgumentException e) { + // an error the SDK does not know about, such as GO Feature Flag's own FLAG_CONFIG, is + // still an error: reporting it as GENERAL keeps the evaluation from looking successful. + return ErrorCode.GENERAL; + } + } + + @Override + public boolean isFlagTrackable(final String flagKey) { + // trackEvents is the only field of the flag configuration a provider may read: everything + // else belongs to the evaluation engine and is passed through untouched. + JsonNode flag = this.state.flags.get(flagKey); + if (flag == null) { + return true; + } + JsonNode trackEvents = flag.get(Const.FIELD_TRACK_EVENTS); + return trackEvents == null || trackEvents.isNull() || trackEvents.asBoolean(true); + } + + /** + * stopPolling cancels the configuration polling task, if one is running. + * Once dispose() returns, the subscription is guaranteed to deliver no further emission to the + * refresh consumer, so a request still in flight can no longer reach the configuration state. The + * field is cleared so that the disposed subscription cannot be disposed, or mistaken for a live + * one, a second time. + */ + private synchronized void stopPolling() { if (this.configurationDisposable != null) { this.configurationDisposable.dispose(); + this.configurationDisposable = null; } } @@ -143,45 +476,181 @@ private Disposable startCheckFlagConfigurationChangesDaemon() { ? options.getFlagChangePollingIntervalMs() : Const.DEFAULT_POLLING_CONFIG_FLAG_CHANGE_INTERVAL_MS; - Observable intervalObservable = - Observable.interval(pollingIntervalMs, TimeUnit.MILLISECONDS, Schedulers.io()); - Observable apiCallObservable = intervalObservable - .flatMap(tick -> Observable.fromCallable(() -> - this.api.retrieveFlagConfiguration(this.state.etag, options.getEvaluationFlagList())) + Observable pollObservable = Observable.defer(() -> + Observable.timer(nextPollDelayMs(pollingIntervalMs), TimeUnit.MILLISECONDS, Schedulers.io())) + .repeat(); + Observable apiCallObservable = pollObservable + .flatMap(tick -> Observable.fromCallable(this::refreshFlagConfiguration) .onErrorResumeNext(e -> { log.error("error while calling flag configuration API", e); - return Observable.empty(); + emitRefreshFailureEvent(); + return Observable.>empty(); })) + // a 304 emits an empty Optional: drop it here so the refresh consumer below is + // structurally unable to write state for a response that carries no configuration. + .filter(Optional::isPresent) + .map(Optional::get) .subscribeOn(Schedulers.io()); return apiCallObservable.subscribe( response -> { - EvaluatorState current = this.state; - if (response.getEtag().equals(current.etag)) { - log.debug("flag configuration has not changed: {}", response); - return; + try { + applyFlagConfiguration(response); + } catch (Exception e) { + // an exception escaping the subscriber disposes it, which would stop polling + // for the lifetime of the provider rather than for this one refresh. + log.error("error while applying the flag configuration", e); } + }, + throwable -> log.error("flag configuration polling has stopped and will not resume", throwable)); + } - if (response.getLastUpdated().before(current.lastUpdate)) { - log.info("configuration received is older than the current one"); - return; - } + /** + * refreshFlagConfiguration fetches the flag configuration once and counts it as a successful refresh. + * + * @return the configuration, or an empty Optional when the relay proxy answered not modified + */ + private Optional refreshFlagConfiguration() { + val configuration = this.api.retrieveFlagConfiguration(this.state.etag, options.getEvaluationFlagList()); + emitRefreshSuccessEvent(); + return configuration; + } - log.info("flag configuration has changed"); - val flagChanges = findFlagConfigurationChanges(current.flags, response.getFlags()); - this.state = new EvaluatorState( - response.getFlags(), - response.getEvaluationContextEnrichment(), - response.getEtag(), - response.getLastUpdated()); - val changeDetails = ProviderEventDetails.builder() - .flagsChanged(flagChanges) - .message("flag configuration has changed") - .build(); - this.emitProviderConfigurationChanged.accept(changeDetails); - }, - throwable -> - log.error("error while calling flag configuration API, error: {}", throwable.getMessage())); + /** + * nextPollDelayMs is the polling interval with jitter applied, so that a fleet restarted together + * does not poll the relay proxy in lockstep for as long as it stays up. + * + * @param pollingIntervalMs - the configured polling interval + * @return the interval, randomly shortened or lengthened by up to {@link Const#POLLING_JITTER_RATIO} + */ + @SuppressFBWarnings(value = "PREDICTABLE_RANDOM", justification = "the poll jitter is not security-relevant") + static long nextPollDelayMs(final long pollingIntervalMs) { + return (long) (pollingIntervalMs + * ThreadLocalRandom.current() + .nextDouble(1 - Const.POLLING_JITTER_RATIO, 1 + Const.POLLING_JITTER_RATIO)); + } + + /** + * emitRefreshFailureEvent counts a failed refresh and marks the configuration stale once enough of + * them have happened in a row. + */ + private void emitRefreshFailureEvent() { + if (this.consecutiveRefreshFailures.incrementAndGet() != Const.STALE_AFTER_CONSECUTIVE_FAILURES) { + return; + } + + log.warn( + "{} consecutive failed refreshes, still serving the last known good configuration", + Const.STALE_AFTER_CONSECUTIVE_FAILURES); + this.staleAnnounced.set(true); + try { + this.emitter.accept( + ProviderEvent.PROVIDER_STALE, + ProviderEventDetails.builder() + .message("the flag configuration could not be refreshed " + + Const.STALE_AFTER_CONSECUTIVE_FAILURES + " times in a row") + .build()); + } catch (Exception e) { + log.error("error while emitting the {} event", ProviderEvent.PROVIDER_STALE, e); + } + } + + /** + * emitRefreshSuccessEvent counts a refresh that worked and, if the configuration had been announced + * as stale, announces that it no longer is. + */ + private void emitRefreshSuccessEvent() { + this.consecutiveRefreshFailures.set(0); + if (!this.staleAnnounced.compareAndSet(true, false)) { + return; + } + + log.info("the flag configuration could be refreshed again"); + try { + this.emitter.accept( + ProviderEvent.PROVIDER_READY, + ProviderEventDetails.builder() + .message("the flag configuration could be refreshed again") + .build()); + } catch (Exception e) { + log.error("error while emitting the {} event", ProviderEvent.PROVIDER_READY, e); + } + } + + /** + * applyFlagConfiguration replaces the configuration state with a newly retrieved one, unless the + * response describes the configuration already held or an older one. + * + *

Both validators are optional: a relay proxy behind a cache or a reverse proxy may answer + * without an {@code ETag} or with a {@code Last-Modified} this provider cannot parse, so a null + * on either side means "cannot rule this response out" rather than a comparison. + * + * @param response - configuration returned by the last successful refresh + */ + private void applyFlagConfiguration(final FlagConfigResponse response) { + EvaluatorState current = this.state; + if (response.getEtag() != null && response.getEtag().equals(current.etag)) { + log.debug("flag configuration has not changed: {}", response); + return; + } + + if (response.getLastUpdated() != null + && current.lastUpdate != null + && response.getLastUpdated().before(current.lastUpdate)) { + log.info("configuration received is older than the current one"); + return; + } + + val flagChanges = + enrichmentChanged(current.evaluationContextEnrichment, response.getEvaluationContextEnrichment()) + ? allFlagKeys(current.flags, response.getFlags()) + : findFlagConfigurationChanges(current.flags, response.getFlags()); + this.state = new EvaluatorState( + response.getFlags(), + response.getEvaluationContextEnrichment(), + response.getEtag(), + response.getLastUpdated()); + + if (flagChanges.isEmpty()) { + log.debug("flag configuration has not changed: {}", response); + return; + } + + log.info("flag configuration has changed"); + val changeDetails = ProviderEventDetails.builder() + .flagsChanged(flagChanges) + .message("flag configuration has changed") + .build(); + this.emitter.accept(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, changeDetails); + } + + /** + * enrichmentChanged reports whether the evaluation context enrichment differs between two + * configurations. A null enrichment is the same as an empty one. + * + * @param original - enrichment currently in use + * @param updated - enrichment of the new configuration + * @return true if the enrichment has changed + */ + private static boolean enrichmentChanged(final Map original, final Map updated) { + return !Optional.ofNullable(original) + .orElse(Collections.emptyMap()) + .equals(Optional.ofNullable(updated).orElse(Collections.emptyMap())); + } + + /** + * allFlagKeys lists every flag of either configuration: a new enrichment can change the result of + * any of them. + * + * @param originalFlags - list of original flags + * @param newFlags - list of new flags + * @return the keys of every flag in either configuration + */ + private static List allFlagKeys( + final Map originalFlags, final Map newFlags) { + Set keys = new LinkedHashSet<>(newFlags.keySet()); + keys.addAll(originalFlags.keySet()); + return new ArrayList<>(keys); } /** @@ -192,16 +661,16 @@ private Disposable startCheckFlagConfigurationChangesDaemon() { * @return - list of flags that have changed */ private List findFlagConfigurationChanges( - final Map originalFlags, final Map newFlags) { + final Map originalFlags, final Map newFlags) { // this function should return a list of flags that have changed between the two maps // it should contain all updated, added and removed flags List changedFlags = new ArrayList<>(); // Find added or updated flags - for (Map.Entry entry : newFlags.entrySet()) { + for (Map.Entry entry : newFlags.entrySet()) { String key = entry.getKey(); - Flag newFlag = entry.getValue(); - Flag originalFlag = originalFlags.get(key); + JsonNode newFlag = entry.getValue(); + JsonNode originalFlag = originalFlags.get(key); if (originalFlag == null || !originalFlag.equals(newFlag)) { changedFlags.add(key); diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java index 15b60c5f85..931afdf0b0 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java @@ -1,9 +1,23 @@ package dev.openfeature.contrib.providers.gofeatureflag.evaluator; -import dev.openfeature.contrib.providers.gofeatureflag.api.GoFeatureFlagApi; -import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import dev.openfeature.contrib.providers.ofrep.OfrepProvider; +import dev.openfeature.contrib.providers.ofrep.OfrepProviderOptions; +import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEvent; +import dev.openfeature.sdk.ProviderEventDetails; +import dev.openfeature.sdk.Value; +import java.time.Duration; +import java.util.HashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; import lombok.extern.slf4j.Slf4j; +import lombok.val; /** * RemoteEvaluator is an implementation of the IEvaluator interface. @@ -11,21 +25,56 @@ */ @Slf4j public class RemoteEvaluator implements IEvaluator { - /** API to contact GO Feature Flag. */ - public final GoFeatureFlagApi api; + /** + * Start of the error message the OFREP client reports for a 401 or 403. That client folds every + * status into a GENERAL error code, so the message is the only part of the result that still + * distinguishes rejected credentials from an ordinary failure. + */ + private static final String OFREP_AUTHENTICATION_ERROR = "authentication/authorization error for flag:"; + + /** Options to configure the provider, kept to rebuild the OFREP provider after a shutdown. */ + private final GoFeatureFlagProviderOptions options; + /** OFREP provider doing the actual remote evaluation. */ + private volatile OfrepProvider ofrep; + /** true once shutdown() has stopped the OFREP provider, which cannot be restarted. */ + private volatile boolean ofrepShutDown; + /** Method to call to emit a provider event to the SDK. */ + private final BiConsumer emitter; + /** Guards against re-reporting the same authentication failure on every later evaluation. */ + private final AtomicBoolean authenticationFailureReported = new AtomicBoolean(); /** * Constructor of the evaluator. * - * @param api - api service to evaluate the flags + * @param opts - options to configure the provider + * @param emitter - method to call to emit a provider event to the SDK */ - public RemoteEvaluator(GoFeatureFlagApi api) { - this.api = api; + public RemoteEvaluator(GoFeatureFlagProviderOptions opts, BiConsumer emitter) { + this.options = opts; + this.emitter = emitter; + this.ofrep = newOfrepProvider(opts); } - @Override - public GoFeatureFlagResponse evaluate(String key, Object defaultValue, EvaluationContext evaluationContext) { - return this.api.evaluateFlag(key, evaluationContext); + /** + * newOfrepProvider builds the OFREP provider from the provider options. + * + * @param opts - options to configure the provider + * @return a new OFREP provider + */ + private static OfrepProvider newOfrepProvider(final GoFeatureFlagProviderOptions opts) { + val headers = new HashMap>(); + opts.getCustomHeaders().forEach((name, value) -> headers.put(name, ImmutableList.of(value))); + if (opts.getApiKey() != null && !opts.getApiKey().isEmpty()) { + headers.keySet().removeIf(Const.HTTP_HEADER_API_KEY::equalsIgnoreCase); + headers.put(Const.HTTP_HEADER_API_KEY, ImmutableList.of(opts.getApiKey())); + } + + return OfrepProvider.constructProvider(OfrepProviderOptions.builder() + .baseUrl(opts.getEndpoint().replaceAll("/+$", "")) + .connectTimeout(Duration.ofMillis(opts.getTimeout())) + .requestTimeout(Duration.ofMillis(opts.getTimeout())) + .headers(ImmutableMap.copyOf(headers)) + .build()); } @Override @@ -34,12 +83,89 @@ public boolean isFlagTrackable(String flagKey) { } @Override - public void init() { - // do nothing + public void initialize(final EvaluationContext ctx, final String domain) throws Exception { + restartAfterShutdown(); + this.ofrep.initialize(ctx, domain); } @Override - public void destroy() { - // do nothing + public void initialize(final EvaluationContext ctx) throws Exception { + restartAfterShutdown(); + this.ofrep.initialize(ctx); + } + + /** + * restartAfterShutdown prepares the evaluator for a new initialization. The OFREP provider's + * shutdown terminates the executor its HTTP client runs on, so a shut down one is replaced. + */ + private void restartAfterShutdown() { + this.authenticationFailureReported.set(false); + if (this.ofrepShutDown) { + this.ofrep = newOfrepProvider(this.options); + this.ofrepShutDown = false; + } + } + + @Override + public void shutdown() { + this.ofrepShutDown = true; + this.ofrep.shutdown(); + } + + @Override + public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { + return reportAuthenticationFailure(this.ofrep.getBooleanEvaluation(key, defaultValue, ctx)); + } + + @Override + public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { + return reportAuthenticationFailure(this.ofrep.getStringEvaluation(key, defaultValue, ctx)); + } + + @Override + public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { + return reportAuthenticationFailure(this.ofrep.getIntegerEvaluation(key, defaultValue, ctx)); + } + + @Override + public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { + return reportAuthenticationFailure(this.ofrep.getDoubleEvaluation(key, defaultValue, ctx)); + } + + @Override + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { + return reportAuthenticationFailure(this.ofrep.getObjectEvaluation(key, defaultValue, ctx)); + } + + /** + * reportAuthenticationFailure moves the provider to the fatal state when the relay proxy has + * rejected our credentials. + * + *

Remote evaluation holds no configuration, so initialization has nothing to fetch and cannot + * discover that the API key is wrong. The first evaluation is therefore the earliest point at + * which the provider can learn it, and rejected credentials cannot be repaired by retrying, so + * the SDK must be told rather than left reporting a per-call error forever.

+ * + * @param evaluation - result returned by the OFREP client + * @param - type of the flag value + * @return the evaluation, unchanged + */ + private ProviderEvaluation reportAuthenticationFailure(final ProviderEvaluation evaluation) { + if (evaluation.getErrorCode() != ErrorCode.GENERAL + || evaluation.getErrorMessage() == null + || !evaluation.getErrorMessage().startsWith(OFREP_AUTHENTICATION_ERROR)) { + return evaluation; + } + + if (this.authenticationFailureReported.compareAndSet(false, true)) { + log.error("the relay proxy rejected our credentials, the provider cannot recover by retrying"); + this.emitter.accept( + ProviderEvent.PROVIDER_ERROR, + ProviderEventDetails.builder() + .errorCode(ErrorCode.PROVIDER_FATAL) + .message("authentication/authorization error while evaluating a flag remotely") + .build()); + } + return evaluation; } } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/AuthenticationFailure.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/AuthenticationFailure.java new file mode 100644 index 0000000000..ccd3b682e0 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/AuthenticationFailure.java @@ -0,0 +1,14 @@ +package dev.openfeature.contrib.providers.gofeatureflag.exception; + +import dev.openfeature.sdk.exceptions.FatalError; +import lombok.experimental.StandardException; + +/** + * Thrown when the relay proxy rejects our credentials (HTTP 401 or 403). + * + *

It extends the SDK's FatalError, whose error code is PROVIDER_FATAL, because that is what the + * SDK inspects to move the provider to the FATAL state: credentials cannot be repaired by retrying, + * so a provider that keeps retrying them would never recover and would hide the real problem.

+ */ +@StandardException +public class AuthenticationFailure extends FatalError {} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java index 4ea6101e40..c04766e390 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java @@ -2,14 +2,14 @@ import dev.openfeature.contrib.providers.gofeatureflag.bean.FeatureEvent; import dev.openfeature.contrib.providers.gofeatureflag.bean.IEvent; +import dev.openfeature.contrib.providers.gofeatureflag.evaluator.IEvaluator; import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; -import dev.openfeature.contrib.providers.gofeatureflag.service.EvaluationService; import dev.openfeature.contrib.providers.gofeatureflag.service.EventsPublisher; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; import dev.openfeature.contrib.providers.gofeatureflag.util.EvaluationContextUtil; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.Hook; import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.Reason; import java.util.Map; import lombok.extern.slf4j.Slf4j; @@ -19,12 +19,10 @@ */ @Slf4j public final class DataCollectorHook implements Hook> { - /** options contains all the options of this hook. */ - private final DataCollectorHookOptions options; /** eventsPublisher is the system collecting all the information to send to GO Feature Flag. */ private final EventsPublisher eventsPublisher; - /** evalService is the service to evaluate the flags. */ - private final EvaluationService evalService; + /** evaluator is the service to evaluate the flags. */ + private final IEvaluator evaluator; /** * Constructor of the hook. @@ -38,46 +36,69 @@ public DataCollectorHook(final DataCollectorHookOptions options) throws InvalidO } options.validate(); eventsPublisher = options.getEventsPublisher(); - evalService = options.getEvalService(); - this.options = options; + evaluator = options.getEvaluator(); } @Override public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) { - if (!this.evalService.isFlagTrackable(ctx.getFlagKey()) - || (!Boolean.TRUE.equals(this.options.getCollectUnCachedEvaluation()) - && !Reason.CACHED.name().equals(details.getReason()))) { + // the relay proxy evaluated this flag itself and recorded it server side as it did so + if (wasEvaluatedRemotely(details) || !this.evaluator.isFlagTrackable(ctx.getFlagKey())) { return; } IEvent event = FeatureEvent.builder() .key(ctx.getFlagKey()) .kind("feature") - .contextKind(EvaluationContextUtil.isAnonymousUser(ctx.getCtx()) ? "anonymousUser" : "user") + .source("INPROCESS") + .contextKind(EvaluationContextUtil.contextKind(ctx.getCtx())) .defaultValue(false) - .variation(details.getVariant()) + .variation(details.getVariant() != null ? details.getVariant() : "SdkDefault") .value(details.getValue()) - .userKey(ctx.getCtx().getTargetingKey()) + .userKey(EvaluationContextUtil.userKey(ctx.getCtx())) .creationDate(System.currentTimeMillis() / 1000L) .build(); eventsPublisher.add(event); } + /** + * finallyAfter records a failed evaluation. It stands in for the error stage, which only sees the + * exception and so cannot tell that the relay proxy produced the result. + */ @Override - public void error(HookContext ctx, Exception error, Map hints) { + public void finallyAfter(HookContext ctx, FlagEvaluationDetails details, Map hints) { + if (details.getErrorCode() == null + || wasEvaluatedRemotely(details) + || !this.evaluator.isFlagTrackable(ctx.getFlagKey())) { + return; + } + IEvent event = FeatureEvent.builder() .key(ctx.getFlagKey()) .kind("feature") - .contextKind(EvaluationContextUtil.isAnonymousUser(ctx.getCtx()) ? "anonymousUser" : "user") + .source("INPROCESS") + .contextKind(EvaluationContextUtil.contextKind(ctx.getCtx())) .creationDate(System.currentTimeMillis() / 1000L) .defaultValue(true) .variation("SdkDefault") .value(ctx.getDefaultValue()) - .userKey(ctx.getCtx().getTargetingKey()) + .userKey(EvaluationContextUtil.userKey(ctx.getCtx())) .build(); eventsPublisher.add(event); } + /** + * wasEvaluatedRemotely reports whether this result came from the relay proxy rather than the + * local engine. The relay proxy recorded such a result itself, so recording it here too would + * count it twice. + * + * @param details - result the SDK is about to hand to the caller + * @return true if the relay proxy produced this result + */ + private static boolean wasEvaluatedRemotely(final FlagEvaluationDetails details) { + return details.getFlagMetadata() != null + && Boolean.TRUE.equals(details.getFlagMetadata().getBoolean(Const.METADATA_EVALUATED_REMOTELY)); + } + /** shutdown should be called when we stop the hook, it will publish the remaining event. */ public void shutdown() { // eventsPublisher is required so no need to check if it is null diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java index ffb67400ae..608fed7c76 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java @@ -1,8 +1,8 @@ package dev.openfeature.contrib.providers.gofeatureflag.hook; import dev.openfeature.contrib.providers.gofeatureflag.bean.IEvent; +import dev.openfeature.contrib.providers.gofeatureflag.evaluator.IEvaluator; import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; -import dev.openfeature.contrib.providers.gofeatureflag.service.EvaluationService; import dev.openfeature.contrib.providers.gofeatureflag.service.EventsPublisher; import lombok.Builder; import lombok.Getter; @@ -14,20 +14,15 @@ @Builder @Getter public class DataCollectorHookOptions { - /** - * collectUnCachedEvent (optional) set to true if you want to send all events not only the cached - * evaluations. - */ - private Boolean collectUnCachedEvaluation; /** * eventsPublisher is the system collecting all the information to send to GO Feature Flag. */ private EventsPublisher eventsPublisher; /** - * evalService is the service to evaluate the flags. + * evaluator is used to know whether the usage of a flag should be collected. */ - private EvaluationService evalService; + private IEvaluator evaluator; /** * Validate the options provided to the data collector hook. @@ -38,5 +33,8 @@ public void validate() throws InvalidOptions { if (getEventsPublisher() == null) { throw new InvalidOptions("No events publisher provided"); } + if (getEvaluator() == null) { + throw new InvalidOptions("No evaluator provided"); + } } } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHook.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHook.java index 68bfa2d93e..9300f6b162 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHook.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHook.java @@ -9,11 +9,14 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; +import lombok.val; /** * EnrichEvaluationContextHook is an OpenFeature Hook in charge of enriching the evaluation context. */ public class EnrichEvaluationContextHook implements Hook { + private static final String GOFEATUREFLAG_KEY = "gofeatureflag"; + private static final String EXPORTER_METADATA_KEY = "exporterMetadata"; private final Map exporterMetadata; public EnrichEvaluationContextHook(Map exporterMetadata) { @@ -29,10 +32,10 @@ public Optional before(HookContext ctx, Map entry : exporterMetadata.entrySet()) { switch (entry.getValue().getClass().getSimpleName()) { case "String": @@ -53,9 +56,13 @@ public Optional before(HookContext ctx, Map expMetadata = new HashMap<>(); - expMetadata.put("exporterMetadata", new Value(metadata)); - mutableContext.add("gofeatureflag", new MutableStructure(expMetadata)); + Map goffNamespace = new HashMap<>(); + val existing = ctx.getCtx().getValue(GOFEATUREFLAG_KEY); + if (existing != null && existing.isStructure()) { + goffNamespace.putAll(existing.asStructure().asMap()); + } + goffNamespace.put(EXPORTER_METADATA_KEY, new Value(metadata)); + mutableContext.add(GOFEATUREFLAG_KEY, new MutableStructure(goffNamespace)); return Optional.of(mutableContext); } } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationService.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationService.java deleted file mode 100644 index da452d39f7..0000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationService.java +++ /dev/null @@ -1,155 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.service; - -import static dev.openfeature.sdk.Value.objectToValue; - -import dev.openfeature.contrib.providers.gofeatureflag.evaluator.IEvaluator; -import dev.openfeature.contrib.providers.gofeatureflag.util.MetadataUtil; -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.ProviderEvaluation; -import dev.openfeature.sdk.Reason; -import dev.openfeature.sdk.exceptions.FlagNotFoundError; -import dev.openfeature.sdk.exceptions.TargetingKeyMissingError; -import dev.openfeature.sdk.exceptions.TypeMismatchError; -import lombok.AllArgsConstructor; -import lombok.val; - -/** - * EvaluationService is responsible for evaluating feature flags using the provided evaluator. - * It can use different evaluators based on the configuration and context. - */ -@AllArgsConstructor -public class EvaluationService { - /** - * The evaluator used to evaluate the flags. - */ - private IEvaluator evaluator; - - /** - * Return true if we should track the usage of the flag. - * - * @param flagKey - name of the flag - * @return true if the flag is trackable, false otherwise - */ - public boolean isFlagTrackable(final String flagKey) { - return this.evaluator.isFlagTrackable(flagKey); - } - - /** - * Init the evaluator. - */ - public void init() { - this.evaluator.init(); - } - - /** - * Destroy the evaluator. - */ - public void destroy() { - this.evaluator.destroy(); - } - - /** - * Get the evaluation response from the evaluator. - * - * @param flagKey - name of the flag - * @param defaultValue - default value - * @param evaluationContext - evaluation context - * @param expectedType - expected type of the value - * @param - type of the value - * @return the evaluation response - */ - public ProviderEvaluation getEvaluation( - String flagKey, T defaultValue, EvaluationContext evaluationContext, Class expectedType) { - - if (evaluationContext.getTargetingKey() == null) { - throw new TargetingKeyMissingError("GO Feature Flag requires a targeting key"); - } - - val goffResp = evaluator.evaluate(flagKey, defaultValue, evaluationContext); - - // Check for FLAG_NOT_FOUND error first, before general error handling - if (goffResp.getErrorCode() != null - && ErrorCode.FLAG_NOT_FOUND.name().equalsIgnoreCase(goffResp.getErrorCode())) { - throw new FlagNotFoundError("Flag " + flagKey + " was not found in your configuration"); - } - - // If we have an error code, we return the error directly. - if (goffResp.getErrorCode() != null && !goffResp.getErrorCode().isEmpty()) { - return ProviderEvaluation.builder() - .errorCode(mapErrorCode(goffResp.getErrorCode())) - .errorMessage(goffResp.getErrorDetails()) - .reason(Reason.ERROR.name()) - .value(defaultValue) - .build(); - } - - if (Reason.DISABLED.name().equalsIgnoreCase(goffResp.getReason())) { - // we don't set a variant since we are using the default value, - // and we are not able to know which variant it is. - return ProviderEvaluation.builder() - .value(defaultValue) - .variant(goffResp.getVariationType()) - .reason(Reason.DISABLED.name()) - .build(); - } - - // Convert the value received from the API. - T flagValue = convertValue(goffResp.getValue(), expectedType); - - if (flagValue.getClass() != expectedType) { - throw new TypeMismatchError(String.format( - "Flag value %s had unexpected type %s, expected %s.", flagKey, flagValue.getClass(), expectedType)); - } - - return ProviderEvaluation.builder() - .errorCode(mapErrorCode(goffResp.getErrorCode())) - .reason(goffResp.getReason()) - .value(flagValue) - .variant(goffResp.getVariationType()) - .flagMetadata(MetadataUtil.convertFlagMetadata(goffResp.getMetadata())) - .build(); - } - - /** - * convertValue is converting the object return by the proxy response in the right type. - * - * @param value - The value we have received - * @param expectedType - the type we expect for this value - * @param the type we want to convert to. - * @return A converted object - */ - private T convertValue(Object value, Class expectedType) { - boolean isPrimitive = expectedType == Boolean.class - || expectedType == String.class - || expectedType == Integer.class - || expectedType == Double.class; - - if (isPrimitive) { - if (value.getClass() == Integer.class && expectedType == Double.class) { - return (T) Double.valueOf((Integer) value); - } - return (T) value; - } - return (T) objectToValue(value); - } - - /** - * mapErrorCode is mapping the errorCode in string received by the API to our internal SDK - * ErrorCode enum. - * - * @param errorCode - string of the errorCode received from the API - * @return an item from the enum - */ - private ErrorCode mapErrorCode(String errorCode) { - if (errorCode == null || errorCode.isEmpty()) { - return null; - } - - try { - return ErrorCode.valueOf(errorCode); - } catch (IllegalArgumentException e) { - return null; - } - } -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java index 32e1272f7a..42b1967968 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -27,13 +28,18 @@ public final class EventsPublisher { public final AtomicBoolean isShutdown = new AtomicBoolean(false); private final int maxPendingEvents; private final Consumer> publisher; + /** true while a batch is being posted, so that a second publish skips rather than overlapping it. */ + private final AtomicBoolean publishing = new AtomicBoolean(false); + /** true while a flush of a full buffer is queued on the scheduler and has not started yet. */ + private final AtomicBoolean flushRequested = new AtomicBoolean(false); private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); private final Lock readLock = readWriteLock.readLock(); private final Lock writeLock = readWriteLock.writeLock(); - private final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1); + private final long flushIntervalMs; private final List eventsList; + private volatile ScheduledExecutorService scheduledExecutorService; /** * Constructor. @@ -47,6 +53,21 @@ public EventsPublisher(Consumer> publisher, long flushIntervalMs, int ma eventsList = new CopyOnWriteArrayList<>(); this.publisher = publisher; this.maxPendingEvents = maxPendingEvents; + this.flushIntervalMs = flushIntervalMs; + start(); + } + + /** + * start schedules the periodic flush. + * Calling start() on a running publisher does nothing, so it is safe to call from both the + * constructor and provider initialization. + */ + public synchronized void start() { + if (scheduledExecutorService != null && !scheduledExecutorService.isShutdown()) { + return; + } + isShutdown.set(false); + scheduledExecutorService = Executors.newScheduledThreadPool(1); log.debug("Scheduling events publishing at fixed rate of {} milliseconds", flushIntervalMs); scheduledExecutorService.scheduleAtFixedRate( this::publish, flushIntervalMs, flushIntervalMs, TimeUnit.MILLISECONDS); @@ -73,50 +94,121 @@ public void add(T event) { } if (shouldPublish) { - log.warn("events collection is full. Publishing before adding new events."); - publish(); + requestFlush(); } try { writeLock.lock(); if (eventsList != null) { eventsList.add(event); + discardOverflow(); } } finally { writeLock.unlock(); } } + /** + * requestFlush has the scheduler's thread publish the buffer, so the thread adding an event, often + * one evaluating a flag, never waits for the data collector. Requests made while one is already + * queued are merged into it. + */ + private void requestFlush() { + if (!flushRequested.compareAndSet(false, true)) { + return; + } + log.warn("events collection is full, publishing it"); + try { + scheduledExecutorService.execute(() -> { + flushRequested.set(false); + publish(); + }); + } catch (RejectedExecutionException e) { + flushRequested.set(false); + log.debug("the publisher is shutting down, the final drain will publish the buffer"); + } + } + + /** + * discardOverflow keeps the buffer within twice maxPendingEvents, dropping the oldest events + * first. Without a cap a data collector outage is an unbounded memory leak, and the oldest + * events are the least useful to keep. + * + *

Callers must hold {@link #writeLock}.

+ */ + private void discardOverflow() { + long overflow = eventsList.size() - (2L * maxPendingEvents); + if (overflow > 0) { + log.warn("events buffer is full, discarding the {} oldest events", overflow); + eventsList.subList(0, (int) overflow).clear(); + } + } + /** * publish events. * * @return count of publish events */ public int publish() { - int publishedEvents = 0; + if (!publishing.compareAndSet(false, true)) { + log.debug("a publish is already in progress, skipping this one"); + return 0; + } + try { + return drainAndPost(); + } finally { + publishing.set(false); + } + } + + /** + * drainAndPost swaps the buffer out under the lock, releases it, and only then posts, so the + * data collector's availability cannot hold up an evaluation. A batch that fails to publish goes + * back to the head of the buffer, keeping the events in chronological order. + * + *

Callers must have set {@link #publishing}, or have stopped the scheduler.

+ */ + private int drainAndPost() { + List batch; writeLock.lock(); try { if (eventsList.isEmpty()) { log.debug("Not publishing, no events"); - return publishedEvents; + return 0; } - log.info("publishing {} events", eventsList.size()); - publisher.accept(new ArrayList<>(eventsList)); - publishedEvents = eventsList.size(); + batch = new ArrayList<>(eventsList); eventsList.clear(); - return publishedEvents; + } finally { + writeLock.unlock(); + } + + try { + log.info("publishing {} events", batch.size()); + publisher.accept(batch); + return batch.size(); } catch (Exception e) { log.error("Error publishing events", e); + writeLock.lock(); + try { + eventsList.addAll(0, batch); + discardOverflow(); + } finally { + writeLock.unlock(); + } return 0; - } finally { - writeLock.unlock(); } } - /** Shutdown. */ - public void shutdown() { + /** + * Shutdown: stop accepting events, stop the scheduler, letting a publish in progress finish, then + * drain what is buffered. + */ + public synchronized void shutdown() { log.info("shutdown, draining remaining events"); - publish(); - ConcurrentUtil.shutdownAndAwaitTermination(scheduledExecutorService, 10); + isShutdown.set(true); + if (scheduledExecutorService != null) { + ConcurrentUtil.shutdownAndAwaitTermination(scheduledExecutorService, 10); + } + drainAndPost(); } } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java index 6b3c6a326c..8c3bccb238 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java @@ -10,24 +10,43 @@ */ public class Const { // HTTP - public static final String BEARER_TOKEN = "Bearer "; public static final String APPLICATION_JSON = "application/json; charset=utf-8"; public static final String HTTP_HEADER_CONTENT_TYPE = "Content-Type"; - public static final String HTTP_HEADER_AUTHORIZATION = "Authorization"; + public static final String HTTP_HEADER_API_KEY = "X-API-Key"; public static final String HTTP_HEADER_ETAG = "ETag"; public static final String HTTP_HEADER_IF_NONE_MATCH = "If-None-Match"; public static final String HTTP_HEADER_LAST_MODIFIED = "Last-Modified"; + // API ROUTES (relative to the configured endpoint, so that a path prefix on the endpoint survives) + public static final String PATH_FLAG_CONFIGURATION = "v1/flag/configuration"; + public static final String PATH_DATA_COLLECTOR = "v1/data/collector"; + // FLAG CONFIGURATION FIELDS + // the only field of a flag configuration a provider may read, everything else is the engine's + public static final String FIELD_TRACK_EVENTS = "trackEvents"; + // FLAG METADATA KEYS + public static final String METADATA_EVALUATED_REMOTELY = "gofeatureflag_evaluated_remotely"; + // EXPORTER METADATA KEYS + // the collector groups by provider, so the value must not change between releases + public static final String METADATA_PROVIDER = "provider"; + public static final String METADATA_OPENFEATURE = "openfeature"; + // EVENT FIELDS + public static final String UNDEFINED_TARGETING_KEY = "undefined-targetingKey"; // DEFAULT VALUES public static final long DEFAULT_POLLING_CONFIG_FLAG_CHANGE_INTERVAL_MS = 2L * 60L * 1000L; public static final long DEFAULT_FLUSH_INTERVAL_MS = Duration.ofMinutes(1).toMillis(); public static final int DEFAULT_MAX_PENDING_EVENTS = 10000; public static final int DEFAULT_WASM_EVALUATOR_POOL_SIZE = Runtime.getRuntime().availableProcessors(); + /** consecutive failed refreshes after which the configuration is announced as stale. */ + public static final int STALE_AFTER_CONSECUTIVE_FAILURES = 3; + /** fraction by which each poll interval is randomly shortened or lengthened. */ + public static final double POLLING_JITTER_RATIO = 0.1; // MAPPERS public static final ObjectMapper DESERIALIZE_OBJECT_MAPPER = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); public static final ObjectMapper SERIALIZE_OBJECT_MAPPER = new ObjectMapper(); public static final ObjectMapper SERIALIZE_WASM_MAPPER = new ObjectMapper() - .setSerializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) + .setDefaultPropertyInclusion(com.fasterxml.jackson.annotation.JsonInclude.Value.construct( + com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL, + com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS)) .setDateFormat(new StdDateFormat().withColonInTimeZone(true)); } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtil.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtil.java index fc085bad84..16218d9ed3 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtil.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtil.java @@ -14,6 +14,9 @@ public class EvaluationContextUtil { */ private static final String anonymousFieldName = "anonymous"; + private static final String ANONYMOUS_USER_CONTEXT_KIND = "anonymousUser"; + private static final String USER_CONTEXT_KIND = "user"; + /** * isAnonymousUser is checking if the user in the evaluationContext is anonymous. * @@ -25,6 +28,31 @@ public static boolean isAnonymousUser(final EvaluationContext ctx) { return true; } Value value = ctx.getValue(anonymousFieldName); - return value != null && value.asBoolean(); + return value != null && value.isBoolean() && Boolean.TRUE.equals(value.asBoolean()); + } + + /** + * contextKind is the bucket an event is counted under. + * + * @param ctx - EvaluationContext from open-feature + * @return the bucket this evaluation belongs to + */ + public static String contextKind(final EvaluationContext ctx) { + return isAnonymousUser(ctx) ? ANONYMOUS_USER_CONTEXT_KIND : USER_CONTEXT_KIND; + } + + /** + * userKey is the key an event is attributed to. + * + * @param ctx - EvaluationContext from open-feature + * @return the targeting key, or a placeholder when there is none + */ + public static String userKey(final EvaluationContext ctx) { + if (ctx == null + || ctx.getTargetingKey() == null + || ctx.getTargetingKey().isEmpty()) { + return Const.UNDEFINED_TARGETING_KEY; + } + return ctx.getTargetingKey(); } } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/JsonValueUtil.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/JsonValueUtil.java new file mode 100644 index 0000000000..64bc9ebb4a --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/JsonValueUtil.java @@ -0,0 +1,46 @@ +package dev.openfeature.contrib.providers.gofeatureflag.util; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * JsonValueUtil is a utility class to prepare values decoded from JSON before they are converted to + * an Open Feature Value. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class JsonValueUtil { + /** + * widenBigIntegers replaces every BigInteger in a decoded JSON value with its Double equivalent. + * + *

Jackson decodes an integer beyond the long range as BigInteger, which Value.objectToValue + * rejects. The engine writes those numbers from a float64, so a Double holds them exactly. + * + * @param value - a value decoded from the engine's JSON output + * @return the same value, with BigInteger replaced by Double at any depth + */ + public static Object widenBigIntegers(final Object value) { + if (value instanceof BigInteger) { + return ((BigInteger) value).doubleValue(); + } + if (value instanceof Map) { + Map widened = new LinkedHashMap<>(); + for (Map.Entry entry : ((Map) value).entrySet()) { + widened.put(entry.getKey(), widenBigIntegers(entry.getValue())); + } + return widened; + } + if (value instanceof List) { + List widened = new ArrayList<>(); + for (Object item : (List) value) { + widened.add(widenBigIntegers(item)); + } + return widened; + } + return value; + } +} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/MetadataUtil.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/MetadataUtil.java index 965e55b9ac..8d444473bd 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/MetadataUtil.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/MetadataUtil.java @@ -1,16 +1,21 @@ package dev.openfeature.contrib.providers.gofeatureflag.util; +import com.fasterxml.jackson.databind.ObjectMapper; import dev.openfeature.sdk.ImmutableMetadata; import java.util.Map; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; /** * MetadataUtil is a utility class to convert the metadata received from the server to an * ImmutableMetadata format known by Open Feature. */ +@Slf4j @NoArgsConstructor(access = AccessLevel.PRIVATE) public class MetadataUtil { + private static final ObjectMapper MAPPER = new ObjectMapper(); + /** * convertFlagMetadata is converting the flagMetadata object received from the server to an * ImmutableMetadata format known by Open Feature. @@ -24,7 +29,11 @@ public static ImmutableMetadata convertFlagMetadata(Map flagMeta return builder.build(); } flagMetadata.forEach((k, v) -> { - if (v instanceof Long) { + if (v == null) { + log.debug("skipping null metadata value for key {}", k); + } else if (v instanceof String) { + builder.addString(k, (String) v); + } else if (v instanceof Long) { builder.addLong(k, (Long) v); } else if (v instanceof Integer) { builder.addInteger(k, (Integer) v); @@ -35,9 +44,18 @@ public static ImmutableMetadata convertFlagMetadata(Map flagMeta } else if (v instanceof Boolean) { builder.addBoolean(k, (Boolean) v); } else { - builder.addString(k, v.toString()); + builder.addString(k, asJson(k, v)); } }); return builder.build(); } + + private static String asJson(final String key, final Object value) { + try { + return MAPPER.writeValueAsString(value); + } catch (Exception e) { + log.warn("metadata value for key {} could not be serialised to JSON", key, e); + return String.valueOf(value); + } + } } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java index e413913dc3..738ea28ec5 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java @@ -2,15 +2,11 @@ import com.dylibso.chicory.runtime.ByteArrayMemory; import com.dylibso.chicory.runtime.ExportFunction; -import com.dylibso.chicory.runtime.HostFunction; -import com.dylibso.chicory.runtime.ImportFunction; import com.dylibso.chicory.runtime.ImportValues; import com.dylibso.chicory.runtime.Instance; import com.dylibso.chicory.runtime.Memory; -import com.dylibso.chicory.wasi.WasiExitException; import com.dylibso.chicory.wasi.WasiOptions; import com.dylibso.chicory.wasi.WasiPreview1; -import com.dylibso.chicory.wasm.types.ValueType; import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.exception.WasmFileNotFound; import dev.openfeature.contrib.providers.gofeatureflag.util.Const; @@ -18,21 +14,44 @@ import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.Reason; import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import lombok.val; /** * EvaluationWasm is a class that represents the evaluation of a feature flag * it calls an external WASM module to evaluate the feature flag. */ -public final class EvaluationWasm { +@Slf4j +public final class EvaluationWasm implements AutoCloseable { + private final WasiPreview1 wasi; private final Instance instance; private final ExportFunction evaluate; private final ExportFunction malloc; private final ExportFunction free; + /** the engine writes nothing while evaluating normally, so anything it prints reports a fault. */ + private final WasmGuestOutput stdout = new WasmGuestOutput(line -> log.error("evaluation engine: {}", line)); + + private final WasmGuestOutput stderr = new WasmGuestOutput(line -> log.error("evaluation engine: {}", line)); + + /** + * A complete evaluation with a targeting rule, the shape of the canonical ABI vector. The rule + * matters: parsing its query is the bulk of what the engine initialises lazily on first use. + */ + private static final byte[] WARM_UP_INPUT = ("{\"flagKey\":\"warm-up\"," + + "\"flag\":{\"variations\":{\"enabled\":true,\"disabled\":false}," + + "\"targeting\":[{\"query\":\"targetingKey eq \\\"warm-up\\\"\",\"variation\":\"enabled\"}]," + + "\"defaultRule\":{\"variation\":\"disabled\"}}," + + "\"evalContext\":{\"targetingKey\":\"warm-up\"}," + + "\"flagContext\":{\"defaultSdkValue\":false}}") + .getBytes(StandardCharsets.UTF_8); + + /** + * poisoned is set when the guest faults. A trap does not unwind the module's shadow-stack + * pointer, so the instance is permanently unusable and must never serve another evaluation. + */ + @Getter + private volatile boolean poisoned; /** * Constructor of the EvaluationWasm. @@ -41,60 +60,44 @@ public final class EvaluationWasm { * @throws WasmFileNotFound - if the WASM file is not found */ public EvaluationWasm() throws WasmFileNotFound { - // We will create two output streams to capture stdout and stderr - val wasi = WasiPreview1.builder() - .withOptions(WasiOptions.builder().inheritSystem().build()) + this.wasi = WasiPreview1.builder() + .withOptions(WasiOptions.builder() + .withStdout(this.stdout) + .withStderr(this.stderr) + .withThrowOnExit0(false) + .build()) .build(); - List hostFunctions = - Arrays.stream(wasi.toHostFunctions()).map(this::replaceProcExit).collect(Collectors.toList()); this.instance = Instance.builder(Module.load()) .withMemoryFactory(ByteArrayMemory::new) .withMachineFactory(Module::create) - .withImportValues( - ImportValues.builder().withFunctions(hostFunctions).build()) + .withImportValues(ImportValues.builder() + .addFunction(this.wasi.toHostFunctions()) + .build()) .build(); this.evaluate = this.instance.export("evaluate"); this.malloc = this.instance.export("malloc"); this.free = this.instance.export("free"); } - private ImportFunction replaceProcExit(HostFunction hf) { - return hf.name().equals("proc_exit") ? getProcExitFunc() : hf; - } - /** - * getProcExitFunc is a function that is called when the WASM module calls - * proc_exit. It throws a WasiExitException with the exit code. - * By default, the exit code is 0, and it raises an Exception. - * - * @return a HostFunction that is called when the WASM module calls proc_exit + * close releases the descriptors the WASI instance owns. The WASI object serves the guest's + * imports for as long as the module is alive, so it can only be released once this evaluator is + * discarded, never at the end of the constructor. */ - private ImportFunction getProcExitFunc() { - return new HostFunction( - "wasi_snapshot_preview1", - "proc_exit", - Collections.singletonList(ValueType.I32), - Collections.emptyList(), - (instance, args) -> { - if ((int) args[0] != 0) { - throw new WasiExitException((int) args[0]); - } - return null; - }); + @Override + public void close() { + this.wasi.close(); + this.stdout.close(); + this.stderr.close(); } /** - * preWarmWasm is a function that is called to pre-warm the WASM module - * It calls the malloc function to allocate memory for the WASM module - * and then calls the free function to free the memory. + * preWarmWasm runs one throwaway evaluation so that the engine's lazy initialisation, and the + * loading of the classes it was compiled to, happen now rather than on the first real evaluation + * this instance serves. The answer is discarded; a fault propagates like any other. */ public void preWarmWasm() { - val message = "".getBytes(StandardCharsets.UTF_8); - Memory memory = this.instance.memory(); - int len = message.length; - int ptr = (int) malloc.apply(len)[0]; - memory.write(ptr, message); - this.free.apply(ptr, len); + evaluateRaw(WARM_UP_INPUT); } /** @@ -104,38 +107,65 @@ public void preWarmWasm() { * @return the result of the evaluation */ public GoFeatureFlagResponse evaluate(WasmInput wasmInput) { - int len = 0; + try { + val output = evaluateRaw(Const.SERIALIZE_WASM_MAPPER.writeValueAsBytes(wasmInput)); + return Const.DESERIALIZE_OBJECT_MAPPER.readValue(output, GoFeatureFlagResponse.class); + } catch (Exception | Error e) { + return errorResponse(e); + } + } + + /** + * evaluateRaw hands the engine a raw input and returns its raw output, across the engine ABI. + * + * @param message - the JSON input of the engine + * @return the JSON output of the engine + */ + String evaluateRaw(final byte[] message) { int ptr = 0; try { - // convert the WasmInput object to JSON string - val message = Const.SERIALIZE_WASM_MAPPER.writeValueAsBytes(wasmInput); - // Store the json string in the memory Memory memory = this.instance.memory(); - len = message.length; - ptr = (int) malloc.apply(len)[0]; + ptr = (int) malloc.apply(message.length)[0]; memory.write(ptr, message); - // Call the wasm evaluate function - val resultPointer = this.evaluate.apply(ptr, len); + val resultPointer = this.evaluate.apply(ptr, message.length); - // Read the output int valuePosition = (int) ((resultPointer[0] >>> 32) & 0xFFFFFFFFL); int valueSize = (int) (resultPointer[0] & 0xFFFFFFFFL); - val output = memory.readString(valuePosition, valueSize); - - // Convert the output to a WasmOutput object - return Const.DESERIALIZE_OBJECT_MAPPER.readValue(output, GoFeatureFlagResponse.class); - - } catch (Exception e) { - val response = new GoFeatureFlagResponse(); - response.setErrorCode(ErrorCode.GENERAL.name()); - response.setReason(Reason.ERROR.name()); - response.setErrorDetails(e.getMessage()); - return response; + return memory.readString(valuePosition, valueSize); + } catch (RuntimeException | Error e) { + // anything escaping the guest, an OutOfMemoryError from memory.grow included, aborted it mid-call + this.poisoned = true; + throw e; } finally { - if (len > 0) { - this.free.apply(ptr, len); - } + freeInput(ptr, message.length); } } + + /** + * freeInput releases the input buffer, unless the guest faulted while serving this evaluation. + * + *

Calling into a trapped instance faults inside malloc at a wrapped address, which masks the + * original error; the instance is discarded by the pool anyway, so its memory is not worth + * reclaiming. A fault raised by free itself is contained here for the same reason.

+ */ + private void freeInput(final int ptr, final int len) { + if (len <= 0 || this.poisoned) { + return; + } + try { + this.free.apply(ptr); + } catch (RuntimeException | Error e) { + this.poisoned = true; + log.error("failed to free the WASM input buffer, the instance will be discarded", e); + } + } + + private GoFeatureFlagResponse errorResponse(final Throwable e) { + val response = new GoFeatureFlagResponse(); + response.setErrorCode(ErrorCode.GENERAL.name()); + response.setReason(Reason.ERROR.name()); + response.setErrorDetails(e.getMessage()); + return response; + } } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java index dd7a2a99f6..a850ca0c8a 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java @@ -7,6 +7,10 @@ import dev.openfeature.sdk.Reason; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; /** @@ -15,52 +19,177 @@ * evaluate() calls without interleaving memory operations. */ @Slf4j -public final class WasmEvaluatorPool { +public final class WasmEvaluatorPool implements AutoCloseable { + /** how long an evaluation waits for an instance before checking again whether the pool was closed. */ + private static final long CLOSED_CHECK_INTERVAL_MS = 100L; + private final BlockingQueue pool; + private final Supplier instanceFactory; + /** instances discarded after a fault whose replacement could not be built yet. */ + private final AtomicInteger missing = new AtomicInteger(); + + @Getter + private volatile boolean closed; /** * Creates a pool of {@code size} independent EvaluationWasm instances. - * All instances are allocated eagerly so that first-call latency is - * absorbed at provider initialisation time. + * All instances are allocated and warmed up eagerly so that first-call latency is + * absorbed in provider initialisation time: each slot costs roughly 20 ms of warm-up. * * @param size number of WASM instances; must be >= 1 * @throws WasmFileNotFound if the embedded WASM module cannot be loaded */ public WasmEvaluatorPool(int size) throws WasmFileNotFound { + this(size, EvaluationWasm::new); + } + + WasmEvaluatorPool(int size, Supplier instanceFactory) throws WasmFileNotFound { + this.instanceFactory = instanceFactory; this.pool = new ArrayBlockingQueue<>(size); for (int i = 0; i < size; i++) { - EvaluationWasm instance = new EvaluationWasm(); - instance.preWarmWasm(); - pool.add(instance); + pool.add(newInstance()); } } + private EvaluationWasm newInstance() { + EvaluationWasm instance = instanceFactory.get(); + instance.preWarmWasm(); + return instance; + } + /** * Evaluates a feature flag by borrowing one WASM instance from the pool, * delegating to it, and returning it when done. - * Blocks if all instances are busy until one becomes available. + * Blocks if all instances are busy until one becomes available, or until the pool is closed. * * @param wasmInput evaluation input * @return evaluation result */ public GoFeatureFlagResponse evaluate(WasmInput wasmInput) { - EvaluationWasm instance; + EvaluationWasm instance = null; try { - instance = pool.take(); + // close() empties the queue and nothing is offered back afterwards, so a plain take() + // would wait forever: waiting in slices lets a waiter notice the pool has been closed. + while (instance == null) { + if (closed) { + return errorResponse("WASM evaluator pool is closed"); + } + instance = pool.poll(CLOSED_CHECK_INTERVAL_MS, TimeUnit.MILLISECONDS); + if (instance == null && claimMissingInstance()) { + instance = rebuild(); + if (instance == null) { + missing.incrementAndGet(); + return errorResponse("no WASM instance is available and none could be rebuilt"); + } + } + } } catch (InterruptedException e) { Thread.currentThread().interrupt(); - GoFeatureFlagResponse err = new GoFeatureFlagResponse(); - err.setErrorCode(ErrorCode.GENERAL.name()); - err.setReason(Reason.ERROR.name()); - err.setErrorDetails("WASM evaluator pool interrupted while waiting for an available instance"); - return err; + return errorResponse("WASM evaluator pool interrupted while waiting for an available instance"); } try { return instance.evaluate(wasmInput); } finally { - if (!pool.offer(instance)) { - log.error("Failed to return WASM instance to pool — instance leaked, pool capacity may be compromised"); + returnToPool(instance); + } + } + + /** + * returnToPool gives the instance back, replacing it first if the guest faulted while it was + * serving. A trapped instance is permanently poisoned and must never be reused, so it is dropped + * and a fresh one takes its place; if the replacement cannot be built, an evaluation that finds + * the pool empty builds it later. + */ + private void returnToPool(final EvaluationWasm instance) { + EvaluationWasm toReturn = instance; + if (instance.isPoisoned()) { + log.warn("discarding a WASM instance whose guest faulted, and rebuilding it"); + closeQuietly(instance); + toReturn = rebuild(); + if (toReturn == null) { + missing.incrementAndGet(); + return; } } + if (closed) { + closeQuietly(toReturn); + return; + } + if (!pool.offer(toReturn)) { + log.error("Failed to return WASM instance to pool - instance leaked, pool capacity may be compromised"); + closeQuietly(toReturn); + } + } + + /** + * rebuild builds a replacement instance. The interrupt flag is cleared while it runs, because + * the module checks it and would fail the build on a thread interrupted during the evaluation + * that poisoned the previous instance. + * + * @return the new instance, or null if it could not be built + */ + private EvaluationWasm rebuild() { + boolean interrupted = Thread.interrupted(); + try { + return newInstance(); + } catch (Exception | Error e) { + log.error("failed to rebuild a WASM instance", e); + return null; + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + + /** + * claimMissingInstance takes responsibility for rebuilding one of the missing instances. + * + * @return true if an instance is missing and this caller must rebuild it + */ + private boolean claimMissingInstance() { + int current = missing.get(); + while (current > 0) { + if (missing.compareAndSet(current, current - 1)) { + return true; + } + current = missing.get(); + } + return false; + } + + /** + * close releases every instance the pool owns. An instance still serving an evaluation is not in + * the queue, so returnToPool closes it as soon as it comes back rather than handing it to a pool + * nobody will read again. + */ + @Override + public void close() { + closed = true; + EvaluationWasm instance = pool.poll(); + while (instance != null) { + closeQuietly(instance); + instance = pool.poll(); + } + } + + /** + * Releasing an instance is best effort: a descriptor that refuses to close must not abort a + * provider shutdown, nor the rebuild of an instance whose guest faulted. + */ + private void closeQuietly(final EvaluationWasm instance) { + try { + instance.close(); + } catch (Exception e) { + log.warn("failed to release a WASM instance", e); + } + } + + private GoFeatureFlagResponse errorResponse(final String details) { + GoFeatureFlagResponse err = new GoFeatureFlagResponse(); + err.setErrorCode(ErrorCode.GENERAL.name()); + err.setReason(Reason.ERROR.name()); + err.setErrorDetails(details); + return err; } } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmGuestOutput.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmGuestOutput.java new file mode 100644 index 0000000000..1395e8fc02 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmGuestOutput.java @@ -0,0 +1,41 @@ +package dev.openfeature.contrib.providers.gofeatureflag.wasm; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; + +/** + * WasmGuestOutput turns what the evaluation engine writes to one of its standard streams into log + * events, one per line, so that a host routes the engine's diagnostics, such as the panic it prints + * before trapping, through its logging backend like the rest of the provider's. + */ +final class WasmGuestOutput extends OutputStream { + private final Consumer sink; + private final ByteArrayOutputStream line = new ByteArrayOutputStream(); + + WasmGuestOutput(final Consumer sink) { + this.sink = sink; + } + + @Override + public synchronized void write(final int b) { + if (b == '\n') { + emit(); + } else if (b != '\r') { + line.write(b); + } + } + + @Override + public synchronized void close() { + emit(); + } + + private void emit() { + if (line.size() > 0) { + sink.accept(line.toString(StandardCharsets.UTF_8)); + line.reset(); + } + } +} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInput.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInput.java index ef342fefd4..a454228d28 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInput.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInput.java @@ -1,6 +1,6 @@ package dev.openfeature.contrib.providers.gofeatureflag.wasm.bean; -import dev.openfeature.contrib.providers.gofeatureflag.bean.Flag; +import com.fasterxml.jackson.databind.JsonNode; import java.util.Map; import lombok.AllArgsConstructor; import lombok.Builder; @@ -10,6 +10,8 @@ /** * This class represents the input to the WASM module. * It contains the flag key, the flag, the evaluation context, and the flag context. + * The flag is passed through as the raw JSON received from the relay proxy, so that no field the + * engine understands can be lost on the way in. */ @Data @Builder @@ -17,7 +19,7 @@ @NoArgsConstructor public class WasmInput { private String flagKey; - private Flag flag; + private JsonNode flag; private Map evalContext; private FlagContext flagContext; } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/AbstractGoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/AbstractGoFeatureFlagProviderTest.java new file mode 100644 index 0000000000..b9c2b1c991 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/AbstractGoFeatureFlagProviderTest.java @@ -0,0 +1,51 @@ +package dev.openfeature.contrib.providers.gofeatureflag; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; +import dev.openfeature.sdk.OpenFeatureAPI; +import java.io.IOException; +import java.util.stream.Stream; +import lombok.SneakyThrows; +import okhttp3.HttpUrl; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.params.provider.Arguments; + +abstract class AbstractGoFeatureFlagProviderTest { + /** The three shapes that register no data collector hook, plus the one that does. */ + static Stream shutdownModes() { + return Stream.of( + Arguments.of(EvaluationType.IN_PROCESS, false), + Arguments.of(EvaluationType.IN_PROCESS, true), + Arguments.of(EvaluationType.REMOTE, false), + Arguments.of(EvaluationType.REMOTE, true)); + } + + protected MockWebServer server; + protected GoffApiMock goffAPIMock; + protected HttpUrl baseUrl; + protected String testName; + + @BeforeEach + void beforeEach(TestInfo testInfo) throws IOException { + this.server = new MockWebServer(); + goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.DEFAULT); + this.server.setDispatcher(goffAPIMock.dispatcher); + this.server.start(); + baseUrl = server.url(""); + this.testName = testInfo.getDisplayName(); + } + + @SneakyThrows + @AfterEach + void afterEach() throws IOException { + OpenFeatureAPI.getInstance().shutdown(); + + Thread.sleep(50L); + this.server.close(); + this.server = null; + baseUrl = null; + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptionsTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptionsTest.java new file mode 100644 index 0000000000..b8cedd977e --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptionsTest.java @@ -0,0 +1,140 @@ +package dev.openfeature.contrib.providers.gofeatureflag; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidEndpoint; +import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidExporterMetadata; +import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; +import java.util.HashMap; +import java.util.Map; +import lombok.SneakyThrows; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("Provider options validation") +class GoFeatureFlagProviderOptionsTest extends AbstractGoFeatureFlagProviderTest { + @Test + void constructor_options_null() { + assertThrows(InvalidOptions.class, () -> new GoFeatureFlagProvider(null)); + } + + @Test + void constructor_options_empty() { + assertThrows( + InvalidOptions.class, + () -> new GoFeatureFlagProvider( + GoFeatureFlagProviderOptions.builder().build())); + } + + @SneakyThrows + @Test + void constructor_options_empty_endpoint() { + assertThrows( + InvalidEndpoint.class, + () -> new GoFeatureFlagProvider( + GoFeatureFlagProviderOptions.builder().endpoint("").build())); + } + + @SneakyThrows + @Test + void constructor_options_only_timeout() { + assertThrows( + InvalidEndpoint.class, + () -> new GoFeatureFlagProvider( + GoFeatureFlagProviderOptions.builder().timeout(10000).build())); + } + + @SneakyThrows + @Test + void constructor_options_valid_endpoint() { + assertDoesNotThrow(() -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint("http://localhost:1031") + .build())); + } + + @DisplayName("Should error if the metadata is not a valid type") + @SneakyThrows + @Test + void shouldErrorIfTheMetadataIsNotAValidType() { + assertThrows( + InvalidExporterMetadata.class, + () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .exporterMetadata(Map.of( + // object is not a valid metadata + "invalid-metadata", goffAPIMock)) + .evaluationType(EvaluationType.REMOTE) + .build())); + } + + @DisplayName("Should error if a custom header is one the provider sets or the HTTP client refuses") + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"Content-Type", "if-none-match", "Host", "Content-Length", "a header name with spaces"}) + void shouldErrorIfACustomHeaderIsOneTheProviderSetsOrTheHttpClientRefuses(String name) { + assertThrows( + InvalidOptions.class, + () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .customHeaders(Map.of(name, "value")) + .build())); + } + + @DisplayName("Should error if a custom header has no name or no value") + @ParameterizedTest(name = "null {0}") + @ValueSource(strings = {"name", "value"}) + void shouldErrorIfACustomHeaderHasNoNameOrNoValue(String missing) { + val headers = new HashMap(); + headers.put("name".equals(missing) ? null : "X-Gateway-Token", "value".equals(missing) ? null : "token"); + + assertThrows( + InvalidOptions.class, + () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .customHeaders(headers) + .build())); + } + + @DisplayName("Should error if a custom header is configured twice under different cases") + @Test + void shouldErrorIfACustomHeaderIsConfiguredTwiceUnderDifferentCases() { + assertThrows( + InvalidOptions.class, + () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .customHeaders(Map.of("X-Gateway-Token", "a", "x-gateway-token", "b")) + .build())); + } + + @DisplayName("Should error if invalid flush interval is set") + @SneakyThrows + @Test + void shouldErrorIfInvalidFlushIntervalIsSet() { + assertThrows( + InvalidOptions.class, + () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(-1L) + .maxPendingEvents(1000) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build())); + } + + @DisplayName("Should error if invalid max pending events is set") + @SneakyThrows + @Test + void shouldErrorIfInvalidMaxPendingEventsIsSet() { + assertThrows( + InvalidOptions.class, + () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(-1000) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build())); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index dd837f7969..bb806e02ce 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -1,1075 +1,159 @@ package dev.openfeature.contrib.providers.gofeatureflag; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; -import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidEndpoint; -import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidExporterMetadata; -import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; -import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import dev.openfeature.contrib.providers.gofeatureflag.hook.DataCollectorHook; +import dev.openfeature.contrib.providers.gofeatureflag.hook.EnrichEvaluationContextHook; import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.MutableContext; -import dev.openfeature.sdk.MutableStructure; +import dev.openfeature.sdk.ImmutableContext; import dev.openfeature.sdk.MutableTrackingEventDetails; import dev.openfeature.sdk.OpenFeatureAPI; -import dev.openfeature.sdk.Reason; -import dev.openfeature.sdk.Value; -import dev.openfeature.sdk.exceptions.GeneralError; -import java.io.IOException; -import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; import lombok.SneakyThrows; -import lombok.extern.slf4j.Slf4j; import lombok.val; -import okhttp3.HttpUrl; import okhttp3.mockwebserver.MockWebServer; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; -@Slf4j -class GoFeatureFlagProviderTest { - private MockWebServer server; - private GoffApiMock goffAPIMock; - private HttpUrl baseUrl; - private String testName; - - @BeforeEach - void beforeEach(TestInfo testInfo) throws IOException { - this.server = new MockWebServer(); - goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.DEFAULT); - this.server.setDispatcher(goffAPIMock.dispatcher); - this.server.start(); - baseUrl = server.url(""); - this.testName = testInfo.getDisplayName(); +@DisplayName("Provider lifecycle") +class GoFeatureFlagProviderTest extends AbstractGoFeatureFlagProviderTest { + @DisplayName("Should stop polling the flag configuration on shutdown") + @SneakyThrows + @Test + void shouldStopPollingTheFlagConfigurationOnShutdown() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .flagChangePollingIntervalMs(100L) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + Thread.sleep(300L); + + provider.shutdown(); + // whatever was in flight when shutdown was called may still land, so settle first + Thread.sleep(200L); + val afterShutdown = goffAPIMock.getConfigurationCallCount(); + Thread.sleep(500L); + + assertTrue(afterShutdown > 1, "the provider never polled, so stopping is not what is under test"); + assertEquals( + afterShutdown, + goffAPIMock.getConfigurationCallCount(), + "the configuration was still being polled after shutdown"); } + @DisplayName("Should stop the event publisher on shutdown in every mode") + @ParameterizedTest(name = "{0} evaluation, data collection disabled: {1}") + @MethodSource("shutdownModes") @SneakyThrows - @AfterEach - void afterEach() throws IOException { - OpenFeatureAPI.getInstance().shutdown(); - - Thread.sleep(50L); - this.server.close(); - this.server = null; - baseUrl = null; + void shouldStopTheEventPublisherOnShutdownInEveryMode(EvaluationType type, boolean disableDataCollection) { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1000) + .endpoint(baseUrl.toString()) + .evaluationType(type) + .disableDataCollection(disableDataCollection) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.track("before-shutdown", TestUtils.defaultEvaluationContext, new MutableTrackingEventDetails()); + + provider.shutdown(); + val afterShutdown = goffAPIMock.getCollectorRequestsHistory().size(); + + client.track("after-shutdown", TestUtils.defaultEvaluationContext, new MutableTrackingEventDetails()); + Thread.sleep(400L); + + assertEquals(disableDataCollection ? 0 : 1, afterShutdown, "the final drain did not happen"); + assertEquals( + afterShutdown, + goffAPIMock.getCollectorRequestsHistory().size(), + "the publisher kept accepting and flushing after shutdown"); } - @Nested - @DisplayName("Common tests working with all evaluation types") - class Common { - @SneakyThrows - @Test - void getMetadata_validate_name() { - assertEquals( - "GO Feature Flag Provider", - new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint("https://gofeatureflag.org") - .timeout(1000) - .build()) - .getMetadata() - .getName()); - } - - @Test - void constructor_options_null() { - assertThrows(InvalidOptions.class, () -> new GoFeatureFlagProvider(null)); - } - - @Test - void constructor_options_empty() { - assertThrows( - InvalidOptions.class, - () -> new GoFeatureFlagProvider( - GoFeatureFlagProviderOptions.builder().build())); - } - - @SneakyThrows - @Test - void constructor_options_empty_endpoint() { - assertThrows( - InvalidEndpoint.class, - () -> new GoFeatureFlagProvider( - GoFeatureFlagProviderOptions.builder().endpoint("").build())); - } - - @SneakyThrows - @Test - void constructor_options_only_timeout() { - assertThrows( - InvalidEndpoint.class, - () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .timeout(10000) - .build())); - } - - @SneakyThrows - @Test - void constructor_options_valid_endpoint() { - assertDoesNotThrow(() -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint("http://localhost:1031") - .build())); - } - - @DisplayName("Should error if the metadata is not a valid type") - @SneakyThrows - @Test - void shouldErrorIfTheMetadataIsNotAValidType() { - assertThrows( - InvalidExporterMetadata.class, - () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .exporterMetadata(Map.of( - // object is not a valid metadata - "invalid-metadata", goffAPIMock)) - .evaluationType(EvaluationType.REMOTE) - .build())); - } - - @DisplayName("Should error if invalid flush interval is set") - @SneakyThrows - @Test - void shouldErrorIfInvalidFlushIntervalIsSet() { - assertThrows( - InvalidOptions.class, - () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(-1L) - .maxPendingEvents(1000) - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build())); - } - - @DisplayName("Should error if invalid max pending events is set") - @SneakyThrows - @Test - void shouldErrorIfInvalidMaxPendingEventsIsSet() { - assertThrows( - InvalidOptions.class, - () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(100L) - .maxPendingEvents(-1000) - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build())); - } + @DisplayName("Should not register the hooks twice when initialized twice") + @SneakyThrows + @Test + void shouldNotRegisterTheHooksTwiceWhenInitializedTwice() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + provider.initialize(new ImmutableContext()); + val afterFirst = provider.getProviderHooks().size(); + provider.initialize(new ImmutableContext()); + val afterSecond = provider.getProviderHooks(); + provider.shutdown(); + + assertEquals(afterFirst, afterSecond.size(), "a second initialization registered the hooks again"); + assertEquals( + List.of(EnrichEvaluationContextHook.class, DataCollectorHook.class), + afterSecond.stream().map(Object::getClass).collect(Collectors.toList())); } - @Nested - class InProcessEvaluation { - @DisplayName("Should use in process evaluation by default") - @SneakyThrows - @Test - void shouldUseInProcessByDefault() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - client.getBooleanDetails("bool_targeting_match", false, new MutableContext()); - val want = "/v1/flag/configuration"; - assertEquals(want, server.takeRequest().getPath()); - } - - @DisplayName("Should use in process evaluation if option is set") - @SneakyThrows - @Test - void shouldUseInProcessIfOptionIsSet() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - client.getBooleanDetails("bool_targeting_match", false, new MutableContext()); - val want = "/v1/flag/configuration"; - assertEquals(want, server.takeRequest().getPath()); - } - - @DisplayName("Should throw an error if the endpoint is not available") - @SneakyThrows - @Test - void shouldThrowAnErrorIfEndpointNotAvailable() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(s.url("").toString()) - .timeout(1000) - .build()); - assertThrows( - GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, g)); - } - } - - @DisplayName("Should throw an error if api key is missing") - @SneakyThrows - @Test - void shouldThrowAnErrorIfApiKeyIsMissing() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(s.url("").toString()) - .timeout(1000) - .build()); - assertThrows( - GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, g)); - } - } - - @DisplayName("Should return FLAG_NOT_FOUND if the flag does not exists") - @SneakyThrows - @Test - void shouldReturnFlagNotFoundIfFlagDoesNotExists() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("DOES_NOT_EXISTS", false, TestUtils.defaultEvaluationContext); - - val want = FlagEvaluationDetails.builder() - .value(false) - .flagKey("DOES_NOT_EXISTS") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.FLAG_NOT_FOUND) - .errorMessage("Flag DOES_NOT_EXISTS was not found in your configuration") - .build(); - assertEquals(want, got); - } - - @DisplayName("Should throw an error if we expect a boolean and got another type") - @SneakyThrows - @Test - void shouldThrowAnErrorIfWeExpectABooleanAndGotAnotherType() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("string_key", false, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .flagKey("string_key") - .value(false) - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.TYPE_MISMATCH) - .errorMessage( - "Flag value string_key had unexpected type class java.lang.String, expected class java.lang.Boolean.") - .build(); - assertEquals(want, got); - } - - @DisplayName("Should resolve a valid boolean flag with TARGETING MATCH reason") - @SneakyThrows - @Test - void shouldResolveAValidBooleanFlagWithTargetingMatchReason() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(true) - .variant("enabled") - .flagKey("bool_targeting_match") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "this is a test flag") - .addBoolean("defaultValue", false) - .build()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should resolve a valid string flag with TARGETING MATCH reason") - @SneakyThrows - @Test - void shouldResolveAValidStringFlagWithTargetingMatchReason() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getStringDetails("string_key", "", TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value("CC0002") - .variant("color1") - .flagKey("string_key") - .reason(Reason.STATIC.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "this is a test flag") - .addString("defaultValue", "CC0000") - .build()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should resolve a valid double flag with TARGETING MATCH reason") - @SneakyThrows - @Test - void shouldResolveAValidDoubleFlagWithTargetingMatchReason() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getDoubleDetails("double_key", 100.10, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(101.25) - .variant("medium") - .flagKey("double_key") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "this is a test flag") - .addDouble("defaultValue", 100.25) - .build()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should resolve a valid integer flag with TARGETING MATCH reason") - @SneakyThrows - @Test - void shouldResolveAValidIntegerFlagWithTargetingMatchReason() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getIntegerDetails("integer_key", 1000, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(101) - .variant("medium") - .flagKey("integer_key") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "this is a test flag") - .addInteger("defaultValue", 1000) - .build()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should resolve a valid object flag with TARGETING MATCH reason") - @SneakyThrows - @Test - void shouldResolveAValidObjectFlagWithTargetingMatchReason() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getObjectDetails( - "object_key", - Value.objectToValue(new MutableStructure().add("default", "true")), - TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(Value.objectToValue(new MutableStructure().add("test", "false"))) - .variant("varB") - .flagKey("object_key") - .reason(Reason.TARGETING_MATCH.name()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should use boolean default value if the flag is disabled") - @SneakyThrows - @Test - void shouldUseBooleanDefaultValueIfTheFlagIsDisabled() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("disabled_bool", false, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(false) - .variant("SdkDefault") - .flagKey("disabled_bool") - .reason(Reason.DISABLED.name()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should emit configuration change event, if config has changed") - @SneakyThrows - @Test - void shouldEmitConfigurationChangeEventIfConfigHasChanged() { - val s = new MockWebServer(); - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.CHANGE_CONFIG_AFTER_1ST_EVAL); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flagChangePollingIntervalMs(100L) - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - - AtomicBoolean configurationChangedCalled = new AtomicBoolean(false); - List flagsChanged = new ArrayList<>(); - client.onProviderConfigurationChanged(event -> { - configurationChangedCalled.set(true); - flagsChanged.addAll(event.getFlagsChanged()); - }); - client.getBooleanDetails("disabled_bool", false, TestUtils.defaultEvaluationContext); - - // waiting to get a flag change - int maxWait = 10; - while (!configurationChangedCalled.get() && maxWait > 0) { - maxWait--; - Thread.sleep(10L); - } - assertTrue(configurationChangedCalled.get()); - assertEquals(List.of("bool_targeting_match", "new-flag-changed", "disabled_bool"), flagsChanged); - } - - @DisplayName("Should not emit configuration change event, if config has not changed") - @SneakyThrows - @Test - void shouldNotEmitConfigurationChangeEventIfConfigHasNotChanged() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flagChangePollingIntervalMs(100L) - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - AtomicBoolean configurationChangedCalled = new AtomicBoolean(false); - client.onProviderConfigurationChanged(event -> { - configurationChangedCalled.set(true); - }); - client.getBooleanDetails("disabled_bool", false, TestUtils.defaultEvaluationContext); - Thread.sleep(150L); - assertFalse(configurationChangedCalled.get()); - } - - @DisplayName("Should change evaluation details if config has changed") - @SneakyThrows - @Test - void shouldChangeEvaluationValueIfConfigHasChanged() { - val s = new MockWebServer(); - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.CHANGE_CONFIG_AFTER_1ST_EVAL); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flagChangePollingIntervalMs(100L) - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - AtomicBoolean configurationChangedCalled = new AtomicBoolean(false); - client.onProviderConfigurationChanged(event -> { - configurationChangedCalled.set(true); - }); - - val got1 = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); - // waiting to get a flag change - int maxWait = 10; - while (!configurationChangedCalled.get() && maxWait > 0) { - maxWait--; - Thread.sleep(10L); - } - val got2 = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); - assertNotEquals(got1, got2); - } - - @DisplayName("Should error if flag configuration endpoint return a 404") - @SneakyThrows - @Test - void shouldErrorIfFlagConfigurationEndpointReturn404() { - val s = new MockWebServer(); - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR_404); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flagChangePollingIntervalMs(100L) - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - assertThrows(GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider)); - } - - @DisplayName("Should ignore configuration if etag is different by last-modified is older") - @SneakyThrows - @Test - void shouldIgnoreConfigurationIfEtagIsDifferentByLastModifiedIsOlder() { - val s = new MockWebServer(); - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.SERVE_OLD_CONFIGURATION); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flagChangePollingIntervalMs(100L) - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - AtomicBoolean configurationChangedCalled = new AtomicBoolean(false); - client.onProviderConfigurationChanged(event -> { - configurationChangedCalled.set(true); - }); - - client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); - Thread.sleep(300L); - assertFalse(configurationChangedCalled.get()); - } - - @DisplayName("Should apply a scheduled rollout step") - @SneakyThrows - @Test - void shouldApplyAScheduledRolloutStep() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.SCHEDULED_ROLLOUT_FLAG_CONFIG); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .timeout(1000) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("my-flag", false, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(true) - .variant("enabled") - .flagKey("my-flag") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "this is a test flag") - .addBoolean("defaultValue", false) - .build()) - .build(); - assertEquals(want, got); - } - } - - @DisplayName("Should not apply a scheduled rollout step if the date is in the future") - @SneakyThrows - @Test - void shouldNotApplyAScheduledRolloutStepIfTheDateIsInTheFuture() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.SCHEDULED_ROLLOUT_FLAG_CONFIG); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .timeout(1000) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails( - "my-flag-scheduled-in-future", true, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(false) - .variant("disabled") - .flagKey("my-flag-scheduled-in-future") - .reason(Reason.STATIC.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "this is a test flag") - .addBoolean("defaultValue", false) - .build()) - .build(); - assertEquals(want, got); - } - } - - @DisplayName("Should evaluate flags correctly under concurrent access") - @SneakyThrows - @Test - void shouldEvaluateFlagsCorrectlyUnderConcurrentAccess() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .flagChangePollingIntervalMs(999999L) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - - int threadCount = 20; - int evaluationsPerThread = 100; - AtomicInteger errorCount = new AtomicInteger(0); - CountDownLatch startGate = new CountDownLatch(1); - CountDownLatch doneLatch = new CountDownLatch(threadCount); - - for (int t = 0; t < threadCount; t++) { - new Thread(() -> { - try { - startGate.await(); - for (int i = 0; i < evaluationsPerThread; i++) { - FlagEvaluationDetails result = client.getBooleanDetails( - "bool_targeting_match", false, TestUtils.defaultEvaluationContext); - if (result.getErrorCode() != null) { - errorCount.incrementAndGet(); - } - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - doneLatch.countDown(); - } - }) - .start(); - } - - startGate.countDown(); - assertTrue(doneLatch.await(30, TimeUnit.SECONDS), "Threads did not finish in time"); - assertEquals(0, errorCount.get(), "Concurrent evaluations produced errors"); - } + @DisplayName("Should evaluate a configured flag after shutdown and re-init") + @ParameterizedTest(name = "{0} evaluation") + @CsvSource({"IN_PROCESS, bool_targeting_match", "REMOTE, bool_flag"}) + @SneakyThrows + void shouldEvaluateAConfiguredFlagAfterShutdownAndReInit(EvaluationType type, String flagKey) { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(type) + .build()); + provider.initialize(new ImmutableContext()); + provider.shutdown(); + provider.initialize(new ImmutableContext()); + + val evaluated = provider.getBooleanEvaluation(flagKey, false, TestUtils.defaultEvaluationContext); + provider.shutdown(); + + assertNull(evaluated.getErrorCode()); + assertEquals(true, evaluated.getValue()); } - @Nested - class DataCollectorHook { - @DisplayName("Should omit events if max pending events is reached") - @SneakyThrows - @Test - void shouldCallMultipleTimeTheDataCollectorIfMaxPendingEventsIsReached() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(100L) - .maxPendingEvents(1) - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - client.getIntegerDetails("integer_key", 1000, TestUtils.defaultEvaluationContext); - client.getIntegerDetails("integer_key", 1000, TestUtils.defaultEvaluationContext); - Thread.sleep(180L); - assertEquals(2, goffAPIMock.getCollectorRequestsHistory().size()); - } - - @DisplayName("Should not send evaluation event if flag has tracking disabled") - @SneakyThrows - @Test - void shouldNotSendEvaluationEventIfFlagHasTrackingDisabled() { + @DisplayName("Should record one evaluation once after being initialized twice") + @SneakyThrows + @Test + void shouldRecordOneEvaluationOnceAfterBeingInitializedTwice() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.DEFAULT); + s.setDispatcher(mock.dispatcher); GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() .flushIntervalMs(100L) .maxPendingEvents(1) - .endpoint(baseUrl.toString()) + .endpoint(s.url("").toString()) .evaluationType(EvaluationType.IN_PROCESS) .build()); + provider.initialize(new ImmutableContext()); OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); val client = OpenFeatureAPI.getInstance().getClient(testName); - client.getStringDetails("string_key", "default", TestUtils.defaultEvaluationContext); - client.getStringDetails("string_key", "default", TestUtils.defaultEvaluationContext); - Thread.sleep(180L); - assertEquals(0, goffAPIMock.getCollectorRequestsHistory().size()); - } - @DisplayName("Should not send events for remote evaluation") - @SneakyThrows - @Test - void shouldResolveAValidStringFlag() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(100L) - .maxPendingEvents(1) - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.REMOTE) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - client.getStringDetails("string_flag", "false", TestUtils.defaultEvaluationContext); + client.getIntegerDetails("integer_key", 1000, TestUtils.defaultEvaluationContext); Thread.sleep(180L); - assertEquals(0, goffAPIMock.getCollectorRequestsHistory().size()); - } - } - - @Nested - class EnrichEvaluationContext { - @DisplayName("Should add to the context the exporter metadata to the evaluation context") - @SneakyThrows - @Test - void shouldAddToTheContextTheExporterMetadataToTheEvaluationContext() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .exporterMetadata(Map.of( - "test-string", - "testing-provider", - "test-int", - 1, - "test-double", - 3.14, - "test-boolean", - true)) - .evaluationType(EvaluationType.REMOTE) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); - val got = Const.DESERIALIZE_OBJECT_MAPPER.readValue(goffAPIMock.getLastRequestBody(), HashMap.class); - - val context = new HashMap(); - context.put("targetingKey", "d45e303a-38c2-11ed-a261-0242ac120002"); - context.put("rate", 3.14); - context.put("company_info", Map.of("size", 120, "name", "my_company")); - context.put("anonymous", false); - context.put("email", "john.doe@gofeatureflag.org"); - context.put("lastname", "doe"); - context.put("firstname", "john"); - context.put("age", 30); - context.put( - "gofeatureflag", - Map.of( - "exporterMetadata", - Map.of( - "test-double", - 3.14, - "test-int", - 1, - "test-boolean", - true, - "test-string", - "testing-provider"))); - context.put("professional", true); - context.put("labels", List.of("pro", "beta")); - - Map want = new HashMap<>(); - want.put("context", context); - assertEquals(want, got); - } - - @DisplayName("Should not add gofeatureflag key in exporterMetadata if the exporterMetadata is empty") - @SneakyThrows - @Test - void shouldNotAddGoffeatureflagKeyInExporterMetadataIfTheExporterMetadataIsEmpty() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.REMOTE) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); - val got = Const.DESERIALIZE_OBJECT_MAPPER.readValue(goffAPIMock.getLastRequestBody(), HashMap.class); - - val context = new HashMap(); - context.put("targetingKey", "d45e303a-38c2-11ed-a261-0242ac120002"); - context.put("rate", 3.14); - context.put("company_info", Map.of("size", 120, "name", "my_company")); - context.put("anonymous", false); - context.put("email", "john.doe@gofeatureflag.org"); - context.put("lastname", "doe"); - context.put("firstname", "john"); - context.put("age", 30); - context.put("professional", true); - context.put("labels", List.of("pro", "beta")); - - Map want = new HashMap<>(); - want.put("context", context); - assertEquals(want, got); - } - } - - @Nested - class RemoteEvaluation { - @DisplayName("Should error if the endpoint is not available") - @SneakyThrows - @Test - void shouldErrorIfEndpointNotAvailable() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.REMOTE) - .timeout(1000) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(false) - .flagKey("bool_flag") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.GENERAL) - .errorMessage("Unknown error while retrieving flag ") - .build(); - assertEquals(want, got); - } - } - - @DisplayName("Should error if no API Key provided") - @SneakyThrows - @Test - void shouldErrorIfApiKeyIsMissing() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.API_KEY_MISSING); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.REMOTE) - .timeout(1000) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(false) - .flagKey("bool_flag") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.GENERAL) - .errorMessage("authentication/authorization error") - .build(); - assertEquals(want, got); - } - } - - @DisplayName("Should error if API Key is invalid") - @SneakyThrows - @Test - void shouldErrorIfApiKeyIsInvalid() { - try (val s = new MockWebServer()) { - val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.INVALID_API_KEY); - s.setDispatcher(goffAPIMock.dispatcher); - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(s.url("").toString()) - .evaluationType(EvaluationType.REMOTE) - .apiKey("invalid") - .timeout(1000) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(false) - .flagKey("bool_flag") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.GENERAL) - .errorMessage("authentication/authorization error") - .build(); - assertEquals(want, got); - } - } - - @DisplayName("Should error if the flag is not found") - @SneakyThrows - @Test - void shouldErrorIfFlagNotFound() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.REMOTE) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("does-not-exists", false, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(false) - .flagKey("does-not-exists") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.FLAG_NOT_FOUND) - .errorMessage("Flag does-not-exists not found") - .build(); - assertEquals(want, got); - } - - @DisplayName("Should error if evaluating the wrong type") - @SneakyThrows - @Test - void shouldErrorIfEvaluatingTheWrongType() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.REMOTE) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getStringDetails("bool_flag", "default", TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value("default") - .flagKey("bool_flag") - .reason(Reason.ERROR.name()) - .errorMessage( - "Flag value bool_flag had unexpected type class java.lang.Boolean, expected class java.lang.String.") - .errorCode(ErrorCode.TYPE_MISMATCH) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should resolve a valid boolean flag") - @SneakyThrows - @Test - void shouldResolveAValidBooleanFlag() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.REMOTE) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(true) - .variant("enabled") - .flagKey("bool_flag") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "A flag that is always off") - .build()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should resolve a valid string flag") - @SneakyThrows - @Test - void shouldResolveAValidStringFlag() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.REMOTE) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getStringDetails("string_flag", "false", TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value("string value") - .variant("variantA") - .flagKey("string_flag") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "A flag that is always off") - .build()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should resolve a valid int flag") - @SneakyThrows - @Test - void shouldResolveAValidIntFlag() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.REMOTE) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getIntegerDetails("int_flag", 0, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(100) - .variant("variantA") - .flagKey("int_flag") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "A flag that is always off") - .build()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should resolve a valid double flag") - @SneakyThrows - @Test - void shouldResolveAValidDoubleFlag() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.REMOTE) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getDoubleDetails("double_flag", 0.0, TestUtils.defaultEvaluationContext); - val want = FlagEvaluationDetails.builder() - .value(100.11) - .variant("variantA") - .flagKey("double_flag") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "A flag that is always off") - .build()) - .build(); - assertEquals(want, got); - } - - @DisplayName("Should resolve a valid object flag") - @SneakyThrows - @Test - void shouldResolveAValidObjectFlag() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.REMOTE) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - val got = client.getObjectDetails("object_flag", new Value("default"), TestUtils.defaultEvaluationContext); - - val want = FlagEvaluationDetails.builder() - .value(new Value(new MutableStructure().add("name", "foo").add("age", 100))) - .variant("variantA") - .flagKey("object_flag") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(ImmutableMetadata.builder() - .addString("description", "A flag that is always off") - .build()) - .build(); - assertEquals(want, got); + assertEquals( + 1, + mock.getCollectorRequestsHistory().size(), + "a duplicated data collector hook records the same evaluation once per copy"); } } - @Nested - class Tracking { - @DisplayName("Should send the evaluation information to the data collector") - @SneakyThrows - @Test - void shouldSendTrackingEventToTheDataCollector() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(100L) - .maxPendingEvents(1000) - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - client.track( - "my-key", - TestUtils.defaultEvaluationContext, - new MutableTrackingEventDetails().add("revenue", 123).add("user_id", "123ABC")); - Thread.sleep(200L); - assertEquals(1, goffAPIMock.getCollectorRequestsHistory().size()); - } - - @DisplayName("Should omit events if max pending events is reached") - @SneakyThrows - @Test - void shouldCallMultipleTimeTheDataCollectorIfMaxPendingEventsIsReached() { - GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .flushIntervalMs(100L) - .maxPendingEvents(1) - .endpoint(baseUrl.toString()) - .evaluationType(EvaluationType.IN_PROCESS) - .build()); - OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); - val client = OpenFeatureAPI.getInstance().getClient(testName); - client.track( - "my-key", - TestUtils.defaultEvaluationContext, - new MutableTrackingEventDetails().add("revenue", 123).add("user_id", "123ABC")); - client.track( - "my-key", - TestUtils.defaultEvaluationContext, - new MutableTrackingEventDetails().add("revenue", 567).add("user_id", "123ABC")); - Thread.sleep(180L); - assertEquals(2, goffAPIMock.getCollectorRequestsHistory().size()); - } + @SneakyThrows + @Test + void getMetadata_validate_name() { + assertEquals( + "GO Feature Flag Provider", + new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint("https://gofeatureflag.org") + .timeout(1000) + .build()) + .getMetadata() + .getName()); } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderConfigurationPollingTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderConfigurationPollingTest.java new file mode 100644 index 0000000000..69ebb3d838 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderConfigurationPollingTest.java @@ -0,0 +1,209 @@ +package dev.openfeature.contrib.providers.gofeatureflag; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.exceptions.GeneralError; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.SneakyThrows; +import lombok.val; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Provider configuration polling") +class ProviderConfigurationPollingTest extends AbstractGoFeatureFlagProviderTest { + @DisplayName("Should emit configuration change event, if config has changed") + @SneakyThrows + @Test + void shouldEmitConfigurationChangeEventIfConfigHasChanged() { + val s = new MockWebServer(); + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.CHANGE_CONFIG_AFTER_1ST_EVAL); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + AtomicBoolean configurationChangedCalled = new AtomicBoolean(false); + List flagsChanged = new ArrayList<>(); + client.onProviderConfigurationChanged(event -> { + configurationChangedCalled.set(true); + flagsChanged.addAll(event.getFlagsChanged()); + }); + client.getBooleanDetails("disabled_bool", false, TestUtils.defaultEvaluationContext); + + // waiting to get a flag change + int maxWait = 200; + while (!configurationChangedCalled.get() && maxWait > 0) { + maxWait--; + Thread.sleep(10L); + } + assertTrue(configurationChangedCalled.get()); + assertEquals(List.of("bool_targeting_match", "new-flag-changed", "disabled_bool"), flagsChanged); + } + + @DisplayName("Should keep polling the configuration after a 304 not-modified response") + @SneakyThrows + @Test + void shouldKeepPollingAfterANotModifiedResponse() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.NOT_MODIFIED_THEN_CHANGE); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + AtomicBoolean configurationChangedCalled = new AtomicBoolean(false); + client.onProviderConfigurationChanged(event -> configurationChangedCalled.set(true)); + + val before = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + + // the 1st poll answers a 304, so the change can only arrive on a later poll + int maxWait = 200; + while (!configurationChangedCalled.get() && maxWait > 0) { + maxWait--; + Thread.sleep(10L); + } + + assertTrue( + configurationChangedCalled.get(), + "the polling daemon must survive a 304 and pick up the next configuration change"); + assertTrue(goffAPIMock.getConfigurationCallCount() >= 3, "the 304 poll should have happened"); + val after = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + assertNotEquals(before, after); + } + } + + @DisplayName("Should not emit configuration change event, if config has not changed") + @SneakyThrows + @Test + void shouldNotEmitConfigurationChangeEventIfConfigHasNotChanged() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + AtomicBoolean configurationChangedCalled = new AtomicBoolean(false); + client.onProviderConfigurationChanged(event -> { + configurationChangedCalled.set(true); + }); + client.getBooleanDetails("disabled_bool", false, TestUtils.defaultEvaluationContext); + Thread.sleep(150L); + assertFalse(configurationChangedCalled.get()); + } + + @DisplayName("Should not emit configuration change event, if only the ETag has changed") + @SneakyThrows + @Test + void shouldNotEmitConfigurationChangeEventIfOnlyTheEtagHasChanged() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.SAME_CONFIG_CHANGING_ETAG); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + AtomicInteger changeEvents = new AtomicInteger(); + client.onProviderConfigurationChanged(event -> changeEvents.incrementAndGet()); + + Thread.sleep(500L); + + // every poll carries a validator the provider has never stored, so the ETag cannot + // tell "changed" from "fetched" and only the content can + assertTrue(goffAPIMock.getConfigurationCallCount() >= 3, "the provider should have polled"); + assertEquals(0, changeEvents.get(), "an unchanged configuration was announced as a change"); + } + } + + @DisplayName("Should change evaluation details if config has changed") + @SneakyThrows + @Test + void shouldChangeEvaluationValueIfConfigHasChanged() { + val s = new MockWebServer(); + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.CHANGE_CONFIG_AFTER_1ST_EVAL); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + AtomicBoolean configurationChangedCalled = new AtomicBoolean(false); + client.onProviderConfigurationChanged(event -> { + configurationChangedCalled.set(true); + }); + + val got1 = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + // waiting to get a flag change + int maxWait = 200; + while (!configurationChangedCalled.get() && maxWait > 0) { + maxWait--; + Thread.sleep(10L); + } + val got2 = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + assertNotEquals(got1, got2); + } + + @DisplayName("Should error if flag configuration endpoint return a 404") + @SneakyThrows + @Test + void shouldErrorIfFlagConfigurationEndpointReturn404() { + val s = new MockWebServer(); + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR_404); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + assertThrows(GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider)); + } + + @DisplayName("Should ignore configuration if etag is different by last-modified is older") + @SneakyThrows + @Test + void shouldIgnoreConfigurationIfEtagIsDifferentByLastModifiedIsOlder() { + val s = new MockWebServer(); + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.SERVE_OLD_CONFIGURATION); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + AtomicBoolean configurationChangedCalled = new AtomicBoolean(false); + client.onProviderConfigurationChanged(event -> { + configurationChangedCalled.set(true); + }); + + client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + Thread.sleep(300L); + assertFalse(configurationChangedCalled.get()); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderDataCollectorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderDataCollectorTest.java new file mode 100644 index 0000000000..f7487b1cb8 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderDataCollectorTest.java @@ -0,0 +1,278 @@ +package dev.openfeature.contrib.providers.gofeatureflag; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.SneakyThrows; +import lombok.val; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +@DisplayName("Provider data collector") +class ProviderDataCollectorTest extends AbstractGoFeatureFlagProviderTest { + @DisplayName("Should flush before the interval once max pending events is reached") + @SneakyThrows + @Test + void shouldFlushBeforeTheIntervalOnceMaxPendingEventsIsReached() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(60_000L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.getIntegerDetails("integer_key", 1000, TestUtils.defaultEvaluationContext); + client.getIntegerDetails("integer_key", 1000, TestUtils.defaultEvaluationContext); + for (int i = 0; i < 200 && goffAPIMock.getCollectorRequestsHistory().isEmpty(); i++) { + Thread.sleep(10L); + } + assertFalse( + goffAPIMock.getCollectorRequestsHistory().isEmpty(), + "a full buffer should be flushed without waiting for the interval"); + } + + @DisplayName("Should not send evaluation event if flag has tracking disabled") + @SneakyThrows + @Test + void shouldNotSendEvaluationEventIfFlagHasTrackingDisabled() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.getStringDetails("string_key", "default", TestUtils.defaultEvaluationContext); + client.getStringDetails("string_key", "default", TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + assertEquals(0, goffAPIMock.getCollectorRequestsHistory().size()); + } + + @DisplayName("Should not send an evaluation event for a flag the relay proxy evaluated") + @SneakyThrows + @Test + void shouldNotSendAnEvaluationEventForAFlagTheRelayProxyEvaluated() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + client.getBooleanDetails("flag-with-a-broken-query", false, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + + assertEquals(List.of("flag-with-a-broken-query"), mock.getEvaluatedFlagKeys()); + assertEquals(0, mock.getCollectorRequestsHistory().size()); + } + } + + @DisplayName("Should not send an evaluation event for a flag the relay proxy evaluated with the wrong type") + @SneakyThrows + @Test + void shouldNotSendAnEvaluationEventForAFlagTheRelayProxyEvaluatedWithTheWrongType() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + val details = client.getIntegerDetails("flag-with-a-broken-query", 1, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + + assertEquals( + ErrorCode.TYPE_MISMATCH, details.getErrorCode(), "the evaluation did not reach the error stage"); + assertEquals(List.of("flag-with-a-broken-query"), mock.getEvaluatedFlagKeys()); + assertEquals(0, mock.getCollectorRequestsHistory().size()); + } + } + + @DisplayName("Should send an evaluation event for a flag the engine evaluated") + @SneakyThrows + @Test + void shouldSendAnEvaluationEventForAFlagTheEngineEvaluated() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + client.getBooleanDetails("healthy-flag", false, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + + // without this the suppression above would also hold with no hook wired at all + assertEquals(1, mock.getCollectorRequestsHistory().size()); + } + } + + @DisplayName("Should not send an evaluation event for an untrackable flag that errors") + @SneakyThrows + @Test + void shouldNotSendAnEvaluationEventForAnUntrackableFlagThatErrors() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + // string_key carries trackEvents:false, and asking for it as a boolean reaches the error + // stage rather than the after stage + val details = client.getBooleanDetails("string_key", false, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + + assertEquals(ErrorCode.TYPE_MISMATCH, details.getErrorCode(), "the evaluation did not reach the error stage"); + assertEquals( + 0, + goffAPIMock.getCollectorRequestsHistory().size(), + "an untrackable flag was recorded by the error stage"); + } + + @DisplayName("Should send an evaluation event for a trackable flag that errors") + @SneakyThrows + @Test + void shouldSendAnEvaluationEventForATrackableFlagThatErrors() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + val details = client.getBooleanDetails("integer_key", false, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + + assertEquals(ErrorCode.TYPE_MISMATCH, details.getErrorCode(), "the evaluation did not reach the error stage"); + assertEquals( + 1, + goffAPIMock.getCollectorRequestsHistory().size(), + "the gate silenced the error stage for every flag, not only untrackable ones"); + } + + @DisplayName("Should send an evaluation event for a flag absent from the configuration") + @SneakyThrows + @Test + void shouldSendAnEvaluationEventForAFlagAbsentFromTheConfiguration() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + val details = + client.getBooleanDetails("a-flag-added-since-the-last-poll", false, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + + assertEquals(ErrorCode.FLAG_NOT_FOUND, details.getErrorCode()); + assertEquals( + 1, + goffAPIMock.getCollectorRequestsHistory().size(), + "a flag this provider has not polled yet went unrecorded"); + } + + @DisplayName("Should record an evaluation with no targeting key under a placeholder key") + @SneakyThrows + @Test + void shouldRecordAnEvaluationWithNoTargetingKeyUnderAPlaceholderKey() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + client.getIntegerDetails("integer_key", 1000, new ImmutableContext()); + Thread.sleep(180L); + + val body = Const.DESERIALIZE_OBJECT_MAPPER.readValue(goffAPIMock.getLastRequestBody(), HashMap.class); + val events = (List>) body.get("events"); + assertEquals(1, events.size()); + assertEquals( + "undefined-targetingKey", + events.get(0).get("userKey"), + "an evaluation with no targeting key was attributed to nobody"); + } + + @DisplayName("Should record a locally evaluated flag as an in-process evaluation") + @SneakyThrows + @ParameterizedTest(name = "{0}") + @CsvSource({"after stage, bool_targeting_match", "error stage, integer_key"}) + void shouldRecordALocallyEvaluatedFlagAsAnInProcessEvaluation(String stage, String flagKey) { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + client.getBooleanDetails(flagKey, false, TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + + val body = Const.DESERIALIZE_OBJECT_MAPPER.readValue(goffAPIMock.getLastRequestBody(), HashMap.class); + val events = (List>) body.get("events"); + assertEquals(1, events.size()); + assertEquals( + "INPROCESS", + events.get(0).get("source"), + "the collector cannot tell an in-process evaluation from one the relay proxy served"); + } + + @DisplayName("Should not send events for remote evaluation") + @SneakyThrows + @Test + void shouldResolveAValidStringFlag() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.REMOTE) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.getStringDetails("string_flag", "false", TestUtils.defaultEvaluationContext); + Thread.sleep(180L); + assertEquals(0, goffAPIMock.getCollectorRequestsHistory().size()); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderEnrichEvaluationContextTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderEnrichEvaluationContextTest.java new file mode 100644 index 0000000000..770f17ced7 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderEnrichEvaluationContextTest.java @@ -0,0 +1,97 @@ +package dev.openfeature.contrib.providers.gofeatureflag; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import dev.openfeature.sdk.OpenFeatureAPI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.SneakyThrows; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Provider evaluation context enrichment") +class ProviderEnrichEvaluationContextTest extends AbstractGoFeatureFlagProviderTest { + @DisplayName("Should add to the context the exporter metadata to the evaluation context") + @SneakyThrows + @Test + void shouldAddToTheContextTheExporterMetadataToTheEvaluationContext() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .exporterMetadata(Map.of( + "test-string", "testing-provider", "test-int", 1, "test-double", 3.14, "test-boolean", true)) + .evaluationType(EvaluationType.REMOTE) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); + val got = Const.DESERIALIZE_OBJECT_MAPPER.readValue(goffAPIMock.getLastRequestBody(), HashMap.class); + + val context = new HashMap(); + context.put("targetingKey", "d45e303a-38c2-11ed-a261-0242ac120002"); + context.put("rate", 3.14); + context.put("company_info", Map.of("size", 120, "name", "my_company")); + context.put("anonymous", false); + context.put("email", "john.doe@gofeatureflag.org"); + context.put("lastname", "doe"); + context.put("firstname", "john"); + context.put("age", 30); + context.put( + "gofeatureflag", + Map.of( + "exporterMetadata", + Map.of( + "test-double", + 3.14, + "test-int", + 1, + "test-boolean", + true, + "test-string", + "testing-provider", + "provider", + "java", + "openfeature", + true))); + context.put("professional", true); + context.put("labels", List.of("pro", "beta")); + + Map want = new HashMap<>(); + want.put("context", context); + assertEquals(want, got); + } + + @DisplayName("Should add the reserved exporter metadata even if the user configured none") + @SneakyThrows + @Test + void shouldAddTheReservedExporterMetadataEvenIfTheUserConfiguredNone() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.REMOTE) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); + val got = Const.DESERIALIZE_OBJECT_MAPPER.readValue(goffAPIMock.getLastRequestBody(), HashMap.class); + + val context = new HashMap(); + context.put("targetingKey", "d45e303a-38c2-11ed-a261-0242ac120002"); + context.put("rate", 3.14); + context.put("company_info", Map.of("size", 120, "name", "my_company")); + context.put("anonymous", false); + context.put("email", "john.doe@gofeatureflag.org"); + context.put("lastname", "doe"); + context.put("firstname", "john"); + context.put("age", 30); + context.put("professional", true); + context.put("labels", List.of("pro", "beta")); + context.put("gofeatureflag", Map.of("exporterMetadata", Map.of("provider", "java", "openfeature", true))); + + Map want = new HashMap<>(); + want.put("context", context); + assertEquals(want, got, "a provider with no configured metadata is unattributable to an SDK"); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderInProcessEvaluationTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderInProcessEvaluationTest.java new file mode 100644 index 0000000000..3bdb1a7684 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderInProcessEvaluationTest.java @@ -0,0 +1,470 @@ +package dev.openfeature.contrib.providers.gofeatureflag; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.MutableStructure; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.FatalError; +import dev.openfeature.sdk.exceptions.GeneralError; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.SneakyThrows; +import lombok.val; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Provider in-process evaluation") +class ProviderInProcessEvaluationTest extends AbstractGoFeatureFlagProviderTest { + @DisplayName("Should use in process evaluation by default") + @SneakyThrows + @Test + void shouldUseInProcessByDefault() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.getBooleanDetails("bool_targeting_match", false, new MutableContext()); + val want = "/v1/flag/configuration"; + assertEquals(want, server.takeRequest().getPath()); + } + + @DisplayName("Should use in process evaluation if option is set") + @SneakyThrows + @Test + void shouldUseInProcessIfOptionIsSet() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.getBooleanDetails("bool_targeting_match", false, new MutableContext()); + val want = "/v1/flag/configuration"; + assertEquals(want, server.takeRequest().getPath()); + } + + @DisplayName("Should throw an error if the endpoint is not available") + @SneakyThrows + @Test + void shouldThrowAnErrorIfEndpointNotAvailable() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .timeout(1000) + .build()); + assertThrows(GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, g)); + } + } + + @DisplayName("Should throw an error if api key is missing") + @SneakyThrows + @Test + void shouldThrowAnErrorIfApiKeyIsMissing() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .timeout(1000) + .build()); + assertThrows(GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, g)); + } + } + + @DisplayName("Should return FLAG_NOT_FOUND if the flag does not exists") + @SneakyThrows + @Test + void shouldReturnFlagNotFoundIfFlagDoesNotExists() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("DOES_NOT_EXISTS", false, TestUtils.defaultEvaluationContext); + + val want = FlagEvaluationDetails.builder() + .value(false) + .flagKey("DOES_NOT_EXISTS") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .errorMessage("Flag DOES_NOT_EXISTS was not found in your configuration") + .build(); + assertEquals(want, got); + } + + @DisplayName("Should throw an error if we expect a boolean and got another type") + @SneakyThrows + @Test + void shouldThrowAnErrorIfWeExpectABooleanAndGotAnotherType() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("string_key", false, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .flagKey("string_key") + .value(false) + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.TYPE_MISMATCH) + .errorMessage( + "Flag value string_key had unexpected type class java.lang.String, expected class java.lang.Boolean.") + .build(); + assertEquals(want, got); + } + + @DisplayName("Should resolve a valid boolean flag with TARGETING MATCH reason") + @SneakyThrows + @Test + void shouldResolveAValidBooleanFlagWithTargetingMatchReason() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(true) + .variant("enabled") + .flagKey("bool_targeting_match") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "this is a test flag") + .addBoolean("defaultValue", false) + .build()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should resolve a valid string flag with TARGETING MATCH reason") + @SneakyThrows + @Test + void shouldResolveAValidStringFlagWithTargetingMatchReason() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getStringDetails("string_key", "", TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value("CC0002") + .variant("color1") + .flagKey("string_key") + .reason(Reason.STATIC.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "this is a test flag") + .addString("defaultValue", "CC0000") + .build()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should resolve a valid double flag with TARGETING MATCH reason") + @SneakyThrows + @Test + void shouldResolveAValidDoubleFlagWithTargetingMatchReason() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getDoubleDetails("double_key", 100.10, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(101.25) + .variant("medium") + .flagKey("double_key") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "this is a test flag") + .addDouble("defaultValue", 100.25) + .build()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should resolve a valid integer flag with TARGETING MATCH reason") + @SneakyThrows + @Test + void shouldResolveAValidIntegerFlagWithTargetingMatchReason() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getIntegerDetails("integer_key", 1000, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(101) + .variant("medium") + .flagKey("integer_key") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "this is a test flag") + .addInteger("defaultValue", 1000) + .build()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should resolve a valid object flag with TARGETING MATCH reason") + @SneakyThrows + @Test + void shouldResolveAValidObjectFlagWithTargetingMatchReason() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getObjectDetails( + "object_key", + Value.objectToValue(new MutableStructure().add("default", "true")), + TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(Value.objectToValue(new MutableStructure().add("test", "false"))) + .variant("varB") + .flagKey("object_key") + .reason(Reason.TARGETING_MATCH.name()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should use boolean default value if the flag is disabled") + @SneakyThrows + @Test + void shouldUseBooleanDefaultValueIfTheFlagIsDisabled() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("disabled_bool", false, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(false) + .variant("SdkDefault") + .flagKey("disabled_bool") + .reason(Reason.DISABLED.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "this is a test flag") + .addBoolean("defaultValue", false) + .build()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should not reject an evaluation context without a targeting key") + @SneakyThrows + @Test + void shouldNotRejectAnEvaluationContextWithoutATargetingKey() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.DEFAULT); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + val got = client.getObjectDetails("object_key", new Value("default"), new ImmutableContext()); + + assertNull(got.getErrorCode()); + assertEquals("varA", got.getVariant()); + } + } + + @DisplayName("Should return TARGETING_KEY_MISSING if the flag needs a targeting key to bucket") + @SneakyThrows + @Test + void shouldReturnTargetingKeyMissingIfTheFlagNeedsATargetingKeyToBucket() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.DEFAULT); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + // string_key buckets on a percentage default rule, so the engine cannot evaluate it blind + val got = client.getStringDetails("string_key", "default", new ImmutableContext()); + + assertEquals(ErrorCode.TARGETING_KEY_MISSING, got.getErrorCode()); + assertEquals(Reason.ERROR.name(), got.getReason()); + assertEquals("default", got.getValue()); + } + } + + @DisplayName("Should be in FATAL state if the relay proxy rejects the credentials") + @SneakyThrows + @Test + void shouldBeInFatalStateIfTheRelayProxyRejectsTheCredentials() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.API_KEY_MISSING); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + + assertThrows(FatalError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider)); + + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + assertEquals(ErrorCode.PROVIDER_FATAL, got.getErrorCode()); + } + } + + @DisplayName("Should report PROVIDER_NOT_READY on evaluation after a failed initialization") + @SneakyThrows + @Test + void shouldReportProviderNotReadyAfterAFailedInitialization() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR_404); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flagChangePollingIntervalMs(100L) + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + assertThrows(GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider)); + + // a failed initialization leaves the provider in ERROR, which the SDK does not + // short-circuit, so the resolver is reached and must answer for itself + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + + assertEquals(ErrorCode.PROVIDER_NOT_READY, got.getErrorCode()); + assertEquals(Reason.ERROR.name(), got.getReason()); + assertEquals(false, got.getValue()); + } + } + + @DisplayName("Should apply a scheduled rollout step") + @SneakyThrows + @Test + void shouldApplyAScheduledRolloutStep() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.SCHEDULED_ROLLOUT_FLAG_CONFIG); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .timeout(1000) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("my-flag", false, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(true) + .variant("enabled") + .flagKey("my-flag") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "this is a test flag") + .addBoolean("defaultValue", false) + .build()) + .build(); + assertEquals(want, got); + } + } + + @DisplayName("Should not apply a scheduled rollout step if the date is in the future") + @SneakyThrows + @Test + void shouldNotApplyAScheduledRolloutStepIfTheDateIsInTheFuture() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.SCHEDULED_ROLLOUT_FLAG_CONFIG); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .timeout(1000) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("my-flag-scheduled-in-future", true, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(false) + .variant("disabled") + .flagKey("my-flag-scheduled-in-future") + .reason(Reason.STATIC.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "this is a test flag") + .addBoolean("defaultValue", false) + .build()) + .build(); + assertEquals(want, got); + } + } + + @DisplayName("Should evaluate flags correctly under concurrent access") + @SneakyThrows + @Test + void shouldEvaluateFlagsCorrectlyUnderConcurrentAccess() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .flagChangePollingIntervalMs(999999L) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + int threadCount = 20; + int evaluationsPerThread = 100; + AtomicInteger errorCount = new AtomicInteger(0); + CountDownLatch startGate = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + + for (int t = 0; t < threadCount; t++) { + new Thread(() -> { + try { + startGate.await(); + for (int i = 0; i < evaluationsPerThread; i++) { + FlagEvaluationDetails result = client.getBooleanDetails( + "bool_targeting_match", false, TestUtils.defaultEvaluationContext); + if (result.getErrorCode() != null) { + errorCount.incrementAndGet(); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }) + .start(); + } + + startGate.countDown(); + assertTrue(doneLatch.await(30, TimeUnit.SECONDS), "Threads did not finish in time"); + assertEquals(0, errorCount.get(), "Concurrent evaluations produced errors"); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderRemoteEvaluationTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderRemoteEvaluationTest.java new file mode 100644 index 0000000000..b553cbf4bf --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderRemoteEvaluationTest.java @@ -0,0 +1,342 @@ +package dev.openfeature.contrib.providers.gofeatureflag; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.MutableStructure; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ProviderState; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import java.util.Map; +import lombok.SneakyThrows; +import lombok.val; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Provider remote evaluation") +class ProviderRemoteEvaluationTest extends AbstractGoFeatureFlagProviderTest { + @DisplayName("Should error if the endpoint is not available") + @SneakyThrows + @Test + void shouldErrorIfEndpointNotAvailable() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.REMOTE) + .timeout(1000) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(false) + .flagKey("bool_flag") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.GENERAL) + .errorMessage("Unknown error while retrieving flag: bool_flag, status code: 500") + .build(); + assertEquals(want, got); + } + } + + @DisplayName("Should become FATAL on the first evaluation rejected for bad credentials") + @SneakyThrows + @Test + void shouldBecomeFatalOnTheFirstEvaluationRejectedForBadCredentials() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.INVALID_API_KEY); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.REMOTE) + .apiKey("a-rejected-key") + .timeout(1000) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + // remote evaluation holds no configuration, so initialization has nothing to fetch + // and cannot discover the credentials are wrong + assertEquals(ProviderState.READY, client.getProviderState()); + + client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); + + // the event is emitted on the SDK's own emitter thread + for (int i = 0; i < 100 && client.getProviderState() != ProviderState.FATAL; i++) { + Thread.sleep(20); + } + assertEquals(ProviderState.FATAL, client.getProviderState()); + } + } + + @DisplayName("Should stay READY when an evaluation fails for a repairable reason") + @SneakyThrows + @Test + void shouldStayReadyWhenAnEvaluationFailsForARepairableReason() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.REMOTE) + .timeout(1000) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); + Thread.sleep(200); + + // a 500 is repairable without touching the credentials + assertEquals(ProviderState.READY, client.getProviderState()); + } + } + + @DisplayName("Should error if no API Key provided") + @SneakyThrows + @Test + void shouldErrorIfApiKeyIsMissing() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.API_KEY_MISSING); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.REMOTE) + .timeout(1000) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(false) + .flagKey("bool_flag") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.GENERAL) + .errorMessage("authentication/authorization error for flag: bool_flag") + .build(); + assertEquals(want, got); + } + } + + @DisplayName("Should error if API Key is invalid") + @SneakyThrows + @Test + void shouldErrorIfApiKeyIsInvalid() { + try (val s = new MockWebServer()) { + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.INVALID_API_KEY); + s.setDispatcher(goffAPIMock.dispatcher); + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .evaluationType(EvaluationType.REMOTE) + .apiKey("invalid") + .timeout(1000) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(false) + .flagKey("bool_flag") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.GENERAL) + .errorMessage("authentication/authorization error for flag: bool_flag") + .build(); + assertEquals(want, got); + } + } + + @DisplayName("Should error if the flag is not found") + @SneakyThrows + @Test + void shouldErrorIfFlagNotFound() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.REMOTE) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("does-not-exists", false, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(false) + .flagKey("does-not-exists") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .errorMessage("flag: does-not-exists not found") + .build(); + assertEquals(want, got); + } + + @DisplayName("Should error if evaluating the wrong type") + @SneakyThrows + @Test + void shouldErrorIfEvaluatingTheWrongType() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.REMOTE) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getStringDetails("bool_flag", "default", TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value("default") + .flagKey("bool_flag") + .reason(Reason.ERROR.name()) + .errorMessage("Type mismatch: expected String but got Boolean") + .errorCode(ErrorCode.TYPE_MISMATCH) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "A flag that is always off") + .addBoolean("gofeatureflag_cacheable", true) + .build()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should send the custom headers with a remote evaluation") + @SneakyThrows + @Test + void shouldSendTheCustomHeadersWithARemoteEvaluation() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.REMOTE) + .customHeaders(Map.of("X-Gateway-Token", "gateway-token")) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); + + assertEquals( + "gateway-token", + goffAPIMock.getEvaluateRequestsHistory().get(0).getHeader("X-Gateway-Token"), + "the provider did not hand its custom headers to the remote evaluator"); + } + + @DisplayName("Should resolve a valid boolean flag") + @SneakyThrows + @Test + void shouldResolveAValidBooleanFlag() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.REMOTE) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getBooleanDetails("bool_flag", false, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(true) + .variant("enabled") + .flagKey("bool_flag") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "A flag that is always off") + .addBoolean("gofeatureflag_cacheable", true) + .build()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should resolve a valid string flag") + @SneakyThrows + @Test + void shouldResolveAValidStringFlag() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.REMOTE) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getStringDetails("string_flag", "false", TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value("string value") + .variant("variantA") + .flagKey("string_flag") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "A flag that is always off") + .addBoolean("gofeatureflag_cacheable", true) + .build()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should resolve a valid int flag") + @SneakyThrows + @Test + void shouldResolveAValidIntFlag() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.REMOTE) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getIntegerDetails("int_flag", 0, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(100) + .variant("variantA") + .flagKey("int_flag") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "A flag that is always off") + .addBoolean("gofeatureflag_cacheable", true) + .build()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should resolve a valid double flag") + @SneakyThrows + @Test + void shouldResolveAValidDoubleFlag() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.REMOTE) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getDoubleDetails("double_flag", 0.0, TestUtils.defaultEvaluationContext); + val want = FlagEvaluationDetails.builder() + .value(100.11) + .variant("variantA") + .flagKey("double_flag") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "A flag that is always off") + .addBoolean("gofeatureflag_cacheable", true) + .build()) + .build(); + assertEquals(want, got); + } + + @DisplayName("Should resolve a valid object flag") + @SneakyThrows + @Test + void shouldResolveAValidObjectFlag() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.REMOTE) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val got = client.getObjectDetails("object_flag", new Value("default"), TestUtils.defaultEvaluationContext); + + val want = FlagEvaluationDetails.builder() + .value(new Value(new MutableStructure().add("name", "foo").add("age", 100))) + .variant("variantA") + .flagKey("object_flag") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("description", "A flag that is always off") + .addBoolean("gofeatureflag_cacheable", true) + .build()) + .build(); + assertEquals(want, got); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderTrackingTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderTrackingTest.java new file mode 100644 index 0000000000..c5e081db21 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/ProviderTrackingTest.java @@ -0,0 +1,153 @@ +package dev.openfeature.contrib.providers.gofeatureflag; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.MutableTrackingEventDetails; +import dev.openfeature.sdk.OpenFeatureAPI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.SneakyThrows; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; + +@DisplayName("Provider tracking") +class ProviderTrackingTest extends AbstractGoFeatureFlagProviderTest { + @DisplayName("Should send the evaluation information to the data collector") + @SneakyThrows + @Test + void shouldSendTrackingEventToTheDataCollector() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1000) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.track( + "my-key", + TestUtils.defaultEvaluationContext, + new MutableTrackingEventDetails().add("revenue", 123).add("user_id", "123ABC")); + Thread.sleep(200L); + assertEquals(1, goffAPIMock.getCollectorRequestsHistory().size()); + } + + @DisplayName("Should flush buffered events on shutdown in every mode") + @ParameterizedTest(name = "{0} evaluation, data collection disabled: {1}") + @MethodSource("shutdownModes") + @SneakyThrows + void shouldFlushBufferedEventsOnShutdownInEveryMode(EvaluationType type, boolean disableDataCollection) { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + // long enough that only shutdown can flush within the test + .flushIntervalMs(60000L) + .maxPendingEvents(1000) + .endpoint(baseUrl.toString()) + .evaluationType(type) + .disableDataCollection(disableDataCollection) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.track("my-key", TestUtils.defaultEvaluationContext, new MutableTrackingEventDetails()); + assertEquals(0, goffAPIMock.getCollectorRequestsHistory().size(), "the event was flushed before shutdown"); + + provider.shutdown(); + + assertEquals( + disableDataCollection ? 0 : 1, + goffAPIMock.getCollectorRequestsHistory().size(), + "shutdown dropped the events the publisher was holding"); + } + + @DisplayName("Should build a tracking event by the same rules as a feature event") + @SneakyThrows + @Test + void shouldBuildATrackingEventByTheSameRulesAsAFeatureEvent() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1000) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + val before = System.currentTimeMillis() / 1000L; + + client.track("my-key", new ImmutableContext(), new MutableTrackingEventDetails().add("revenue", 123)); + Thread.sleep(200L); + + val body = Const.DESERIALIZE_OBJECT_MAPPER.readValue(goffAPIMock.getLastRequestBody(), HashMap.class); + val event = ((List>) body.get("events")).get(0); + assertEquals("tracking", event.get("kind")); + assertEquals(Map.of("revenue", 123), event.get("trackingEventDetails")); + assertEquals(Map.of(), event.get("evaluationContext")); + assertEquals("undefined-targetingKey", event.get("userKey"), "a tracking event was attributed to nobody"); + assertEquals("user", event.get("contextKind")); + val creationDate = ((Number) event.get("creationDate")).longValue(); + assertTrue( + creationDate >= before && creationDate <= System.currentTimeMillis() / 1000L, + "creationDate is not Unix epoch seconds: " + creationDate); + } + + @DisplayName("Should record no tracking event when data collection is disabled") + @ParameterizedTest(name = "{0} evaluation") + @EnumSource(EvaluationType.class) + @SneakyThrows + void shouldRecordNoTrackingEventWhenDataCollectionIsDisabled(EvaluationType type) { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(100L) + .maxPendingEvents(1000) + .endpoint(baseUrl.toString()) + .evaluationType(type) + .disableDataCollection(true) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + client.track("my-key", TestUtils.defaultEvaluationContext, new MutableTrackingEventDetails()); + Thread.sleep(400L); + provider.shutdown(); + + assertEquals( + 0, + goffAPIMock.getCollectorRequestsHistory().size(), + "a tracking event was sent although data collection is disabled"); + } + + @DisplayName("Should flush before the interval once max pending events is reached") + @SneakyThrows + @Test + void shouldFlushBeforeTheIntervalOnceMaxPendingEventsIsReached() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(60_000L) + .maxPendingEvents(1) + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + client.track( + "my-key", + TestUtils.defaultEvaluationContext, + new MutableTrackingEventDetails().add("revenue", 123).add("user_id", "123ABC")); + client.track( + "my-key", + TestUtils.defaultEvaluationContext, + new MutableTrackingEventDetails().add("revenue", 567).add("user_id", "123ABC")); + for (int i = 0; i < 200 && goffAPIMock.getCollectorRequestsHistory().isEmpty(); i++) { + Thread.sleep(10L); + } + assertFalse( + goffAPIMock.getCollectorRequestsHistory().isEmpty(), + "a full buffer should be flushed without waiting for the interval"); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java index 7dab61e3a1..961eb9982e 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java @@ -1,19 +1,20 @@ package dev.openfeature.contrib.providers.gofeatureflag.api; 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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; import dev.openfeature.contrib.providers.gofeatureflag.TestUtils; import dev.openfeature.contrib.providers.gofeatureflag.bean.FeatureEvent; -import dev.openfeature.contrib.providers.gofeatureflag.bean.Flag; import dev.openfeature.contrib.providers.gofeatureflag.bean.FlagConfigResponse; -import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.bean.IEvent; -import dev.openfeature.contrib.providers.gofeatureflag.bean.Rule; import dev.openfeature.contrib.providers.gofeatureflag.bean.TrackingEvent; +import dev.openfeature.contrib.providers.gofeatureflag.exception.AuthenticationFailure; import dev.openfeature.contrib.providers.gofeatureflag.exception.FlagConfigurationEndpointNotFound; import dev.openfeature.contrib.providers.gofeatureflag.exception.ImpossibleToRetrieveConfiguration; import dev.openfeature.contrib.providers.gofeatureflag.exception.ImpossibleToSendEventsException; @@ -22,7 +23,6 @@ import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; import dev.openfeature.sdk.MutableTrackingEventDetails; import dev.openfeature.sdk.exceptions.GeneralError; -import dev.openfeature.sdk.exceptions.InvalidContextError; import java.io.IOException; import java.text.SimpleDateFormat; import java.util.ArrayList; @@ -30,11 +30,17 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import lombok.val; import okhttp3.HttpUrl; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.jspecify.annotations.NonNull; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -48,6 +54,11 @@ public class GoFeatureFlagApiTest { private GoffApiMock goffAPIMock; private HttpUrl baseUrl; + private static final String TEST_FLAG_JSON = + "{\"variations\": {\"off\": false, \"on\": true}, \"defaultRule\": {\"variation\": \"off\"}}"; + private static final String TEST2_FLAG_JSON = + "{\"variations\": {\"off\": false, \"on\": true}, \"defaultRule\": {\"variation\": \"on\"}}"; + @BeforeEach void beforeEach(TestInfo testInfo) throws IOException { this.server = new MockWebServer(); @@ -99,21 +110,7 @@ public void shouldThrowInvalidOptionsIfEndpointInvalid() { } @Nested - class EvaluateFlag { - @SneakyThrows - @DisplayName("request should call the ofrep endpoint") - @Test - public void requestShouldCallTheOfrepEndpoint() { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - api.evaluateFlag("flag-key", TestUtils.defaultEvaluationContext); - - val want = "/ofrep/v1/evaluate/flags/flag-key"; - assertEquals(want, server.takeRequest().getPath()); - } - + class SendEventToDataCollector { @SneakyThrows @DisplayName("request should have an api key") @Test @@ -124,200 +121,154 @@ public void requestShouldHaveAnAPIKey() { .apiKey(apiKey) .build(); val api = GoFeatureFlagApi.builder().options(options).build(); - api.evaluateFlag("flag-key", TestUtils.defaultEvaluationContext); - - val want = Const.BEARER_TOKEN + apiKey; - assertEquals(want, server.takeRequest().getHeader("Authorization")); - } - @SneakyThrows - @DisplayName("request should not set an api key if empty") - @Test - public void requestShouldNotSetAnAPIKeyIfEmpty() { - val apiKey = ""; - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .apiKey(apiKey) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - api.evaluateFlag("flag-key", TestUtils.defaultEvaluationContext); - assertNull(server.takeRequest().getHeader("Authorization")); - } + List events = new ArrayList<>(); + Map exporterMetadata = new HashMap<>(); + api.sendEventToDataCollector(events, exporterMetadata); - @SneakyThrows - @DisplayName("request should have the evaluation context in the body") - @Test - public void requestShouldHaveTheEvaluationContextInTheBody() { - val apiKey = "my-api-key"; - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .apiKey(apiKey) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - api.evaluateFlag("flag-key", TestUtils.defaultEvaluationContext); - - val wantStr = "{\"context\":{" - + " \"targetingKey\": \"d45e303a-38c2-11ed-a261-0242ac120002\"," - + " \"email\": \"john.doe@gofeatureflag.org\"," - + " \"firstname\": \"john\"," - + " \"lastname\": \"doe\"," - + " \"anonymous\": false," - + " \"professional\": true," - + " \"rate\": 3.14," - + " \"age\": 30," - + " \"company_info\": {\"name\": \"my_company\", \"size\": 120}," - + " \"labels\": [\"pro\", \"beta\"]" - + "}}"; - val gotStr = goffAPIMock.getLastRequestBody(); - ObjectMapper objectMapper = new ObjectMapper(); - Object want = objectMapper.readTree(wantStr); - Object got = objectMapper.readTree(gotStr); - assertEquals(want, got, "The JSON strings are not equal"); + val request = server.takeRequest(); + assertEquals(apiKey, request.getHeader(Const.HTTP_HEADER_API_KEY)); + assertNull(request.getHeader("Authorization")); } @SneakyThrows - @DisplayName("request should have the default headers") + @DisplayName("request should call the collector endpoint") @Test - public void requestShouldHaveDefaultHeaders() { + public void requestShouldCallTheCollectorEndpoint() { val options = GoFeatureFlagProviderOptions.builder() .endpoint(baseUrl.toString()) .build(); val api = GoFeatureFlagApi.builder().options(options).build(); - api.evaluateFlag("flag-key", TestUtils.defaultEvaluationContext); + List events = new ArrayList<>(); + Map exporterMetadata = new HashMap<>(); + api.sendEventToDataCollector(events, exporterMetadata); - val got = server.takeRequest().getHeaders(); - assertEquals("application/json; charset=utf-8", got.get(Const.HTTP_HEADER_CONTENT_TYPE)); + val want = "/v1/data/collector"; + assertEquals(want, server.takeRequest().getPath()); } @SneakyThrows - @DisplayName("should error if timeout is reached") + @DisplayName("request should keep the path prefix of the endpoint") @Test - public void shouldErrorIfTimeoutIsReached() { + public void requestShouldKeepThePathPrefixOfTheEndpoint() { val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .timeout(200) + .endpoint(server.url("/gofeatureflagproxy/").toString()) .build(); val api = GoFeatureFlagApi.builder().options(options).build(); - assertThrows(GeneralError.class, () -> api.evaluateFlag("timeout", TestUtils.defaultEvaluationContext)); - } + api.sendEventToDataCollector(new ArrayList<>(), new HashMap<>()); - @SneakyThrows - @DisplayName("should error if response is a 401") - @Test - public void shouldErrorIfResponseIsA401() { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - assertThrows(GeneralError.class, () -> api.evaluateFlag("401", TestUtils.defaultEvaluationContext)); + val want = "/gofeatureflagproxy/v1/data/collector"; + assertEquals(want, server.takeRequest().getPath()); } @SneakyThrows - @DisplayName("should error if response is a 403") + @DisplayName("request should go to dataCollectorBaseUrl when it is set") @Test - public void shouldErrorIfResponseIsA403() { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - assertThrows(GeneralError.class, () -> api.evaluateFlag("403", TestUtils.defaultEvaluationContext)); - } + public void requestShouldGoToDataCollectorBaseUrlWhenItIsSet() { + try (val collectorServer = new MockWebServer()) { + collectorServer.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.DEFAULT).dispatcher); + collectorServer.start(); + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .dataCollectorBaseUrl( + collectorServer.url("/collector-prefix/").toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.sendEventToDataCollector(new ArrayList<>(), new HashMap<>()); - @SneakyThrows - @DisplayName("should error if response has invalid JSON") - @Test - public void shouldErrorIfResponseHasInvalidJson() { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - assertThrows( - GeneralError.class, () -> api.evaluateFlag("invalid-json", TestUtils.defaultEvaluationContext)); + val request = collectorServer.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(request, "the data collector base URL was not called"); + // the whole base is replaced: host, port and path prefix + assertEquals("/collector-prefix/v1/data/collector", request.getPath()); + assertEquals(0, server.getRequestCount(), "the endpoint should not have been called"); + } } @SneakyThrows - @DisplayName("should error if response is a 400") + @DisplayName("the other routes should keep using the endpoint when dataCollectorBaseUrl is set") @Test - public void shouldErrorIfResponseIsA400() { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - assertThrows(InvalidContextError.class, () -> api.evaluateFlag("400", TestUtils.defaultEvaluationContext)); - } + public void theOtherRoutesShouldKeepUsingTheEndpointWhenDataCollectorBaseUrlIsSet() { + try (val collectorServer = new MockWebServer()) { + collectorServer.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.DEFAULT).dispatcher); + collectorServer.start(); + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .dataCollectorBaseUrl(collectorServer.url("").toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); - @SneakyThrows - @DisplayName("should error if response is a 500") - @Test - public void shouldErrorIfResponseIsA500() { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - assertThrows(GeneralError.class, () -> api.evaluateFlag("500", TestUtils.defaultEvaluationContext)); + api.retrieveFlagConfiguration(null, Collections.emptyList()); + assertEquals("/v1/flag/configuration", server.takeRequest().getPath()); + assertEquals(0, collectorServer.getRequestCount()); + } } @SneakyThrows - @DisplayName("should have a valid evaluate response") - @Test - public void shouldHaveAValidEvaluateResponse() { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - val got = api.evaluateFlag("flag-key", TestUtils.defaultEvaluationContext); - - val want = new GoFeatureFlagResponse(); - want.setVariationType("off"); - want.setValue(false); - want.setReason("STATIC"); - want.setCacheable(true); - val metadata = new HashMap(); - metadata.put("description", "A flag that is always off"); - want.setMetadata(metadata); - want.setErrorCode(null); - want.setErrorDetails(null); - want.setFailed(false); - - assertEquals(want, got); + @DisplayName("dataCollectorBaseUrl should carry the configured timeout") + @Test + public void dataCollectorBaseUrlShouldCarryTheConfiguredTimeout() { + try (val collectorServer = new MockWebServer()) { + collectorServer.setDispatcher(new Dispatcher() { + @Override + public @NonNull MockResponse dispatch(@NonNull RecordedRequest request) { + return new MockResponse().setResponseCode(200).setHeadersDelay(5, TimeUnit.SECONDS); + } + }); + collectorServer.start(); + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .dataCollectorBaseUrl(collectorServer.url("").toString()) + .timeout(200) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + assertThrows( + ImpossibleToSendEventsException.class, + () -> api.sendEventToDataCollector(new ArrayList<>(), new HashMap<>())); + } } - } - @Nested - class SendEventToDataCollector { @SneakyThrows - @DisplayName("request should have an api key") + @DisplayName("dataCollectorBaseUrl should carry the api key and the timeout") @Test - public void requestShouldHaveAnAPIKey() { - val apiKey = "my-api-key"; - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .apiKey(apiKey) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); + public void dataCollectorBaseUrlShouldCarryTheApiKeyAndTheTimeout() { + try (val collectorServer = new MockWebServer()) { + collectorServer.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.DEFAULT).dispatcher); + collectorServer.start(); + val apiKey = "my-api-key"; + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .dataCollectorBaseUrl(collectorServer.url("").toString()) + .apiKey(apiKey) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.sendEventToDataCollector(new ArrayList<>(), new HashMap<>()); - List events = new ArrayList<>(); - Map exporterMetadata = new HashMap<>(); - api.sendEventToDataCollector(events, exporterMetadata); - - val want = Const.BEARER_TOKEN + apiKey; - assertEquals(want, server.takeRequest().getHeader("Authorization")); + val request = collectorServer.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(request, "the data collector base URL was not called"); + assertEquals(apiKey, request.getHeader(Const.HTTP_HEADER_API_KEY)); + } } @SneakyThrows - @DisplayName("request should call the collector endpoint") + @DisplayName("dataCollectorBaseUrl should carry the custom headers") @Test - public void requestShouldCallTheCollectorEndpoint() { - val options = GoFeatureFlagProviderOptions.builder() - .endpoint(baseUrl.toString()) - .build(); - val api = GoFeatureFlagApi.builder().options(options).build(); - List events = new ArrayList<>(); - Map exporterMetadata = new HashMap<>(); - api.sendEventToDataCollector(events, exporterMetadata); + public void dataCollectorBaseUrlShouldCarryTheCustomHeaders() { + try (val collectorServer = new MockWebServer()) { + collectorServer.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.DEFAULT).dispatcher); + collectorServer.start(); + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .dataCollectorBaseUrl(collectorServer.url("").toString()) + .customHeaders(Map.of("X-Gateway-Token", "gateway-token")) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.sendEventToDataCollector(new ArrayList<>(), new HashMap<>()); - val want = "/v1/data/collector"; - assertEquals(want, server.takeRequest().getPath()); + val request = collectorServer.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(request, "the data collector base URL was not called"); + assertEquals("gateway-token", request.getHeader("X-Gateway-Token")); + } } @SneakyThrows @@ -333,7 +284,7 @@ public void requestShouldNotSetAnAPIKeyIfEmpty() { List events = new ArrayList<>(); Map exporterMetadata = new HashMap<>(); api.sendEventToDataCollector(events, exporterMetadata); - assertNull(server.takeRequest().getHeader("Authorization")); + assertNull(server.takeRequest().getHeader(Const.HTTP_HEADER_API_KEY)); } @SneakyThrows @@ -366,6 +317,7 @@ public void requestShouldHaveTheEvaluationContextInTheBody() { .creationDate(1617970547L) .contextKind("anonymousUser") .kind("feature") + .source("INPROCESS") .userKey("ABCD") .variation("enabled") .value(true) @@ -480,8 +432,39 @@ public void requestShouldHaveAnAPIKey() { val api = GoFeatureFlagApi.builder().options(options).build(); api.retrieveFlagConfiguration(null, Collections.emptyList()); - val want = Const.BEARER_TOKEN + apiKey; - assertEquals(want, server.takeRequest().getHeader("Authorization")); + val request = server.takeRequest(); + assertEquals(apiKey, request.getHeader(Const.HTTP_HEADER_API_KEY)); + assertNull(request.getHeader("Authorization")); + } + + @SneakyThrows + @DisplayName("request should carry the custom headers") + @Test + public void requestShouldCarryTheCustomHeaders() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .customHeaders(Map.of("X-Gateway-Token", "gateway-token")) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.retrieveFlagConfiguration(null, Collections.emptyList()); + + assertEquals("gateway-token", server.takeRequest().getHeader("X-Gateway-Token")); + } + + @SneakyThrows + @DisplayName("a configured api key should win over a custom header of the same name") + @Test + public void aConfiguredApiKeyShouldWinOverACustomHeaderOfTheSameName() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .apiKey("my-api-key") + .customHeaders(Map.of("x-api-key", "custom-key")) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.retrieveFlagConfiguration(null, Collections.emptyList()); + + val request = server.takeRequest(); + assertEquals(List.of("my-api-key"), request.getHeaders().values(Const.HTTP_HEADER_API_KEY)); } @SneakyThrows @@ -498,6 +481,169 @@ public void requestShouldCallTheConfigurationEndpoint() { assertEquals(want, server.takeRequest().getPath()); } + @SneakyThrows + @DisplayName("request should keep the path prefix of the endpoint") + @Test + public void requestShouldKeepThePathPrefixOfTheEndpoint() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(server.url("/gofeatureflagproxy/").toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.retrieveFlagConfiguration(null, Collections.emptyList()); + + val want = "/gofeatureflagproxy/v1/flag/configuration"; + assertEquals(want, server.takeRequest().getPath()); + } + + @SneakyThrows + @DisplayName("request should keep the path prefix of an endpoint without a trailing slash") + @Test + public void requestShouldKeepThePathPrefixOfAnEndpointWithoutTrailingSlash() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(server.url("/gofeatureflagproxy").toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.retrieveFlagConfiguration(null, Collections.emptyList()); + + val want = "/gofeatureflagproxy/v1/flag/configuration"; + assertEquals(want, server.takeRequest().getPath()); + } + + @SneakyThrows + @DisplayName("a flag should be kept opaque, including fields this provider has no model for") + @Test + public void aFlagShouldBeKeptOpaque() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + val got = api.retrieveFlagConfiguration("unknown-flag-field", Collections.emptyList()); + assertTrue(got.isPresent()); + val flag = got.get().getFlags().get("TEST"); + + // a field the engine may have gained after this provider was written + assertEquals( + Const.DESERIALIZE_OBJECT_MAPPER.readTree("{\"nested\": [1, 2, 3]}"), + flag.get("aFieldFromANewerEngine")); + // and a field the deleted typed model never declared + assertEquals("teamId", flag.get("bucketingKey").asText()); + } + + @SneakyThrows + @DisplayName("an unknown field beside the flags should be tolerated") + @Test + public void anUnknownFieldBesideTheFlagsShouldBeTolerated() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + val got = api.retrieveFlagConfiguration("unknown-response-field", Collections.emptyList()); + + // a top level field the provider has no property for must not fail the refresh + assertTrue(got.isPresent()); + assertEquals( + Const.DESERIALIZE_OBJECT_MAPPER.readTree("{\"deep\": {\"deeper\": 1}}"), + got.get().getFlags().get("TEST").get("aFieldFromANewerEngine")); + assertEquals(1, got.get().getEvaluationContextEnrichment().get("anUnknownEnrichmentKey")); + } + + @SneakyThrows + @DisplayName("a 200 carrying no flag map should be a failed refresh") + @Test + public void a200CarryingNoFlagMapShouldBeAFailedRefresh() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + assertThrows( + ImpossibleToRetrieveConfiguration.class, + () -> api.retrieveFlagConfiguration("no-flags", Collections.emptyList())); + } + + @SneakyThrows + @DisplayName("a 200 with a null flag map should be a failed refresh") + @Test + public void a200WithANullFlagMapShouldBeAFailedRefresh() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + assertThrows( + ImpossibleToRetrieveConfiguration.class, + () -> api.retrieveFlagConfiguration("null-flags", Collections.emptyList())); + } + + @SneakyThrows + @DisplayName("a 200 with trailing tokens should be a failed refresh") + @Test + public void a200WithTrailingTokensShouldBeAFailedRefresh() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + assertThrows( + ImpossibleToRetrieveConfiguration.class, + () -> api.retrieveFlagConfiguration("trailing-tokens", Collections.emptyList())); + } + + @SneakyThrows + @DisplayName("a 200 whose body is the json literal null should be a failed refresh") + @Test + public void a200WithANullBodyShouldBeAFailedRefresh() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + assertThrows( + ImpossibleToRetrieveConfiguration.class, + () -> api.retrieveFlagConfiguration("null-body", Collections.emptyList())); + } + + @SneakyThrows + @DisplayName("a null evaluationContextEnrichment should be accepted as no enrichment") + @Test + public void aNullEvaluationContextEnrichmentShouldBeAccepted() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + val got = api.retrieveFlagConfiguration("null-enrichment", Collections.emptyList()); + assertTrue(got.isPresent()); + assertNull(got.get().getEvaluationContextEnrichment()); + assertEquals(1, got.get().getFlags().size()); + } + + @SneakyThrows + @DisplayName("a 304 should not return a configuration") + @Test + public void a304ShouldNotReturnAConfiguration() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + assertEquals(Optional.empty(), api.retrieveFlagConfiguration("304-with-etag", Collections.emptyList())); + } + + @SneakyThrows + @DisplayName("a 304 without an etag should not return a configuration either") + @Test + public void a304WithoutAnEtagShouldNotReturnAConfiguration() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + + assertEquals(Optional.empty(), api.retrieveFlagConfiguration("304-without-etag", Collections.emptyList())); + } + @SneakyThrows @DisplayName("request should not set an api key if empty") @Test @@ -509,7 +655,7 @@ public void requestShouldNotSetAnAPIKeyIfEmpty() { .build(); val api = GoFeatureFlagApi.builder().options(options).build(); api.retrieveFlagConfiguration(null, Collections.emptyList()); - assertNull(server.takeRequest().getHeader("Authorization")); + assertNull(server.takeRequest().getHeader(Const.HTTP_HEADER_API_KEY)); } @SneakyThrows @@ -563,9 +709,9 @@ public void requestShouldHaveReturn401() { .endpoint(baseUrl.toString()) .build(); val api = GoFeatureFlagApi.builder().options(options).build(); + // fatal, not retryable: the SDK moves the provider to FATAL on a PROVIDER_FATAL error assertThrows( - ImpossibleToRetrieveConfiguration.class, - () -> api.retrieveFlagConfiguration("401", Collections.emptyList())); + AuthenticationFailure.class, () -> api.retrieveFlagConfiguration("401", Collections.emptyList())); } @SneakyThrows @@ -576,9 +722,9 @@ public void requestShouldHaveReturn403() { .endpoint(baseUrl.toString()) .build(); val api = GoFeatureFlagApi.builder().options(options).build(); + // fatal, not retryable: the SDK moves the provider to FATAL on a PROVIDER_FATAL error assertThrows( - ImpossibleToRetrieveConfiguration.class, - () -> api.retrieveFlagConfiguration("403", Collections.emptyList())); + AuthenticationFailure.class, () -> api.retrieveFlagConfiguration("403", Collections.emptyList())); } @SneakyThrows @@ -635,26 +781,11 @@ public void requestShouldHaveReturn200SimpleFlags() { val evaluationContextEnrichment = new HashMap(); evaluationContextEnrichment.put("env", "production"); - val flags = new HashMap(); - val variations = new HashMap(); - variations.put("on", true); - variations.put("off", false); - val rule = new Rule(); - rule.setVariation("off"); - - val rule2 = new Rule(); - rule2.setVariation("on"); - - val flag1 = new Flag(); - flag1.setVariations(variations); - flag1.setDefaultRule(rule); - - val flag2 = new Flag(); - flag2.setVariations(variations); - flag2.setDefaultRule(rule2); - - flags.put("TEST", flag1); - flags.put("TEST2", flag2); + // flags are compared as raw JSON: the provider must not reconstruct them from a typed + // model, so there is no model here to compare against either. + val flags = new HashMap(); + flags.put("TEST", Const.DESERIALIZE_OBJECT_MAPPER.readTree(TEST_FLAG_JSON)); + flags.put("TEST2", Const.DESERIALIZE_OBJECT_MAPPER.readTree(TEST2_FLAG_JSON)); val want = FlagConfigResponse.builder() .flags(flags) .etag("\"valid-flag-config.json\"") @@ -662,7 +793,7 @@ public void requestShouldHaveReturn200SimpleFlags() { .parse("Wed, 21 Oct 2015 07:28:00 GMT")) .evaluationContextEnrichment(evaluationContextEnrichment) .build(); - assertEquals(want, got); + assertEquals(Optional.of(want), got); } @SneakyThrows @@ -680,33 +811,18 @@ public void requestShouldNotReturnLastModifiedDateIfInvalidHeader() { val evaluationContextEnrichment = new HashMap(); evaluationContextEnrichment.put("env", "production"); - val flags = new HashMap(); - val variations = new HashMap(); - variations.put("on", true); - variations.put("off", false); - val rule = new Rule(); - rule.setVariation("off"); - - val rule2 = new Rule(); - rule2.setVariation("on"); - - val flag1 = new Flag(); - flag1.setVariations(variations); - flag1.setDefaultRule(rule); - - val flag2 = new Flag(); - flag2.setVariations(variations); - flag2.setDefaultRule(rule2); - - flags.put("TEST", flag1); - flags.put("TEST2", flag2); + // flags are compared as raw JSON: the provider must not reconstruct them from a typed + // model, so there is no model here to compare against either. + val flags = new HashMap(); + flags.put("TEST", Const.DESERIALIZE_OBJECT_MAPPER.readTree(TEST_FLAG_JSON)); + flags.put("TEST2", Const.DESERIALIZE_OBJECT_MAPPER.readTree(TEST2_FLAG_JSON)); val want = FlagConfigResponse.builder() .flags(flags) .etag("\"valid-flag-config.json\"") .lastUpdated(null) .evaluationContextEnrichment(evaluationContextEnrichment) .build(); - assertEquals(want, got); + assertEquals(Optional.of(want), got); } } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepResponseTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepResponseTest.java deleted file mode 100644 index b5aa5f0e7c..0000000000 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepResponseTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.api.bean; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; -import java.util.HashMap; -import java.util.Map; -import lombok.val; -import org.junit.jupiter.api.Test; - -class OfrepResponseTest { - @Test - void testToGoFeatureFlagResponse_AllFieldsMappedCorrectly() { - val ofrepResponse = new OfrepResponse(); - ofrepResponse.setValue("testValue"); - ofrepResponse.setKey("testKey"); - ofrepResponse.setVariant("testVariant"); - ofrepResponse.setReason("testReason"); - ofrepResponse.setErrorCode("testErrorCode"); - ofrepResponse.setErrorDetails("testErrorDetails"); - - Map metadata = new HashMap<>(); - metadata.put("gofeatureflag_cacheable", true); - metadata.put("gofeatureflag_version", "v1.0"); - metadata.put("extra_metadata", "extraValue"); - ofrepResponse.setMetadata(metadata); - - // Act - GoFeatureFlagResponse goFeatureFlagResponse = ofrepResponse.toGoFeatureFlagResponse(); - - // Assert - assertEquals("testValue", goFeatureFlagResponse.getValue()); - assertEquals("testVariant", goFeatureFlagResponse.getVariationType()); - assertEquals("testReason", goFeatureFlagResponse.getReason()); - assertEquals("testErrorCode", goFeatureFlagResponse.getErrorCode()); - assertEquals("testErrorDetails", goFeatureFlagResponse.getErrorDetails()); - assertTrue(goFeatureFlagResponse.isFailed()); - assertTrue(goFeatureFlagResponse.isCacheable()); - assertEquals("v1.0", goFeatureFlagResponse.getVersion()); - assertEquals(1, goFeatureFlagResponse.getMetadata().size()); - assertEquals("extraValue", goFeatureFlagResponse.getMetadata().get("extra_metadata")); - } - - @Test - void testToGoFeatureFlagResponse_NoCacheableOrVersionInMetadata() { - OfrepResponse ofrepResponse = new OfrepResponse(); - ofrepResponse.setMetadata(new HashMap<>()); - - GoFeatureFlagResponse goFeatureFlagResponse = ofrepResponse.toGoFeatureFlagResponse(); - - assertFalse(goFeatureFlagResponse.isCacheable()); - assertNull(goFeatureFlagResponse.getVersion()); - } - - @Test - void testToGoFeatureFlagResponse_NullMetadata() { - // Arrange - OfrepResponse ofrepResponse = new OfrepResponse(); - ofrepResponse.setMetadata(null); - - // Act - GoFeatureFlagResponse goFeatureFlagResponse = ofrepResponse.toGoFeatureFlagResponse(); - - // Assert - assertNull(goFeatureFlagResponse.getMetadata()); - } - - @Test - void testToGoFeatureFlagResponse_ErrorCodeIsNull() { - OfrepResponse ofrepResponse = new OfrepResponse(); - ofrepResponse.setErrorCode(null); - GoFeatureFlagResponse goFeatureFlagResponse = ofrepResponse.toGoFeatureFlagResponse(); - assertFalse(goFeatureFlagResponse.isFailed()); - } -} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/AbstractRelayProxyIntegrationTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/AbstractRelayProxyIntegrationTest.java new file mode 100644 index 0000000000..947b7069f3 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/AbstractRelayProxyIntegrationTest.java @@ -0,0 +1,39 @@ +package dev.openfeature.contrib.providers.gofeatureflag.e2e; + +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.options; +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.provider; + +import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions.GoFeatureFlagProviderOptionsBuilder; +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.OpenFeatureAPI; +import lombok.SneakyThrows; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.testcontainers.containers.GenericContainer; + +/** Gives each test a provider domain of its own, and shuts its provider down once it is done. */ +abstract class AbstractRelayProxyIntegrationTest { + protected String domain; + + @BeforeEach + void beforeEach(TestInfo testInfo) { + this.domain = testInfo.getDisplayName(); + } + + @AfterEach + void afterEach() { + OpenFeatureAPI.getInstance().shutdown(); + } + + @SneakyThrows + protected Client client(final GoFeatureFlagProviderOptionsBuilder options) { + OpenFeatureAPI.getInstance().setProviderAndWait(domain, provider(options)); + return OpenFeatureAPI.getInstance().getClient(domain); + } + + protected Client client(final EvaluationType type, final GenericContainer relayProxy, final String apiKey) { + return client(options(type, relayProxy).apiKey(apiKey)); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/AuthenticationIntegrationTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/AuthenticationIntegrationTest.java new file mode 100644 index 0000000000..24bbf575d2 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/AuthenticationIntegrationTest.java @@ -0,0 +1,67 @@ +package dev.openfeature.contrib.providers.gofeatureflag.e2e; + +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.options; +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.provider; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import dev.openfeature.contrib.providers.gofeatureflag.TestUtils; +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.exceptions.FatalError; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** Runs the provider against a relay proxy that only serves callers sending the authorized_token API key. */ +@Testcontainers +class AuthenticationIntegrationTest extends AbstractRelayProxyIntegrationTest { + @Container + private static final GenericContainer authenticatedRelayProxy = RelayProxyTestHelper.authenticatedRelayProxy(); + + @DisplayName("a relay proxy that requires an API key should serve a provider that sends it") + @ParameterizedTest(name = "{0} evaluation") + @EnumSource(EvaluationType.class) + void aRelayProxyThatRequiresAnApiKeyShouldServeAProviderThatSendsIt(EvaluationType type) { + val client = client(type, authenticatedRelayProxy, "authorized_token"); + + val got = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + + assertEquals(true, got.getValue()); + assertEquals("True", got.getVariant(), "the relay proxy accepted the key and evaluated the flag"); + } + + @DisplayName("an in-process provider with a rejected API key should fail its initialization") + @Test + void anInProcessProviderWithARejectedApiKeyShouldFailItsInitialization() { + val provider = provider( + options(EvaluationType.IN_PROCESS, authenticatedRelayProxy).apiKey("wrong_token")); + + assertThrows(FatalError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(domain, provider)); + + val got = OpenFeatureAPI.getInstance() + .getClient(domain) + .getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + assertEquals(false, got.getValue()); + assertEquals(ErrorCode.PROVIDER_FATAL, got.getErrorCode()); + } + + @DisplayName("a remote provider with a rejected API key should fail its first evaluation") + @Test + void aRemoteProviderWithARejectedApiKeyShouldFailItsFirstEvaluation() { + val client = client(EvaluationType.REMOTE, authenticatedRelayProxy, "wrong_token"); + + val got = client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + + assertEquals(false, got.getValue()); + assertEquals(ErrorCode.GENERAL, got.getErrorCode()); + assertEquals(Reason.ERROR.name(), got.getReason()); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/FlagChangeIntegrationTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/FlagChangeIntegrationTest.java new file mode 100644 index 0000000000..9658371c24 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/FlagChangeIntegrationTest.java @@ -0,0 +1,57 @@ +package dev.openfeature.contrib.providers.gofeatureflag.e2e; + +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.MUTABLE_FLAG; +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.mutableFlagValue; +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.mutableRelayProxy; +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.options; +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.serveMutableFlagVariation; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Changes a flag on a relay proxy of the test's own, and checks when each evaluation mode sees it. */ +class FlagChangeIntegrationTest extends AbstractRelayProxyIntegrationTest { + + @DisplayName("an in-process provider should pick up a flag change by polling the relay proxy") + @Test + void anInProcessProviderShouldPickUpAFlagChangeByPollingTheRelayProxy() { + try (val relayProxy = mutableRelayProxy()) { + relayProxy.start(); + val client = client(options(EvaluationType.IN_PROCESS, relayProxy).flagChangePollingIntervalMs(500L)); + val flagsChanged = new AtomicReference>(); + client.onProviderConfigurationChanged(details -> flagsChanged.set(details.getFlagsChanged())); + assertEquals("A", mutableFlagValue(client)); + + serveMutableFlagVariation(relayProxy, "B"); + + await().atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> assertEquals(List.of(MUTABLE_FLAG), flagsChanged.get())); + assertEquals("B", mutableFlagValue(client)); + } + } + + @DisplayName("a remote provider should see a flag change on its next evaluation without polling") + @Test + void aRemoteProviderShouldSeeAFlagChangeOnItsNextEvaluationWithoutPolling() { + try (val relayProxy = mutableRelayProxy()) { + relayProxy.start(); + val client = client(EvaluationType.REMOTE, relayProxy, null); + val configurationChanges = new AtomicInteger(); + client.onProviderConfigurationChanged(details -> configurationChanges.incrementAndGet()); + assertEquals("A", mutableFlagValue(client)); + + serveMutableFlagVariation(relayProxy, "B"); + + await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> assertEquals("B", mutableFlagValue(client))); + assertEquals(0, configurationChanges.get(), "a remote provider holds no configuration to announce"); + } + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/FlagEvaluationIntegrationTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/FlagEvaluationIntegrationTest.java new file mode 100644 index 0000000000..bf4710528b --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/FlagEvaluationIntegrationTest.java @@ -0,0 +1,198 @@ +package dev.openfeature.contrib.providers.gofeatureflag.e2e; + +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.TARGETING_KEY; +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.evaluate; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +import dev.openfeature.contrib.providers.gofeatureflag.TestUtils; +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.MutableStructure; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Runs the provider against a real relay proxy serving the cross-language fixture of Appendix B.4, + * go-feature-flag's openfeature/provider_tests, with its flags and relay proxy configurations vendored + * unmodified. + */ +@Testcontainers +class FlagEvaluationIntegrationTest extends AbstractRelayProxyIntegrationTest { + @Container + private static final GenericContainer relayProxy = RelayProxyTestHelper.relayProxy(); + + private static final String TARGETING_MATCH = Reason.TARGETING_MATCH.name(); + private static final String DEFAULT = Reason.DEFAULT.name(); + private static final Map PR_916 = Map.of( + "description", "this is a test", + "pr_link", "https://github.com/thomaspoignant/go-feature-flag/pull/916"); + + static Stream enabledFlags() { + val trueObject = new Value(new MutableStructure() + .add("test", "test1") + .add("test2", false) + .add("test3", 123.3) + .add("test4", 1)); + val semverMetadata = Map.of( + "description", "this is a semver matching test", + "pr_link", "https://github.com/thomaspoignant/go-feature-flag/pull/4764"); + return Arrays.stream(EvaluationType.values()) + .flatMap(type -> Stream.of( + Arguments.of(type, "bool_targeting_match", false, true, "True", TARGETING_MATCH, PR_916), + Arguments.of(type, "string_key", "", "CC0000", "True", TARGETING_MATCH, PR_916), + Arguments.of(type, "string_key_with_version", "", "CC0000", "True", TARGETING_MATCH, PR_916), + Arguments.of(type, "integer_key", 0, 100, "True", TARGETING_MATCH, PR_916), + Arguments.of(type, "double_key", 0.0, 100.25, "True", TARGETING_MATCH, PR_916), + Arguments.of(type, "object_key", new Value(), trueObject, "True", TARGETING_MATCH, PR_916), + Arguments.of( + type, + "flag-use-evaluation-context-enrichment", + "", + "A", + "A", + TARGETING_MATCH, + Map.of()), + Arguments.of( + type, + "boolean_semver_targeting_match", + true, + false, + "False", + DEFAULT, + semverMetadata))); + } + + static Stream disabledFlags() { + return Arrays.stream(EvaluationType.values()) + .flatMap(type -> Stream.of( + Arguments.of(type, "disabled_bool", false), + Arguments.of(type, "disabled_string", "sdk-default"), + Arguments.of(type, "disabled_int", 0), + Arguments.of(type, "disabled_float", 0.0), + Arguments.of(type, "disabled_interface", new Value()))); + } + + static Stream semverVersions() { + return Arrays.stream(EvaluationType.values()) + .flatMap(type -> Stream.of( + Arguments.of(type, "10.0.0-10", true, "True", TARGETING_MATCH), + Arguments.of(type, "10.0.0-2", false, "False", DEFAULT))); + } + + @DisplayName("the canonical context should resolve every enabled flag of the fixture") + @ParameterizedTest(name = "{0} evaluation of {1}") + @MethodSource("enabledFlags") + void theCanonicalContextShouldResolveEveryEnabledFlagOfTheFixture( + EvaluationType type, + String flagKey, + Object defaultValue, + Object expectedValue, + String expectedVariant, + String expectedReason, + Map flagMetadata) { + val client = client(type, relayProxy, null); + val expectedMetadata = new HashMap<>(flagMetadata); + if (type == EvaluationType.REMOTE) { + expectedMetadata.put("gofeatureflag_cacheable", true); + } + + val got = evaluate(client, flagKey, defaultValue); + + assertEquals(expectedValue, got.getValue()); + assertEquals(expectedVariant, got.getVariant()); + assertEquals(expectedReason, got.getReason()); + assertEquals( + expectedMetadata, + got.getFlagMetadata().asUnmodifiableMap(), + "the flag metadata unmodified, with nothing added but what the relay proxy adds when it evaluates"); + } + + @DisplayName("a context the targeting query does not match should resolve through the default rule") + @ParameterizedTest(name = "{0} evaluation") + @EnumSource(EvaluationType.class) + void aContextTheTargetingQueryDoesNotMatchShouldResolveThroughTheDefaultRule(EvaluationType type) { + val client = client(type, relayProxy, null); + val ctx = new MutableContext(TARGETING_KEY).add("email", "jane.doe@gofeatureflag.org"); + + val got = client.getBooleanDetails("bool_targeting_match", false, ctx); + + assertEquals(true, got.getValue(), "the default rule sends 100% of the traffic to True"); + assertEquals("True", got.getVariant()); + assertEquals(DEFAULT, got.getReason()); + } + + @DisplayName("a semver targeting query should compare prerelease versions numerically") + @ParameterizedTest(name = "{0} evaluation of version {1}") + @MethodSource("semverVersions") + void aSemverTargetingQueryShouldComparePrereleaseVersionsNumerically( + EvaluationType type, String version, boolean expectedValue, String expectedVariant, String reason) { + val client = client(type, relayProxy, null); + val ctx = new MutableContext(TARGETING_KEY).add("version", version); + + val got = client.getBooleanDetails("boolean_semver_targeting_match", false, ctx); + + assertEquals(expectedValue, got.getValue()); + assertEquals(expectedVariant, got.getVariant()); + assertEquals(reason, got.getReason()); + } + + @DisplayName("an unknown flag should resolve to the default value with FLAG_NOT_FOUND") + @ParameterizedTest(name = "{0} evaluation") + @EnumSource(EvaluationType.class) + void anUnknownFlagShouldResolveToTheDefaultValueWithFlagNotFound(EvaluationType type) { + val client = client(type, relayProxy, null); + + val got = client.getBooleanDetails("does_not_exist", false, TestUtils.defaultEvaluationContext); + + assertEquals(false, got.getValue()); + assertEquals(ErrorCode.FLAG_NOT_FOUND, got.getErrorCode()); + assertEquals(Reason.ERROR.name(), got.getReason()); + } + + @DisplayName("a flag evaluated as the wrong type should resolve to the default value with TYPE_MISMATCH") + @ParameterizedTest(name = "{0} evaluation") + @EnumSource(EvaluationType.class) + void aFlagEvaluatedAsTheWrongTypeShouldResolveToTheDefaultValueWithTypeMismatch(EvaluationType type) { + val client = client(type, relayProxy, null); + + val got = client.getStringDetails("bool_targeting_match", "sdk-default", TestUtils.defaultEvaluationContext); + + assertEquals("sdk-default", got.getValue()); + assertEquals(ErrorCode.TYPE_MISMATCH, got.getErrorCode()); + assertEquals(Reason.ERROR.name(), got.getReason()); + } + + @DisplayName("a disabled flag should resolve to the default value with DISABLED") + @ParameterizedTest(name = "{0} evaluation of {1}") + @MethodSource("disabledFlags") + void aDisabledFlagShouldResolveToTheDefaultValueWithDisabled( + EvaluationType type, String flagKey, Object defaultValue) { + assumeFalse( + type == EvaluationType.REMOTE, + "the relay proxy answers a disabled flag with a null value over OFREP, which the OFREP" + + " resolver reports as FLAG_NOT_FOUND"); + val client = client(type, relayProxy, null); + + val got = evaluate(client, flagKey, defaultValue); + + assertEquals(defaultValue, got.getValue()); + assertEquals(Reason.DISABLED.name(), got.getReason()); + assertNull(got.getErrorCode()); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/RelayProxyOutageIntegrationTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/RelayProxyOutageIntegrationTest.java new file mode 100644 index 0000000000..e31f6f70b1 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/RelayProxyOutageIntegrationTest.java @@ -0,0 +1,59 @@ +package dev.openfeature.contrib.providers.gofeatureflag.e2e; + +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.MUTABLE_FLAG; +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.mutableFlagValue; +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.mutableRelayProxy; +import static dev.openfeature.contrib.providers.gofeatureflag.e2e.RelayProxyTestHelper.options; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import dev.openfeature.contrib.providers.gofeatureflag.TestUtils; +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.Reason; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicBoolean; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Stops a relay proxy of the test's own once the provider is ready, and checks what each evaluation mode serves. */ +class RelayProxyOutageIntegrationTest extends AbstractRelayProxyIntegrationTest { + + @DisplayName("an in-process provider should keep serving its last configuration once the relay proxy is gone") + @Test + void anInProcessProviderShouldKeepServingItsLastConfigurationOnceTheRelayProxyIsGone() { + try (val relayProxy = mutableRelayProxy()) { + relayProxy.start(); + val client = client(options(EvaluationType.IN_PROCESS, relayProxy).flagChangePollingIntervalMs(200L)); + val stale = new AtomicBoolean(); + client.onProviderStale(details -> stale.set(true)); + + relayProxy.stop(); + + val got = client.getStringDetails(MUTABLE_FLAG, "sdk-default", TestUtils.defaultEvaluationContext); + assertEquals("A", got.getValue()); + assertNull(got.getErrorCode()); + await().atMost(Duration.ofSeconds(10)).untilTrue(stale); + assertEquals("A", mutableFlagValue(client), "a stale configuration is still served"); + } + } + + @DisplayName("a remote provider should fail its evaluations once the relay proxy is gone") + @Test + void aRemoteProviderShouldFailItsEvaluationsOnceTheRelayProxyIsGone() { + try (val relayProxy = mutableRelayProxy()) { + relayProxy.start(); + val client = client(options(EvaluationType.REMOTE, relayProxy).timeout(1000)); + assertEquals("A", mutableFlagValue(client)); + + relayProxy.stop(); + + val got = client.getStringDetails(MUTABLE_FLAG, "sdk-default", TestUtils.defaultEvaluationContext); + assertEquals("sdk-default", got.getValue()); + assertEquals(ErrorCode.GENERAL, got.getErrorCode()); + assertEquals(Reason.ERROR.name(), got.getReason()); + } + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/RelayProxyTestHelper.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/RelayProxyTestHelper.java new file mode 100644 index 0000000000..7bc1339b44 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/e2e/RelayProxyTestHelper.java @@ -0,0 +1,109 @@ +package dev.openfeature.contrib.providers.gofeatureflag.e2e; + +import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProvider; +import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; +import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions.GoFeatureFlagProviderOptionsBuilder; +import dev.openfeature.contrib.providers.gofeatureflag.TestUtils; +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Value; +import lombok.SneakyThrows; +import lombok.val; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +/** Builds the relay proxies the end-to-end tests run against, and the providers that talk to them. */ +final class RelayProxyTestHelper { + static final String MUTABLE_FLAG = "mutable_flag"; + static final String TARGETING_KEY = "d45e303a-38c2-11ed-a261-0242ac120002"; + + private static final DockerImageName RELAY_PROXY_IMAGE = + DockerImageName.parse("gofeatureflag/go-feature-flag:latest"); + private static final String FLAGS_PATH = "/openfeature/provider_tests/flags.yaml"; + + private RelayProxyTestHelper() {} + + /** A relay proxy serving the provider_tests fixture, with its evaluation context enrichment. */ + static GenericContainer relayProxy() { + return relayProxy(MountableFile.forClasspathResource("provider_tests/flags.yaml"), "goff-proxy.yaml", 1031); + } + + /** A relay proxy serving the provider_tests fixture only to callers sending the authorized_token API key. */ + static GenericContainer authenticatedRelayProxy() { + return relayProxy( + MountableFile.forClasspathResource("provider_tests/flags.yaml"), "goff-proxy-authenticated.yaml", 1032); + } + + /** A relay proxy serving only {@link #MUTABLE_FLAG} on variation A, for tests that change or stop it. */ + static GenericContainer mutableRelayProxy() { + return relayProxy(Transferable.of(mutableFlag("A")), "goff-proxy.yaml", 1031); + } + + /** Rewrites the flag file of a {@link #mutableRelayProxy()} so that it serves the given variation. */ + static void serveMutableFlagVariation(final GenericContainer relayProxy, final String variation) { + relayProxy.copyFileToContainer(Transferable.of(mutableFlag(variation)), FLAGS_PATH); + } + + static GoFeatureFlagProviderOptionsBuilder options( + final EvaluationType type, final GenericContainer relayProxy) { + return GoFeatureFlagProviderOptions.builder() + .endpoint(endpoint(relayProxy)) + .evaluationType(type); + } + + @SneakyThrows + static GoFeatureFlagProvider provider(final GoFeatureFlagProviderOptionsBuilder options) { + return new GoFeatureFlagProvider(options.build()); + } + + static String mutableFlagValue(final Client client) { + return client.getStringValue(MUTABLE_FLAG, "sdk-default", TestUtils.defaultEvaluationContext); + } + + /** Evaluates the flag with the canonical context, through the resolver matching the default value's type. */ + static FlagEvaluationDetails evaluate(final Client client, final String flagKey, final Object defaultValue) { + val ctx = TestUtils.defaultEvaluationContext; + if (defaultValue instanceof Boolean) { + return client.getBooleanDetails(flagKey, (Boolean) defaultValue, ctx); + } + if (defaultValue instanceof String) { + return client.getStringDetails(flagKey, (String) defaultValue, ctx); + } + if (defaultValue instanceof Integer) { + return client.getIntegerDetails(flagKey, (Integer) defaultValue, ctx); + } + if (defaultValue instanceof Double) { + return client.getDoubleDetails(flagKey, (Double) defaultValue, ctx); + } + return client.getObjectDetails(flagKey, (Value) defaultValue, ctx); + } + + private static GenericContainer relayProxy( + final Transferable flags, final String configuration, final int port) { + return new GenericContainer<>(RELAY_PROXY_IMAGE) + .withCopyToContainer(flags, FLAGS_PATH) + .withCopyFileToContainer( + MountableFile.forClasspathResource("provider_tests/" + configuration), "/" + configuration) + .withCommand("/go-feature-flag", "--config", "/" + configuration) + .withExposedPorts(port) + .waitingFor(Wait.forHttp("/health").forStatusCode(200)); + } + + private static String mutableFlag(final String variation) { + return MUTABLE_FLAG + ":\n" + + " variations:\n" + + " A: A\n" + + " B: B\n" + + " defaultRule:\n" + + " variation: " + variation + "\n"; + } + + private static String endpoint(final GenericContainer relayProxy) { + val port = relayProxy.getExposedPorts().get(0); + return "http://" + relayProxy.getHost() + ":" + relayProxy.getMappedPort(port) + "/"; + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java new file mode 100644 index 0000000000..f28b87da26 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluatorTest.java @@ -0,0 +1,1064 @@ +package dev.openfeature.contrib.providers.gofeatureflag.evaluator; + +import static dev.openfeature.contrib.providers.gofeatureflag.evaluator.InProcessEvaluator.toProviderEvaluation; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; +import dev.openfeature.contrib.providers.gofeatureflag.TestUtils; +import dev.openfeature.contrib.providers.gofeatureflag.api.GoFeatureFlagApi; +import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; +import dev.openfeature.contrib.providers.gofeatureflag.exception.FlagConfigurationEndpointNotFound; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEvent; +import dev.openfeature.sdk.ProviderEventDetails; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.FlagNotFoundError; +import dev.openfeature.sdk.exceptions.GeneralError; +import dev.openfeature.sdk.exceptions.InvalidContextError; +import dev.openfeature.sdk.exceptions.OpenFeatureError; +import dev.openfeature.sdk.exceptions.ParseError; +import dev.openfeature.sdk.exceptions.ProviderNotReadyError; +import dev.openfeature.sdk.exceptions.TargetingKeyMissingError; +import dev.openfeature.sdk.exceptions.TypeMismatchError; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; +import java.util.stream.LongStream; +import java.util.stream.Stream; +import lombok.SneakyThrows; +import lombok.val; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.Property; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +class InProcessEvaluatorTest { + private static final long POLLING_INTERVAL_MS = 100L; + + private MockWebServer server; + private GoffApiMock goffApiMock; + + @BeforeEach + void beforeEach() throws IOException { + this.server = new MockWebServer(); + this.goffApiMock = new GoffApiMock(GoffApiMock.MockMode.DEFAULT); + this.server.setDispatcher(goffApiMock.dispatcher); + this.server.start(); + } + + @AfterEach + void afterEach() throws IOException { + this.server.close(); + this.server = null; + } + + @SneakyThrows + @DisplayName("a second init should not leave a second poller running") + @Test + void aSecondInitShouldNotLeaveASecondPollerRunning() { + val evaluator = evaluator(this.server); + evaluator.initialize(new ImmutableContext()); + evaluator.initialize(new ImmutableContext()); + + // let both a stranded poller and the live one have several chances to fire + Thread.sleep(POLLING_INTERVAL_MS * 5); + evaluator.shutdown(); + + // whatever was in flight when shutdown() was called may still land, so settle first + Thread.sleep(POLLING_INTERVAL_MS * 2); + val afterShutdown = goffApiMock.getConfigurationCallCount(); + assertTrue(afterShutdown > 0, "the evaluator should have polled at least once"); + + // a poller stranded by the second init would keep calling the API forever + Thread.sleep(POLLING_INTERVAL_MS * 5); + assertEquals(afterShutdown, goffApiMock.getConfigurationCallCount(), "polling continued after shutdown()"); + } + + @SneakyThrows + @DisplayName("polling should survive an error raised while applying a configuration") + @Test + void pollingShouldSurviveAnErrorRaisedWhileApplyingAConfiguration() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.CONFIG_CHANGES_EVERY_POLL).dispatcher); + val refreshes = new AtomicInteger(); + val evaluator = evaluator(s, (event, details) -> { + if (refreshes.incrementAndGet() == 1) { + throw new IllegalStateException("an event consumer that fails on the first change"); + } + }); + + evaluator.initialize(new ImmutableContext()); + Thread.sleep(POLLING_INTERVAL_MS * 8); + evaluator.shutdown(); + + assertTrue( + refreshes.get() > 1, + "polling stopped after the first failed refresh, it reached " + refreshes.get() + " refresh(es)"); + } + } + + @SneakyThrows + @DisplayName("polling should survive a configuration served without ETag and Last-Modified") + @Test + void pollingShouldSurviveAConfigurationServedWithoutEtagAndLastModified() { + try (val s = new MockWebServer()) { + s.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + // neither cache validator: nothing to compare this response against + return new MockResponse() + .setResponseCode(200) + .setBody("{\"flags\": {\"TEST\": {\"variations\": {\"on\": true}," + + " \"defaultRule\": {\"variation\": \"on\"}}}}"); + } + }); + val changeEvents = new AtomicInteger(); + val evaluator = evaluator(s, (event, details) -> changeEvents.incrementAndGet()); + + evaluator.initialize(new ImmutableContext()); + Thread.sleep(POLLING_INTERVAL_MS * 4); + val afterPolling = s.getRequestCount(); + Thread.sleep(POLLING_INTERVAL_MS * 4); + val later = s.getRequestCount(); + val evaluated = evaluator.getBooleanEvaluation("TEST", false, new ImmutableContext("user-key")); + evaluator.shutdown(); + + assertTrue(later > afterPolling, "polling stopped, it stayed at " + afterPolling + " request(s)"); + assertEquals(true, evaluated.getValue()); + // the configuration never changes, so no validator to compare must not mean "changed" + assertEquals(0, changeEvents.get(), "an unchanged configuration was announced as a change"); + } + } + + @DisplayName("each poll should be spread by up to ten percent either way") + @Test + void eachPollShouldBeSpreadByUpToTenPercentEitherWay() { + val delays = LongStream.range(0, 1000) + .map(i -> InProcessEvaluator.nextPollDelayMs(1000L)) + .boxed() + .collect(Collectors.toSet()); + + assertTrue(delays.stream().allMatch(d -> d >= 900L && d <= 1100L), "a poll strayed beyond the jitter"); + assertTrue( + delays.stream().anyMatch(d -> d < 1000L) && delays.stream().anyMatch(d -> d > 1000L), + "a fleet restarted together would still poll in lockstep"); + } + + @SneakyThrows + @DisplayName("a flag carrying unknown fields should still evaluate through the engine") + @Test + void aFlagCarryingUnknownFieldsShouldStillEvaluateThroughTheEngine() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.UNKNOWN_RESPONSE_FIELD).dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + + val got = evaluator.getBooleanEvaluation("TEST", false, new ImmutableContext("user-key")); + evaluator.shutdown(); + + // the engine receives the flag with the unrecognised fields still on it + assertEquals(true, got.getValue()); + assertNull(got.getErrorCode()); + } + } + + @SneakyThrows + @DisplayName("should go stale after three consecutive failed refreshes") + @Test + void shouldGoStaleAfterThreeConsecutiveFailedRefreshes() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.FAIL_REFRESH_AFTER_INIT).dispatcher); + val stale = new ArrayList(); + val evaluator = evaluator(s, (event, details) -> { + if (event == ProviderEvent.PROVIDER_STALE) { + stale.add(event); + } + }); + evaluator.initialize(new ImmutableContext()); + + // two failures are not enough, the third is + Thread.sleep(POLLING_INTERVAL_MS * 2 + POLLING_INTERVAL_MS / 2); + assertTrue(stale.isEmpty(), "went stale after fewer than three failures"); + + Thread.sleep(POLLING_INTERVAL_MS * 3); + val evaluated = evaluator.getBooleanEvaluation("bool_targeting_match", false, new ImmutableContext("d45")); + evaluator.shutdown(); + + assertEquals(1, stale.size(), "stale should be announced once, not on every later failure"); + // the configuration it could no longer refresh is still the one it serves: the flag + // resolves from it rather than reporting the provider as unready + assertNull(evaluated.getErrorCode()); + } + } + + @SneakyThrows + @DisplayName("a successful refresh should restart the failure run") + @Test + void aSuccessfulRefreshShouldRestartTheFailureRun() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.FAIL_TWICE_THEN_RECOVER).dispatcher); + val recorded = new RecordedEvents(); + val evaluator = evaluator(s, recorded); + evaluator.initialize(new ImmutableContext()); + + // two failures, then a success: the two must not carry over into the next run, and + // recovering from something never announced is not news + Thread.sleep(POLLING_INTERVAL_MS * 4 + POLLING_INTERVAL_MS / 2); + assertEquals(List.of(), recorded.events, "an event was announced before any staleness"); + + Thread.sleep(POLLING_INTERVAL_MS * 3); + evaluator.shutdown(); + assertEquals( + List.of(ProviderEvent.PROVIDER_STALE), + recorded.events, + "a fresh run of three failures should go stale, once"); + } + } + + @SneakyThrows + @DisplayName("a not modified response should count as a successful refresh") + @Test + void aNotModifiedResponseShouldCountAsASuccessfulRefresh() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.NOT_MODIFIED_DURING_FAILURES).dispatcher); + val stale = new ArrayList(); + val evaluator = evaluator(s, (event, details) -> { + if (event == ProviderEvent.PROVIDER_STALE) { + stale.add(event); + } + }); + evaluator.initialize(new ImmutableContext()); + + // a 304 says the configuration in hand is current, so it is a refresh that worked + Thread.sleep(POLLING_INTERVAL_MS * 8); + evaluator.shutdown(); + + assertTrue(stale.isEmpty(), "a 304 was counted as a failed refresh"); + } + } + + @SneakyThrows + @DisplayName("should go stale again after recovering from a first stale configuration") + @Test + void shouldGoStaleAgainAfterRecoveringFromAFirstStaleConfiguration() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.STALE_AFTER_A_RECOVERY).dispatcher); + val recorded = new RecordedEvents(); + val evaluator = evaluator(s, recorded); + evaluator.initialize(new ImmutableContext()); + + Thread.sleep(POLLING_INTERVAL_MS * 10); + evaluator.shutdown(); + + // announcing the recovery has to re-arm the run, not spend the provider's one chance + assertEquals( + List.of(ProviderEvent.PROVIDER_STALE, ProviderEvent.PROVIDER_READY, ProviderEvent.PROVIDER_STALE), + recorded.events, + "a second outage after a recovery was not announced"); + } + } + + @SneakyThrows + @DisplayName("should return to ready once refreshes recover after a stale configuration") + @Test + void shouldReturnToReadyOnceRefreshesRecoverAfterStale() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.FAIL_UNTIL_RECOVERY).dispatcher); + val recorded = new RecordedEvents(); + val evaluator = evaluator(s, recorded); + evaluator.initialize(new ImmutableContext()); + + // three failures announce the stale configuration, then every later poll succeeds + Thread.sleep(POLLING_INTERVAL_MS * 8); + val evaluated = evaluator.getBooleanEvaluation("bool_targeting_match", false, new ImmutableContext("d45")); + evaluator.shutdown(); + + // the recovery serves the configuration already held, so nothing else is announced: + // the return to ready happens once, not on every successful poll after it + assertEquals( + List.of(ProviderEvent.PROVIDER_STALE, ProviderEvent.PROVIDER_READY), + recorded.events, + "the provider did not come back from stale, or came back more than once"); + assertNull(evaluated.getErrorCode()); + } + } + + @SneakyThrows + @DisplayName("a not modified response should bring the provider back to ready") + @Test + void aNotModifiedResponseShouldBringTheProviderBackToReady() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.NOT_MODIFIED_AFTER_STALE).dispatcher); + val recorded = new RecordedEvents(); + val evaluator = evaluator(s, recorded); + evaluator.initialize(new ImmutableContext()); + + // a 304 carries no configuration, so it never reaches the refresh consumer: the + // recovery has to be recognised where the call succeeds, not where a response is applied + Thread.sleep(POLLING_INTERVAL_MS * 8); + evaluator.shutdown(); + + assertEquals( + List.of(ProviderEvent.PROVIDER_STALE, ProviderEvent.PROVIDER_READY), + recorded.events, + "a 304 did not end the stale condition"); + } + } + + @SneakyThrows + @DisplayName("polling should survive a failure raised while announcing the return to ready") + @Test + void pollingShouldSurviveAFailureRaisedWhileAnnouncingTheReturnToReady() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.FAIL_UNTIL_RECOVERY); + s.setDispatcher(mock.dispatcher); + val evaluator = evaluator(s, (event, details) -> { + throw new IllegalStateException("an emitter that fails, as it does after a shutdown"); + }); + evaluator.initialize(new ImmutableContext()); + + // the return to ready is announced from the refresh call itself, where a throw would + // otherwise be read as one more failed refresh + Thread.sleep(POLLING_INTERVAL_MS * 6); + val afterRecovery = mock.getConfigurationCallCount(); + Thread.sleep(POLLING_INTERVAL_MS * 4); + val later = mock.getConfigurationCallCount(); + val evaluated = evaluator.getBooleanEvaluation("bool_targeting_match", false, new ImmutableContext("d45")); + evaluator.shutdown(); + + assertTrue(later > afterRecovery, "polling stopped, it stayed at " + afterRecovery + " call(s)"); + assertNull(evaluated.getErrorCode()); + } + } + + @SneakyThrows + @DisplayName("a failed local evaluation should be answered by the relay proxy") + @Test + void aFailedLocalEvaluationShouldBeAnsweredByTheRelayProxy() { + val evaluator = evaluator(this.server); + evaluator.initialize(new ImmutableContext()); + + val evaluated = evaluator.getStringEvaluation("string_key", "caller-default", contextTooDeepForTheEngine()); + evaluator.shutdown(); + + // the value, variant and reason are the relay proxy's, not the ones the local engine failed to + // produce and not the caller's default + assertEquals("answered by the relay proxy", evaluated.getValue()); + assertEquals("remoteVariant", evaluated.getVariant()); + assertEquals(Reason.TARGETING_MATCH.name(), evaluated.getReason()); + assertNull(evaluated.getErrorCode()); + } + + @SneakyThrows + @DisplayName("an unknown flag should not be sent to the relay proxy") + @Test + void anUnknownFlagShouldNotBeSentToTheRelayProxy() { + val evaluator = evaluator(this.server); + evaluator.initialize(new ImmutableContext()); + + // FLAG_NOT_FOUND is this provider's own answer about the caller's key, not an engine failure: + // the relay proxy holds the same configuration and would answer the same + assertThrows( + FlagNotFoundError.class, + () -> evaluator.getBooleanEvaluation("DOES_NOT_EXIST", false, new ImmutableContext("user-key"))); + evaluator.shutdown(); + + assertEquals( + List.of(), + goffApiMock.getEvaluatedFlagKeys(), + "the relay proxy was asked about a flag it does not have either"); + } + + @SneakyThrows + @DisplayName("a misconfigured flag should not be sent to the relay proxy") + @Test + void aMisconfiguredFlagShouldNotBeSentToTheRelayProxy() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + + // the engine answers the raw code FLAG_CONFIG, which the SDK enumeration has no member + // for and which mapping folds into GENERAL. Read after mapping, the trigger would send + // this to the relay proxy, which holds the same configuration and would refuse it too. + val error = assertThrows( + GeneralError.class, + () -> evaluator.getBooleanEvaluation( + "flag-without-default-rule", false, new ImmutableContext("user-key"))); + evaluator.shutdown(); + + assertEquals(ErrorCode.GENERAL, error.getErrorCode()); + assertEquals(List.of(), mock.getEvaluatedFlagKeys(), "a misconfiguration was sent to the relay proxy"); + } + } + + @SneakyThrows + @DisplayName("every failed evaluation should be sent to the relay proxy, not only the first") + @Test + void everyFailedEvaluationShouldBeSentToTheRelayProxy() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + + // a targeting query the engine cannot parse makes it trap, which answers the raw code + // GENERAL, so the pair with the misconfigured flag can only pass if the trigger reads it raw + val evaluations = new ArrayList(); + for (int i = 0; i < 5; i++) { + evaluations.add(evaluator + .getBooleanEvaluation("flag-with-a-broken-query", false, new ImmutableContext("user-" + i)) + .getValue()); + } + evaluator.shutdown(); + assertEquals( + List.of(true, true, true, true, true), evaluations, "a later caller got a worse answer than one"); + assertEquals( + Collections.nCopies(5, "flag-with-a-broken-query"), + mock.getEvaluatedFlagKeys(), + "the relay proxy stopped being asked"); + } + } + + @SneakyThrows + @DisplayName("a fallback request should carry the configured api key") + @Test + void aFallbackRequestShouldCarryTheConfiguredApiKey() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + val evaluator = evaluator(s, "my-api-key", 10000); + evaluator.initialize(new ImmutableContext()); + + evaluator.getBooleanEvaluation("flag-with-a-broken-query", false, new ImmutableContext("user-key")); + evaluator.shutdown(); + + assertEquals(1, mock.getEvaluateRequestsHistory().size()); + assertEquals( + "my-api-key", + mock.getEvaluateRequestsHistory().get(0).getHeader(Const.HTTP_HEADER_API_KEY), + "the fallback was sent unauthenticated"); + } + } + + @SneakyThrows + @DisplayName("a fallback request should carry the custom headers") + @Test + void aFallbackRequestShouldCarryTheCustomHeaders() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .flagChangePollingIntervalMs(POLLING_INTERVAL_MS) + .customHeaders(Map.of("X-Gateway-Token", "gateway-token")) + .build(); + val evaluator = new InProcessEvaluator( + GoFeatureFlagApi.builder().options(options).build(), options, (event, details) -> {}); + evaluator.initialize(new ImmutableContext()); + + evaluator.getBooleanEvaluation("flag-with-a-broken-query", false, new ImmutableContext("user-key")); + evaluator.shutdown(); + + assertEquals(1, mock.getEvaluateRequestsHistory().size()); + assertEquals( + "gateway-token", + mock.getEvaluateRequestsHistory().get(0).getHeader("X-Gateway-Token"), + "the fallback left the gateway's headers behind"); + } + } + + @SneakyThrows + @DisplayName("a fallback request should honour the configured timeout") + @Test + void aFallbackRequestShouldHonourTheConfiguredTimeout() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + val impatient = evaluator(s, "my-api-key", 200); + impatient.initialize(new ImmutableContext()); + + // the relay proxy takes 5s to answer, so a 200ms timeout leaves the engine's error standing + val error = assertThrows( + GeneralError.class, + () -> impatient.getBooleanEvaluation( + "flag-the-proxy-answers-slowly", false, new ImmutableContext("user-key"))); + impatient.shutdown(); + assertEquals("Trapped on unreachable instruction", error.getMessage()); + + val patient = evaluator(s, "my-api-key", 10000); + patient.initialize(new ImmutableContext()); + val evaluated = + patient.getBooleanEvaluation("flag-the-proxy-answers-slowly", false, new ImmutableContext("u")); + patient.shutdown(); + + // the same 5s delay against a timeout that tolerates it: the failure above was the timeout + assertEquals(true, evaluated.getValue()); + } + } + + @SneakyThrows + @DisplayName("every fallback should be logged at warning level") + @Test + void everyFallbackShouldBeLoggedAtWarningLevel() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + + val warnings = warningsWhile(() -> { + evaluator.getBooleanEvaluation("flag-with-a-broken-query", false, new ImmutableContext("user-1")); + evaluator.getBooleanEvaluation("flag-with-a-broken-query", false, new ImmutableContext("user-2")); + }); + evaluator.shutdown(); + + assertEquals(2, warnings.size(), "a fallback went unlogged: " + warnings); + assertTrue( + warnings.get(0).contains("flag-with-a-broken-query"), + "the warning does not name the flag: " + warnings.get(0)); + } + } + + @SneakyThrows + @DisplayName("a fallback result should say it was evaluated remotely") + @Test + void aFallbackResultShouldSayItWasEvaluatedRemotely() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + + // the engine trips on the flag's broken query; the relay proxy answers it successfully + val evaluated = + evaluator.getBooleanEvaluation("flag-with-a-broken-query", false, new ImmutableContext("user-key")); + evaluator.shutdown(); + + assertEquals(true, evaluated.getFlagMetadata().getBoolean(Const.METADATA_EVALUATED_REMOTELY)); + // the marker is added to the relay proxy's metadata, not put in place of it + assertEquals(true, evaluated.getFlagMetadata().getBoolean("gofeatureflag_cacheable")); + assertEquals( + "a flag only the relay proxy can evaluate", + evaluated.getFlagMetadata().getString("description")); + } + } + + @SneakyThrows + @DisplayName("an evaluation the engine answers should not go through the fallback") + @Test + void anEvaluationTheEngineAnswersShouldNotGoThroughTheFallback() { + val evaluator = evaluator(this.server); + evaluator.initialize(new ImmutableContext()); + + val evaluated = new ArrayList>(); + val warnings = warningsWhile(() -> evaluated.add( + evaluator.getBooleanEvaluation("bool_targeting_match", false, new ImmutableContext("d45")))); + evaluator.shutdown(); + + assertNull(evaluated.get(0).getErrorCode()); + assertEquals(List.of(), goffApiMock.getEvaluatedFlagKeys()); + assertNull(evaluated.get(0).getFlagMetadata().getBoolean(Const.METADATA_EVALUATED_REMOTELY)); + assertEquals(List.of(), warnings); + } + + @SneakyThrows + @DisplayName("a relay proxy that fails too should leave the engine's error standing") + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"flag-the-proxy-does-not-have", "flag-the-proxy-answers-badly"}) + void aRelayProxyThatFailsTooShouldLeaveTheEnginesErrorStanding(String flagKey) { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + + val error = assertThrows( + GeneralError.class, + () -> evaluator.getBooleanEvaluation(flagKey, false, new ImmutableContext("user-key"))); + evaluator.shutdown(); + + assertEquals("Trapped on unreachable instruction", error.getMessage()); + assertEquals(List.of(flagKey), mock.getEvaluatedFlagKeys()); + } + } + + @SneakyThrows + @DisplayName("a boolean from the relay proxy should report TYPE_MISMATCH on a numeric resolver") + @Test + void aBooleanFromTheRelayProxyShouldReportTypeMismatchOnANumericResolver() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS).dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + + val asInteger = + evaluator.getIntegerEvaluation("flag-with-a-broken-query", 7, new ImmutableContext("user-key")); + val asDouble = + evaluator.getDoubleEvaluation("flag-with-a-broken-query", 7.5, new ImmutableContext("user-key")); + evaluator.shutdown(); + + assertEquals(ErrorCode.TYPE_MISMATCH, asInteger.getErrorCode(), "the relay proxy served a boolean"); + assertEquals(7, asInteger.getValue()); + assertEquals(ErrorCode.TYPE_MISMATCH, asDouble.getErrorCode(), "the relay proxy served a boolean"); + assertEquals(7.5, asDouble.getValue()); + } + } + + @SneakyThrows + @DisplayName("a decimal from the relay proxy should report TYPE_MISMATCH on the integer resolver") + @Test + void aDecimalFromTheRelayProxyShouldReportTypeMismatchOnTheIntegerResolver() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS).dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + + val evaluated = evaluator.getIntegerEvaluation( + "flag-the-proxy-serves-as-a-decimal", 7, new ImmutableContext("user-key")); + evaluator.shutdown(); + + assertEquals(ErrorCode.TYPE_MISMATCH, evaluated.getErrorCode(), "the relay proxy served 101.25"); + assertEquals(7, evaluated.getValue()); + } + } + + @SneakyThrows + @DisplayName("an enrichment-only change should be announced as a configuration change") + @Test + void anEnrichmentOnlyChangeShouldBeAnnouncedAsAConfigurationChange() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.ENRICHMENT_CHANGES_AFTER_INIT).dispatcher); + val changes = new ArrayList(); + val evaluator = evaluator(s, (event, details) -> { + if (event == ProviderEvent.PROVIDER_CONFIGURATION_CHANGED) { + changes.add(details); + } + }); + + evaluator.initialize(new ImmutableContext()); + val before = evaluator.getBooleanEvaluation("TEST", false, new ImmutableContext("user-key")); + Thread.sleep(POLLING_INTERVAL_MS * 4); + val after = evaluator.getBooleanEvaluation("TEST", false, new ImmutableContext("user-key")); + evaluator.shutdown(); + + assertEquals(false, before.getValue()); + assertEquals(true, after.getValue(), "the new enrichment should reach the engine"); + assertEquals(1, changes.size(), "the evaluations changed, but no configuration change was announced"); + assertEquals(List.of("TEST"), changes.get(0).getFlagsChanged()); + } + } + + @SneakyThrows + @DisplayName("should report PROVIDER_NOT_READY before any configuration is loaded") + @Test + void shouldReportProviderNotReadyBeforeAnyConfigurationIsLoaded() { + val evaluator = evaluator(this.server); + + val error = assertThrows( + ProviderNotReadyError.class, + () -> evaluator.getBooleanEvaluation("bool_targeting_match", false, new ImmutableContext("user-key"))); + + assertEquals(ErrorCode.PROVIDER_NOT_READY, error.getErrorCode()); + assertEquals( + "impossible to evaluate flag bool_targeting_match: no flag configuration has been loaded yet", + error.getMessage()); + } + + @SneakyThrows + @DisplayName("should report PROVIDER_NOT_READY, not FLAG_NOT_FOUND, after a failed init") + @Test + void shouldReportProviderNotReadyAfterAFailedInit() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR_404).dispatcher); + val evaluator = evaluator(s); + assertThrows(FlagConfigurationEndpointNotFound.class, () -> evaluator.initialize(new ImmutableContext())); + + val error = assertThrows( + ProviderNotReadyError.class, + () -> evaluator.getBooleanEvaluation( + "bool_targeting_match", false, new ImmutableContext("user-key"))); + + assertEquals(ErrorCode.PROVIDER_NOT_READY, error.getErrorCode()); + } + } + + @SneakyThrows + @DisplayName("an empty but valid configuration counts as loaded") + @Test + void anEmptyButValidConfigurationCountsAsLoaded() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.EMPTY_FLAG_CONFIG).dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + + // a relay proxy legitimately serving zero flags is loaded: the key really is unknown + assertThrows( + FlagNotFoundError.class, + () -> evaluator.getBooleanEvaluation( + "bool_targeting_match", false, new ImmutableContext("user-key"))); + evaluator.shutdown(); + } + } + + @SneakyThrows + @DisplayName("should stay loaded across shutdown and re-init") + @Test + void shouldStayLoadedAcrossShutdownAndReInit() { + val evaluator = evaluator(this.server); + evaluator.initialize(new ImmutableContext()); + evaluator.shutdown(); + evaluator.initialize(new ImmutableContext()); + + // the stored ETag survives shutdown(), so a re-init answered 304 must keep serving what it holds + assertThrows( + FlagNotFoundError.class, + () -> evaluator.getBooleanEvaluation("DOES_NOT_EXIST", false, new ImmutableContext("user-key"))); + evaluator.shutdown(); + } + + @SneakyThrows + @DisplayName("should evaluate a configured flag after shutdown and re-init") + @Test + void shouldEvaluateAConfiguredFlagAfterShutdownAndReInit() { + val evaluator = evaluator(this.server); + evaluator.initialize(new ImmutableContext()); + evaluator.shutdown(); + evaluator.initialize(new ImmutableContext()); + + val evaluated = + evaluator.getBooleanEvaluation("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + evaluator.shutdown(); + + assertNull(evaluated.getErrorCode()); + assertEquals(true, evaluated.getValue()); + } + + @SneakyThrows + @DisplayName("should fall back to the relay proxy after shutdown and re-init") + @Test + void shouldFallBackToTheRelayProxyAfterShutdownAndReInit() { + try (val s = new MockWebServer()) { + val mock = new GoffApiMock(GoffApiMock.MockMode.MISCONFIGURED_FLAGS); + s.setDispatcher(mock.dispatcher); + val evaluator = evaluator(s); + evaluator.initialize(new ImmutableContext()); + evaluator.shutdown(); + evaluator.initialize(new ImmutableContext()); + + val evaluated = + evaluator.getBooleanEvaluation("flag-with-a-broken-query", false, new ImmutableContext("user-key")); + evaluator.shutdown(); + + assertEquals(true, evaluated.getValue()); + assertEquals(List.of("flag-with-a-broken-query"), mock.getEvaluatedFlagKeys()); + } + } + + @SneakyThrows + @DisplayName("a missing targeting key should be passed through to the engine") + @Test + void aMissingTargetingKeyShouldBePassedThroughToTheEngine() { + val evaluator = evaluator(this.server); + evaluator.initialize(new ImmutableContext()); + + // object_key resolves through a default rule that does not bucket, so it needs no targeting key + val got = evaluator.getObjectEvaluation("object_key", null, new ImmutableContext()); + + assertNull(got.getErrorCode()); + assertEquals("varA", got.getVariant()); + evaluator.shutdown(); + } + + @SneakyThrows + @DisplayName("the engine should report TARGETING_KEY_MISSING only for a flag that buckets") + @Test + void theEngineShouldReportTargetingKeyMissingOnlyForAFlagThatBuckets() { + val evaluator = evaluator(this.server); + evaluator.initialize(new ImmutableContext()); + + val error = assertThrows( + TargetingKeyMissingError.class, + () -> evaluator.getStringEvaluation("string_key", "default", new ImmutableContext())); + + assertEquals(ErrorCode.TARGETING_KEY_MISSING, error.getErrorCode()); + evaluator.shutdown(); + } + + @Nested + @DisplayName("conversion of an engine response into a resolution") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class Conversion { + @DisplayName("Should raise the SDK error matching the engine's error code") + @ParameterizedTest(name = "{0}") + @MethodSource("engineErrorCodes") + void shouldRaiseTheSdkErrorMatchingTheEnginesErrorCode( + String engineCode, Class expectedError, ErrorCode expectedCode) { + val response = new GoFeatureFlagResponse(); + response.setErrorCode(engineCode); + response.setErrorDetails("details for " + engineCode); + + val error = assertThrows( + expectedError, () -> toProviderEvaluation("test-flag", false, response, Boolean.class)); + + assertEquals(expectedCode, error.getErrorCode()); + assertEquals("details for " + engineCode, error.getMessage()); + } + + @DisplayName("Should handle successful evaluation") + @Test + void shouldHandleSuccessfulEvaluation() { + ProviderEvaluation result = + toProviderEvaluation("test-flag", false, responseWithValue(true), Boolean.class); + + assertEquals(true, result.getValue()); + assertEquals(Reason.TARGETING_MATCH.name(), result.getReason()); + assertEquals("enabled", result.getVariant()); + assertNull(result.getErrorCode()); + } + + @DisplayName("Should accept an int number for the double resolver") + @Test + void shouldAcceptAnIntegralNumberForTheDoubleResolver() { + assertEquals(100.0, doubleEvaluationOf(100)); + } + + @DisplayName("Should accept an int number larger than an int for the double resolver") + @Test + void shouldAcceptAnIntegralNumberLargerThanAnIntForTheDoubleResolver() { + // above Integer.MAX_VALUE Jackson decodes a JSON integer to Long + assertEquals(3000000000.0, doubleEvaluationOf(3000000000L)); + } + + @DisplayName("Should accept a decimal number for the double resolver") + @Test + void shouldAcceptADecimalNumberForTheDoubleResolver() { + assertEquals(101.25, doubleEvaluationOf(101.25)); + } + + @DisplayName("Should not let a boolean satisfy the double resolver") + @Test + void shouldNotLetABooleanSatisfyTheDoubleResolver() { + assertThrows( + TypeMismatchError.class, + () -> toProviderEvaluation("test-flag", 0.0, responseWithValue(true), Double.class)); + } + + @DisplayName("Should not let a decimal number satisfy the integer resolver") + @Test + void shouldNotLetADecimalNumberSatisfyTheIntegerResolver() { + assertThrows( + TypeMismatchError.class, + () -> toProviderEvaluation("test-flag", 0, responseWithValue(101.25), Integer.class)); + } + + @DisplayName("Should return the caller default and keep the engine details when the value is null") + @Test + void shouldReturnTheCallerDefaultAndKeepTheEngineDetailsWhenTheValueIsNull() { + val response = responseWithValue(null); + response.setReason("TARGETING_MATCH_SPLIT"); + response.setVariationType("nullVariation"); + response.setMetadata(Map.of("description", "a flag with no value")); + + ProviderEvaluation result = toProviderEvaluation("test-flag", true, response, Boolean.class); + + assertEquals(true, result.getValue()); + assertEquals("TARGETING_MATCH_SPLIT", result.getReason(), "the engine reason must be passed through"); + assertEquals("nullVariation", result.getVariant(), "the engine variant must be kept"); + assertEquals("a flag with no value", result.getFlagMetadata().getString("description")); + assertNull(result.getErrorCode()); + } + + @SneakyThrows + @DisplayName("Should accept an object value holding an integer beyond the long range") + @Test + void shouldAcceptAnObjectValueHoldingAnIntegerBeyondTheLongRange() { + val response = Const.DESERIALIZE_OBJECT_MAPPER.readValue( + "{\"value\":{\"limit\":10000000000000000000,\"tiers\":[{\"max\":18446744073709551616}]}," + + "\"reason\":\"STATIC\",\"variationType\":\"big\"}", + GoFeatureFlagResponse.class); + + ProviderEvaluation result = + toProviderEvaluation("test-flag", new Value("caller-default"), response, Value.class); + + Structure value = result.getValue().asStructure(); + assertEquals(1e19, value.getValue("limit").asDouble(), "a float64 the engine wrote in plain form"); + assertEquals( + 18446744073709551616.0, + value.getValue("tiers") + .asList() + .get(0) + .asStructure() + .getValue("max") + .asDouble(), + "nested values beyond the long range must be kept too"); + assertEquals("big", result.getVariant()); + } + + @DisplayName("Should keep the flag metadata when the flag is disabled") + @Test + void shouldKeepTheFlagMetadataWhenTheFlagIsDisabled() { + val response = responseWithValue(true); + response.setReason(Reason.DISABLED.name()); + response.setVariationType("SdkDefault"); + response.setMetadata(Map.of("description", "a disabled flag", "gofeatureflag_cacheable", true)); + + ProviderEvaluation result = toProviderEvaluation("test-flag", false, response, Boolean.class); + + assertEquals(false, result.getValue()); + assertEquals(Reason.DISABLED.name(), result.getReason()); + assertEquals("a disabled flag", result.getFlagMetadata().getString("description")); + assertEquals(true, result.getFlagMetadata().getBoolean("gofeatureflag_cacheable")); + } + + @DisplayName("Should not return a zero value when the value is null") + @Test + void shouldNotReturnAZeroValueWhenTheValueIsNull() { + assertEquals( + 42, + toProviderEvaluation("test-flag", 42, responseWithValue(null), Integer.class) + .getValue()); + assertEquals( + "caller-default", + toProviderEvaluation("test-flag", "caller-default", responseWithValue(null), String.class) + .getValue()); + assertEquals( + new Value("caller-default"), + toProviderEvaluation("test-flag", new Value("caller-default"), responseWithValue(null), Value.class) + .getValue()); + } + + private GoFeatureFlagResponse responseWithValue(Object value) { + val response = new GoFeatureFlagResponse(); + response.setValue(value); + response.setReason(Reason.TARGETING_MATCH.name()); + response.setVariationType("enabled"); + return response; + } + + private Double doubleEvaluationOf(Object engineValue) { + return toProviderEvaluation("test-flag", 0.0, responseWithValue(engineValue), Double.class) + .getValue(); + } + + private Stream engineErrorCodes() { + return Stream.of( + Arguments.of("FLAG_NOT_FOUND", FlagNotFoundError.class, ErrorCode.FLAG_NOT_FOUND), + Arguments.of("GENERAL", GeneralError.class, ErrorCode.GENERAL), + Arguments.of( + "TARGETING_KEY_MISSING", TargetingKeyMissingError.class, ErrorCode.TARGETING_KEY_MISSING), + Arguments.of("INVALID_CONTEXT", InvalidContextError.class, ErrorCode.INVALID_CONTEXT), + Arguments.of("PARSE_ERROR", ParseError.class, ErrorCode.PARSE_ERROR), + // specific to GO Feature Flag, no SDK equivalent + Arguments.of("FLAG_CONFIG", GeneralError.class, ErrorCode.GENERAL)); + } + } + + @SneakyThrows + private InProcessEvaluator evaluator(MockWebServer srv, BiConsumer emitter) { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(srv.url("").toString()) + .flagChangePollingIntervalMs(POLLING_INTERVAL_MS) + .build(); + return new InProcessEvaluator( + GoFeatureFlagApi.builder().options(options).build(), options, emitter); + } + + private InProcessEvaluator evaluator(MockWebServer srv) { + return evaluator(srv, (event, details) -> {}); + } + + @SneakyThrows + private InProcessEvaluator evaluator(MockWebServer srv, String apiKey, long timeoutMs) { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(srv.url("").toString()) + .flagChangePollingIntervalMs(POLLING_INTERVAL_MS) + .apiKey(apiKey) + .timeout((int) timeoutMs) + .build(); + return new InProcessEvaluator( + GoFeatureFlagApi.builder().options(options).build(), options, (event, details) -> {}); + } + + /** + * A context the evaluation engine's own guards refuse to read, so that the engine answers + * PARSE_ERROR rather than a value. It is a real engine failure, not a simulated one. + */ + @SneakyThrows + private static ImmutableContext contextTooDeepForTheEngine() { + val deep = new StringBuilder(); + for (int i = 0; i < 400; i++) { + deep.append("{\"a\":"); + } + deep.append("1"); + for (int i = 0; i < 400; i++) { + deep.append("}"); + } + val asMap = Const.DESERIALIZE_OBJECT_MAPPER.readValue( + "{\"targetingKey\":\"user-key\",\"deep\":" + deep + "}", Map.class); + return new ImmutableContext(Structure.mapToStructure(asMap).asMap()); + } + + /** Runs an evaluation and returns the warnings InProcessEvaluator logged while it ran. */ + private static List warningsWhile(final Runnable evaluation) { + val logger = (org.apache.logging.log4j.core.Logger) LogManager.getLogger(InProcessEvaluator.class); + val captured = new ArrayList(); + val appender = new AbstractAppender("fallback-capture", null, null, true, Property.EMPTY_ARRAY) { + @Override + public void append(LogEvent event) { + if (Level.WARN.equals(event.getLevel())) { + captured.add(event.getMessage().getFormattedMessage()); + } + } + }; + appender.start(); + logger.addAppender(appender); + try { + evaluation.run(); + } finally { + logger.removeAppender(appender); + appender.stop(); + } + return captured; + } + + /** Records the events the evaluator emits, in the order it emits them. */ + private static final class RecordedEvents implements BiConsumer { + final List events = new ArrayList<>(); + + @Override + public void accept(ProviderEvent event, ProviderEventDetails eventDetails) { + this.events.add(event); + } + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java new file mode 100644 index 0000000000..26b0af21b9 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluatorTest.java @@ -0,0 +1,324 @@ +package dev.openfeature.contrib.providers.gofeatureflag.evaluator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import dev.openfeature.contrib.providers.gofeatureflag.util.GoffApiMock; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ProviderEvent; +import dev.openfeature.sdk.ProviderEventDetails; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import lombok.SneakyThrows; +import lombok.val; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class RemoteEvaluatorTest { + private MockWebServer server; + + @BeforeEach + void beforeEach() throws IOException { + this.server = new MockWebServer(); + this.server.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.DEFAULT).dispatcher); + this.server.start(); + } + + @AfterEach + void afterEach() throws IOException { + this.server.close(); + this.server = null; + } + + private RemoteEvaluator evaluator( + String endpoint, String apiKey, BiConsumer emitter) { + return new RemoteEvaluator( + GoFeatureFlagProviderOptions.builder() + .endpoint(endpoint) + .apiKey(apiKey) + .timeout(1000) + .build(), + emitter); + } + + private RemoteEvaluator evaluator(GoFeatureFlagProviderOptions.GoFeatureFlagProviderOptionsBuilder options) { + return new RemoteEvaluator(options.timeout(1000).build(), (event, details) -> {}); + } + + private RemoteEvaluator evaluator() { + return evaluator(this.server.url("").toString(), null, (event, details) -> {}); + } + + @DisplayName("should resolve a boolean flag") + @Test + void shouldResolveABooleanFlag() { + val got = evaluator().getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + + assertEquals(true, got.getValue()); + assertEquals("enabled", got.getVariant()); + assertEquals(Reason.TARGETING_MATCH.name(), got.getReason()); + assertNull(got.getErrorCode()); + } + + @DisplayName("should resolve a string flag") + @Test + void shouldResolveAStringFlag() { + val got = evaluator().getStringEvaluation("string_flag", "default", new ImmutableContext("user-key")); + + assertEquals("string value", got.getValue()); + assertEquals("variantA", got.getVariant()); + assertNull(got.getErrorCode()); + } + + @DisplayName("should resolve an integer flag") + @Test + void shouldResolveAnIntegerFlag() { + val got = evaluator().getIntegerEvaluation("int_flag", 0, new ImmutableContext("user-key")); + + assertEquals(100, got.getValue()); + assertEquals("variantA", got.getVariant()); + assertNull(got.getErrorCode()); + } + + @DisplayName("should resolve a double flag") + @Test + void shouldResolveADoubleFlag() { + val got = evaluator().getDoubleEvaluation("double_flag", 0.0, new ImmutableContext("user-key")); + + assertEquals(100.11, got.getValue()); + assertEquals("variantA", got.getVariant()); + assertNull(got.getErrorCode()); + } + + @DisplayName("should resolve an object flag") + @Test + void shouldResolveAnObjectFlag() { + val got = evaluator().getObjectEvaluation("object_flag", new Value(), new ImmutableContext("user-key")); + + assertEquals("foo", got.getValue().asStructure().getValue("name").asString()); + assertEquals("variantA", got.getVariant()); + assertNull(got.getErrorCode()); + } + + @SneakyThrows + @DisplayName("should evaluate after shutdown and re-init") + @Test + void shouldEvaluateAfterShutdownAndReInit() { + val evaluator = evaluator(); + evaluator.initialize(new ImmutableContext()); + evaluator.shutdown(); + evaluator.initialize(new ImmutableContext()); + + val got = evaluator.getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + evaluator.shutdown(); + + assertEquals(true, got.getValue()); + assertNull(got.getErrorCode()); + } + + @DisplayName("should pass the relay proxy metadata through untouched") + @Test + void shouldPassTheRelayProxyMetadataThrough() { + val got = evaluator().getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + + // gofeatureflag_cacheable belongs to the relay proxy, the provider must not strip it + assertEquals(true, got.getFlagMetadata().getBoolean("gofeatureflag_cacheable")); + assertEquals("A flag that is always off", got.getFlagMetadata().getString("description")); + } + + @DisplayName("should pass gofeatureflag_version through untouched") + @Test + void shouldPassTheRelayProxyVersionThrough() { + val got = evaluator().getBooleanEvaluation("metadata_with_version", false, new ImmutableContext("user-key")); + + assertEquals(true, got.getFlagMetadata().getBoolean("gofeatureflag_cacheable")); + assertEquals("1.2.3", got.getFlagMetadata().getString("gofeatureflag_version")); + assertEquals( + "A flag carrying both relay proxy metadata keys", + got.getFlagMetadata().getString("description")); + } + + @DisplayName("should evaluate a flag whose metadata carries no relay proxy key") + @Test + void shouldEvaluateAFlagWithoutRelayProxyMetadataKeys() { + val got = + evaluator().getBooleanEvaluation("metadata_without_goff_keys", false, new ImmutableContext("user-key")); + + assertEquals(true, got.getValue()); + assertNull(got.getErrorCode()); + assertNull(got.getFlagMetadata().getBoolean("gofeatureflag_cacheable")); + assertEquals( + "A relay proxy that adds no gofeatureflag_ keys", + got.getFlagMetadata().getString("description")); + } + + @DisplayName("should evaluate a flag whose response carries no metadata at all") + @Test + void shouldEvaluateAFlagWithoutAnyMetadata() { + val got = evaluator().getBooleanEvaluation("metadata_absent", false, new ImmutableContext("user-key")); + + assertEquals(true, got.getValue()); + assertNull(got.getErrorCode()); + } + + @DisplayName("should not claim a remote evaluation in the flag metadata") + @Test + void shouldNotClaimARemoteEvaluationInTheFlagMetadata() { + val got = evaluator().getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + + // gofeatureflag_evaluated_remotely marks a result recovered by the remote fallback of the + // in-process evaluator, so a plain remote evaluation must not carry it + assertNull(got.getFlagMetadata().getBoolean("gofeatureflag_evaluated_remotely")); + } + + @DisplayName("should report a fatal provider error when the credentials are rejected") + @SneakyThrows + @Test + void shouldReportAFatalProviderErrorWhenTheCredentialsAreRejected() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.INVALID_API_KEY).dispatcher); + val reported = new RecordedEvents(); + val got = evaluator(s.url("").toString(), "a-rejected-key", reported) + .getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + + assertEquals(false, got.getValue()); + assertEquals(1, reported.events.size()); + assertEquals(ProviderEvent.PROVIDER_ERROR, reported.events.get(0)); + assertEquals(ErrorCode.PROVIDER_FATAL, reported.details.get(0).getErrorCode()); + } + } + + @DisplayName("should report the rejected credentials only once") + @SneakyThrows + @Test + void shouldReportTheRejectedCredentialsOnlyOnce() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.INVALID_API_KEY).dispatcher); + val reported = new RecordedEvents(); + val evaluator = evaluator(s.url("").toString(), "a-rejected-key", reported); + + evaluator.getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + evaluator.getStringEvaluation("string_flag", "d", new ImmutableContext("user-key")); + evaluator.getIntegerEvaluation("int_flag", 0, new ImmutableContext("user-key")); + + assertEquals(1, reported.events.size()); + } + } + + @DisplayName("should not report a fatal provider error for an ordinary failure") + @SneakyThrows + @Test + void shouldNotReportAFatalProviderErrorForAnOrdinaryFailure() { + try (val s = new MockWebServer()) { + s.setDispatcher(new GoffApiMock(GoffApiMock.MockMode.ENDPOINT_ERROR).dispatcher); + val reported = new RecordedEvents(); + val got = evaluator(s.url("").toString(), null, reported) + .getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + + // a 500 is repairable without touching the credentials, so it must stay a per-call error + assertEquals(ErrorCode.GENERAL, got.getErrorCode()); + assertTrue(reported.events.isEmpty()); + } + } + + @SneakyThrows + @DisplayName("should send the API key as an X-API-Key header") + @Test + void shouldSendTheApiKeyAsAnApiKeyHeader() { + evaluator(this.server.url("").toString(), "my-api-key", (event, details) -> {}) + .getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + + val request = this.server.takeRequest(); + assertEquals("my-api-key", request.getHeader(Const.HTTP_HEADER_API_KEY)); + } + + @SneakyThrows + @DisplayName("should send the custom headers") + @Test + void shouldSendTheCustomHeaders() { + evaluator(GoFeatureFlagProviderOptions.builder() + .endpoint(this.server.url("").toString()) + .customHeaders(Map.of("X-Gateway-Token", "gateway-token"))) + .getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + + assertEquals("gateway-token", this.server.takeRequest().getHeader("X-Gateway-Token")); + } + + @SneakyThrows + @DisplayName("a configured api key should win over a custom header of the same name") + @Test + void aConfiguredApiKeyShouldWinOverACustomHeaderOfTheSameName() { + evaluator(GoFeatureFlagProviderOptions.builder() + .endpoint(this.server.url("").toString()) + .apiKey("my-api-key") + .customHeaders(Map.of("x-api-key", "custom-key"))) + .getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + + val request = this.server.takeRequest(); + assertEquals(List.of("my-api-key"), request.getHeaders().values(Const.HTTP_HEADER_API_KEY)); + } + + @SneakyThrows + @DisplayName("should not send an API key header when none is configured") + @Test + void shouldNotSendAnApiKeyHeaderWhenNoneIsConfigured() { + evaluator().getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + + val request = this.server.takeRequest(); + assertNull(request.getHeader(Const.HTTP_HEADER_API_KEY)); + } + + @SneakyThrows + @DisplayName("should keep the path prefix of the endpoint") + @Test + void shouldKeepThePathPrefixOfTheEndpoint() { + evaluator(this.server.url("/gofeatureflagproxy/").toString(), null, (event, details) -> {}) + .getBooleanEvaluation("bool_flag", false, new ImmutableContext("user-key")); + + val request = this.server.takeRequest(); + assertEquals("/gofeatureflagproxy/ofrep/v1/evaluate/flags/bool_flag", request.getPath()); + } + + @DisplayName("should report FLAG_NOT_FOUND for an unknown flag") + @Test + void shouldReportFlagNotFoundForAnUnknownFlag() { + val got = evaluator().getBooleanEvaluation("DOES_NOT_EXIST", false, new ImmutableContext("user-key")); + + // the OFREP provider answers with an error code rather than raising, unlike the in-process one + assertEquals(ErrorCode.FLAG_NOT_FOUND, got.getErrorCode()); + assertEquals(false, got.getValue()); + } + + @DisplayName("should report every flag as trackable") + @Test + void shouldReportEveryFlagAsTrackable() { + // in remote mode the relay proxy collects the evaluations itself, so the provider cannot + // know what is tracked and must not filter anything out + assertTrue(evaluator().isFlagTrackable("bool_flag")); + assertTrue(evaluator().isFlagTrackable("DOES_NOT_EXIST")); + } + + /** Records what the evaluator emits, so a test can assert on the event kind and on its details. */ + private static final class RecordedEvents implements BiConsumer { + final List events = new ArrayList<>(); + final List details = new ArrayList<>(); + + @Override + public void accept(ProviderEvent event, ProviderEventDetails eventDetails) { + this.events.add(event); + this.details.add(eventDetails); + } + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookTest.java index b947ed5f8e..2c27d04cef 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookTest.java @@ -1,10 +1,27 @@ package dev.openfeature.contrib.providers.gofeatureflag.hook; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import dev.openfeature.contrib.providers.gofeatureflag.bean.FeatureEvent; +import dev.openfeature.contrib.providers.gofeatureflag.bean.IEvent; +import dev.openfeature.contrib.providers.gofeatureflag.evaluator.IEvaluator; import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; +import dev.openfeature.contrib.providers.gofeatureflag.service.EventsPublisher; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.FlagValueType; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.Reason; +import java.util.Map; import lombok.SneakyThrows; +import lombok.val; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; public class DataCollectorHookTest { @SneakyThrows @@ -20,4 +37,69 @@ void shouldErrorIfNoEventsPublisherProvided() { InvalidOptions.class, () -> new DataCollectorHook(DataCollectorHookOptions.builder().build())); } + + @SneakyThrows + @Test + void shouldErrorIfNoEvaluatorProvided() { + EventsPublisher eventsPublisher = mock(EventsPublisher.class); + assertThrows( + InvalidOptions.class, + () -> new DataCollectorHook(DataCollectorHookOptions.builder() + .eventsPublisher(eventsPublisher) + .build())); + } + + @SneakyThrows + @Test + void shouldRecordAnEvaluationThatWasNotServedFromACache() { + EventsPublisher eventsPublisher = mock(EventsPublisher.class); + val evaluator = mock(IEvaluator.class); + when(evaluator.isFlagTrackable("flag")).thenReturn(true); + val hook = new DataCollectorHook(DataCollectorHookOptions.builder() + .eventsPublisher(eventsPublisher) + .evaluator(evaluator) + .build()); + + hook.after( + HookContext.from( + "flag", FlagValueType.BOOLEAN, null, null, new ImmutableContext("key"), false), + FlagEvaluationDetails.builder() + .flagKey("flag") + .value(true) + .variant("enabled") + .reason(Reason.TARGETING_MATCH.name()) + .build(), + Map.of()); + + verify(eventsPublisher).add(any()); + } + + @SneakyThrows + @Test + void shouldRecordSdkDefaultAsVariationWhenTheEvaluationHasNoVariant() { + EventsPublisher eventsPublisher = mock(EventsPublisher.class); + val evaluator = mock(IEvaluator.class); + when(evaluator.isFlagTrackable("flag")).thenReturn(true); + val hook = new DataCollectorHook(DataCollectorHookOptions.builder() + .eventsPublisher(eventsPublisher) + .evaluator(evaluator) + .build()); + + hook.after( + HookContext.from( + "flag", FlagValueType.BOOLEAN, null, null, new ImmutableContext("key"), false), + FlagEvaluationDetails.builder() + .flagKey("flag") + .value(false) + .reason(Reason.DEFAULT.name()) + .build(), + Map.of()); + + ArgumentCaptor event = ArgumentCaptor.forClass(IEvent.class); + verify(eventsPublisher).add(event.capture()); + assertEquals( + "SdkDefault", + ((FeatureEvent) event.getValue()).getVariation(), + "variation must be the resolved variant, or SdkDefault when there is none"); + } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHookTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHookTest.java index b49d95f295..97372aca93 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHookTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHookTest.java @@ -5,7 +5,12 @@ import dev.openfeature.contrib.providers.gofeatureflag.TestUtils; import dev.openfeature.sdk.FlagValueType; import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.MutableStructure; +import dev.openfeature.sdk.Value; import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.Optional; import lombok.SneakyThrows; import lombok.val; @@ -43,4 +48,70 @@ void shouldReturnSameContextIfMetadataEmpty() { val res = hook.before(hookContext, null); assertEquals(Optional.of(hookContext.getCtx()), res); } + + @DisplayName("Should preserve the caller keys of the gofeatureflag namespace") + @SneakyThrows + @Test + void shouldPreserveTheCallerKeysOfTheGoFeatureFlagNamespace() { + EnrichEvaluationContextHook hook = new EnrichEvaluationContextHook(Map.of("appVersion", "1.2.3")); + val callerNamespace = new MutableStructure() + .add("flagList", List.of(new Value("flag-a"), new Value("flag-b"))) + .add("currentDateTime", "2026-09-15T00:00:00Z"); + val callerContext = new MutableContext("user-key").add("gofeatureflag", callerNamespace); + val hookContext = + HookContext.from("testFlagKey", FlagValueType.BOOLEAN, null, null, callerContext, "default"); + + val got = hook.before(hookContext, null).get().getValue("gofeatureflag").asStructure(); + + assertEquals( + List.of(new Value("flag-a"), new Value("flag-b")), + got.getValue("flagList").asList()); + assertEquals("2026-09-15T00:00:00Z", got.getValue("currentDateTime").asString()); + assertEquals( + "1.2.3", + got.getValue("exporterMetadata") + .asStructure() + .getValue("appVersion") + .asString()); + } + + @DisplayName("Should replace a caller supplied exporterMetadata") + @SneakyThrows + @Test + void shouldReplaceACallerSuppliedExporterMetadata() { + EnrichEvaluationContextHook hook = new EnrichEvaluationContextHook(Map.of("appVersion", "1.2.3")); + val callerNamespace = new MutableStructure() + .add("exporterMetadata", new MutableStructure().add("appVersion", "from-the-caller")); + val callerContext = new MutableContext("user-key").add("gofeatureflag", callerNamespace); + val hookContext = + HookContext.from("testFlagKey", FlagValueType.BOOLEAN, null, null, callerContext, "default"); + + val got = hook.before(hookContext, null).get().getValue("gofeatureflag").asStructure(); + + assertEquals( + "1.2.3", + got.getValue("exporterMetadata") + .asStructure() + .getValue("appVersion") + .asString()); + } + + @DisplayName("Should replace the gofeatureflag namespace if it is not a structure") + @SneakyThrows + @Test + void shouldReplaceTheGoFeatureFlagNamespaceIfItIsNotAStructure() { + EnrichEvaluationContextHook hook = new EnrichEvaluationContextHook(Map.of("appVersion", "1.2.3")); + val callerContext = new MutableContext("user-key").add("gofeatureflag", "not-a-structure"); + val hookContext = + HookContext.from("testFlagKey", FlagValueType.BOOLEAN, null, null, callerContext, "default"); + + val got = hook.before(hookContext, null).get().getValue("gofeatureflag").asStructure(); + + assertEquals( + "1.2.3", + got.getValue("exporterMetadata") + .asStructure() + .getValue("appVersion") + .asString()); + } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationServiceTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationServiceTest.java deleted file mode 100644 index fb6df43ea7..0000000000 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationServiceTest.java +++ /dev/null @@ -1,101 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; -import dev.openfeature.contrib.providers.gofeatureflag.evaluator.IEvaluator; -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.ImmutableContext; -import dev.openfeature.sdk.ProviderEvaluation; -import dev.openfeature.sdk.Reason; -import dev.openfeature.sdk.exceptions.FlagNotFoundError; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -@DisplayName("EvaluationService tests") -class EvaluationServiceTest { - private IEvaluator mockEvaluator; - private EvaluationService evaluationService; - private EvaluationContext evaluationContext; - - @BeforeEach - void setUp() { - mockEvaluator = mock(IEvaluator.class); - evaluationService = new EvaluationService(mockEvaluator); - evaluationContext = new ImmutableContext("test-targeting-key"); - } - - @DisplayName("Should throw FlagNotFoundError when flag is not found") - @Test - void shouldThrowFlagNotFoundErrorWhenFlagIsNotFound() { - // Given: evaluator returns a response with FLAG_NOT_FOUND error code - GoFeatureFlagResponse response = new GoFeatureFlagResponse(); - response.setErrorCode(ErrorCode.FLAG_NOT_FOUND.name()); - response.setErrorDetails("Flag test-flag was not found in your configuration"); - response.setValue(false); - - when(mockEvaluator.evaluate(anyString(), any(), any(EvaluationContext.class))) - .thenReturn(response); - - // When/Then: getEvaluation should throw FlagNotFoundError - FlagNotFoundError exception = assertThrows( - FlagNotFoundError.class, - () -> evaluationService.getEvaluation("test-flag", false, evaluationContext, Boolean.class)); - - assertEquals("Flag test-flag was not found in your configuration", exception.getMessage()); - } - - @DisplayName("Should return error response for other error codes") - @Test - void shouldReturnErrorResponseForOtherErrorCodes() { - // Given: evaluator returns a response with a different error code - GoFeatureFlagResponse response = new GoFeatureFlagResponse(); - response.setErrorCode(ErrorCode.GENERAL.name()); - response.setErrorDetails("Some other error occurred"); - response.setValue(false); - - when(mockEvaluator.evaluate(anyString(), any(), any(EvaluationContext.class))) - .thenReturn(response); - - // When: getEvaluation is called - ProviderEvaluation result = - evaluationService.getEvaluation("test-flag", false, evaluationContext, Boolean.class); - - // Then: should return error response, not throw exception - assertEquals(ErrorCode.GENERAL, result.getErrorCode()); - assertEquals("Some other error occurred", result.getErrorMessage()); - assertEquals(Reason.ERROR.name(), result.getReason()); - assertEquals(false, result.getValue()); - } - - @DisplayName("Should handle successful evaluation") - @Test - void shouldHandleSuccessfulEvaluation() { - // Given: evaluator returns a successful response - GoFeatureFlagResponse response = new GoFeatureFlagResponse(); - response.setValue(true); - response.setReason(Reason.TARGETING_MATCH.name()); - response.setVariationType("enabled"); - response.setErrorCode(null); - - when(mockEvaluator.evaluate(anyString(), any(), any(EvaluationContext.class))) - .thenReturn(response); - - // When: getEvaluation is called - ProviderEvaluation result = - evaluationService.getEvaluation("test-flag", false, evaluationContext, Boolean.class); - - // Then: should return successful evaluation - assertEquals(true, result.getValue()); - assertEquals(Reason.TARGETING_MATCH.name(), result.getReason()); - assertEquals("enabled", result.getVariant()); - assertEquals(null, result.getErrorCode()); - } -} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java new file mode 100644 index 0000000000..3d88be8c45 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisherTest.java @@ -0,0 +1,258 @@ +package dev.openfeature.contrib.providers.gofeatureflag.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.SneakyThrows; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class EventsPublisherTest { + private static final long FLUSH_INTERVAL_MS = 100L; + + @SneakyThrows + @DisplayName("a publisher restarted after a shutdown should collect and flush again") + @Test + void aPublisherRestartedAfterAShutdownShouldCollectAndFlushAgain() { + val published = new CopyOnWriteArrayList(); + val publisher = new EventsPublisher(published::addAll, FLUSH_INTERVAL_MS, 10000); + + publisher.add("before-shutdown"); + publisher.shutdown(); + assertEquals(List.of("before-shutdown"), published, "shutdown should drain what is buffered"); + + // the provider being initialized again must reset both one-shot pieces of state + publisher.start(); + publisher.add("after-restart"); + Thread.sleep(FLUSH_INTERVAL_MS * 4); + + assertEquals( + List.of("before-shutdown", "after-restart"), + published, + "a restarted publisher should accept and flush events again"); + assertFalse(publisher.isShutdown.get(), "start() should reset the shutdown flag"); + } + + @SneakyThrows + @DisplayName("a shut down publisher should drop events until it is restarted") + @Test + void aShutDownPublisherShouldDropEventsUntilItIsRestarted() { + val published = new CopyOnWriteArrayList(); + val publisher = new EventsPublisher(published::addAll, FLUSH_INTERVAL_MS, 10000); + + publisher.shutdown(); + publisher.add("dropped"); + assertTrue(publisher.isShutdown.get(), "the shutdown flag should be raised"); + + // restart, so a scheduler exists again: anything accepted while shut down would surface here + publisher.start(); + Thread.sleep(FLUSH_INTERVAL_MS * 4); + + assertEquals(List.of(), published, "a shut down publisher should not accept new events"); + } + + @SneakyThrows + @DisplayName("start should be a no-op on a running publisher") + @Test + void startShouldBeANoOpOnARunningPublisher() { + val published = new CopyOnWriteArrayList(); + val publisher = new EventsPublisher(published::addAll, FLUSH_INTERVAL_MS, 10000); + + publisher.start(); + publisher.add("once"); + Thread.sleep(FLUSH_INTERVAL_MS * 4); + publisher.shutdown(); + + assertEquals(List.of("once"), published, "a second scheduler would publish the event twice"); + } + + @SneakyThrows + @DisplayName("adding an event should not wait for an in-flight publish") + @Test + void addingAnEventShouldNotWaitForAnInFlightPublish() { + val posting = new CountDownLatch(1); + val releasePost = new CountDownLatch(1); + val publisher = new EventsPublisher( + batch -> { + posting.countDown(); + try { + releasePost.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }, + FLUSH_INTERVAL_MS, + 10000); + + publisher.add("first"); + val flush = new Thread(publisher::publish); + flush.start(); + assertTrue(posting.await(5, TimeUnit.SECONDS), "the publish should have reached the collector"); + + // the collector is still hanging: enqueuing must not block behind it + val added = new CountDownLatch(1); + new Thread(() -> { + publisher.add("while-posting"); + added.countDown(); + }) + .start(); + assertTrue(added.await(2, TimeUnit.SECONDS), "add() blocked behind the data collector"); + + releasePost.countDown(); + flush.join(5000); + publisher.shutdown(); + } + + @SneakyThrows + @DisplayName("a full buffer should be flushed off the thread adding the event") + @Test + void aFullBufferShouldBeFlushedOffTheThreadAddingTheEvent() { + val postingThreads = new CopyOnWriteArrayList(); + val posting = new CountDownLatch(1); + val releasePost = new CountDownLatch(1); + val publisher = new EventsPublisher( + batch -> { + postingThreads.add(Thread.currentThread()); + posting.countDown(); + try { + releasePost.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }, + 60_000L, + 1); + + val added = new CountDownLatch(1); + val evaluatingThread = new Thread(() -> { + publisher.add("first"); + publisher.add("fills-the-buffer"); + added.countDown(); + }); + evaluatingThread.start(); + + assertTrue(added.await(2, TimeUnit.SECONDS), "add() waited on the data collector"); + assertTrue(posting.await(2, TimeUnit.SECONDS), "a full buffer should still trigger a flush"); + assertFalse(postingThreads.contains(evaluatingThread), "the flush ran on the thread adding the event"); + + releasePost.countDown(); + publisher.shutdown(); + } + + @SneakyThrows + @DisplayName("publishing should be single flight") + @Test + void publishingShouldBeSingleFlight() { + val concurrentPosts = new AtomicInteger(); + val maxConcurrentPosts = new AtomicInteger(); + val posting = new CountDownLatch(1); + val releasePost = new CountDownLatch(1); + val publisher = new EventsPublisher( + batch -> { + maxConcurrentPosts.accumulateAndGet(concurrentPosts.incrementAndGet(), Math::max); + posting.countDown(); + try { + releasePost.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + concurrentPosts.decrementAndGet(); + }, + FLUSH_INTERVAL_MS, + 10000); + + publisher.add("first"); + val flush = new Thread(publisher::publish); + flush.start(); + assertTrue(posting.await(5, TimeUnit.SECONDS)); + + publisher.add("second"); + publisher.publish(); + + releasePost.countDown(); + flush.join(5000); + assertEquals(1, maxConcurrentPosts.get(), "two publishes overlapped"); + publisher.shutdown(); + } + + @SneakyThrows + @DisplayName("a failed batch should be re-queued in chronological order") + @Test + void aFailedBatchShouldBeRequeuedInChronologicalOrder() { + val attempts = new CopyOnWriteArrayList>(); + val publisher = new EventsPublisher( + batch -> { + attempts.add(List.copyOf(batch)); + if (attempts.size() == 1) { + throw new IllegalStateException("collector is down"); + } + }, + FLUSH_INTERVAL_MS, + 10000); + + publisher.add("first"); + publisher.add("second"); + publisher.publish(); + + publisher.add("third"); + publisher.publish(); + + assertEquals(List.of("first", "second"), attempts.get(0)); + assertEquals(List.of("first", "second", "third"), attempts.get(1), "order was not preserved"); + publisher.shutdown(); + } + + @SneakyThrows + @DisplayName("the buffer should be capped at twice maxPendingEvents, discarding the oldest") + @Test + void theBufferShouldBeCappedAtTwiceMaxPendingEventsDiscardingTheOldest() { + val attempts = new CopyOnWriteArrayList>(); + val collectorIsDown = new AtomicBoolean(true); + val maxPendingEvents = 4; + val publisher = new EventsPublisher( + batch -> { + attempts.add(List.copyOf(batch)); + if (collectorIsDown.get()) { + throw new IllegalStateException("collector is down"); + } + }, + FLUSH_INTERVAL_MS, + maxPendingEvents); + + // the collector refuses every batch, so nothing ever leaves the buffer + for (int i = 0; i < 40; i++) { + publisher.add("event-" + i); + } + // the flushes a full buffer triggers run on the scheduler: let them fail and re-queue first + Thread.sleep(FLUSH_INTERVAL_MS * 2); + + collectorIsDown.set(false); + publisher.shutdown(); + + val delivered = attempts.get(attempts.size() - 1); + assertEquals(2 * maxPendingEvents, delivered.size(), "the buffer grew past twice maxPendingEvents"); + assertEquals("event-39", delivered.get(delivered.size() - 1), "the newest event should be kept"); + assertEquals("event-32", delivered.get(0), "the oldest events should be the ones discarded"); + } + + @SneakyThrows + @DisplayName("the largest maxPendingEvents should still accept events") + @Test + void theLargestMaxPendingEventsShouldStillAcceptEvents() { + val published = new CopyOnWriteArrayList(); + val publisher = new EventsPublisher(published::addAll, FLUSH_INTERVAL_MS, Integer.MAX_VALUE); + + publisher.add("event"); + publisher.shutdown(); + + assertEquals(List.of("event"), published, "twice maxPendingEvents overflowed the buffer cap"); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtilTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtilTest.java index a19284ccc0..176d6f01bd 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtilTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtilTest.java @@ -3,8 +3,19 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import dev.openfeature.sdk.ImmutableContext; import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.MutableStructure; +import dev.openfeature.sdk.Value; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import lombok.val; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; class EvaluationContextUtilTest { @@ -32,4 +43,25 @@ void testIsAnonymousUser_WhenAnonymousFieldIsMissing_ShouldReturnFalse() { MutableContext ctx = new MutableContext(); assertFalse(EvaluationContextUtil.isAnonymousUser(ctx), "Expected false when anonymous field is missing"); } + + @DisplayName("any non-boolean value under the attribute should not make the user anonymous") + @ParameterizedTest(name = "anonymous = {0}") + @MethodSource("nonBooleanValues") + void nonBooleanValuesShouldNotMakeTheUserAnonymous(String label, Value value) { + val ctx = new ImmutableContext(Map.of("anonymous", value)); + assertFalse( + EvaluationContextUtil.isAnonymousUser(ctx), + "a " + label + " under the attribute was read as an assertion of anonymity"); + } + + private static Stream nonBooleanValues() { + return Stream.of( + Arguments.of("string \"true\"", new Value("true")), + Arguments.of("string", new Value("yes")), + Arguments.of("number", new Value(1)), + Arguments.of("zero", new Value(0)), + Arguments.of("list", new Value(List.of(new Value(true)))), + Arguments.of("structure", new Value(new MutableStructure().add("anonymous", true))), + Arguments.of("null value", new Value())); + } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java index 7cfef3e4ab..2c75b15c45 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; import lombok.Getter; import lombok.SneakyThrows; import lombok.val; @@ -14,6 +15,13 @@ public class GoffApiMock { private static final String ofrepResponseDir = "ofrep_evaluate_responses/"; + /** + * body the relay proxy returns alongside an OFREP error status. The status alone is not enough: + * an OFREP client reads the body of every response, so a bodiless error is not representative. + */ + private static final String ofrepErrorBody = + "{\"errorCode\":\"GENERAL\",\"errorDetails\":\"error\",\"metadata\":{}}"; + private final MockMode mode; @Getter @@ -22,7 +30,16 @@ public class GoffApiMock { @Getter private int collectorCallCount = 0; + @Getter private int configurationCallCount = 0; + + /** Keys of every flag the relay proxy has been asked to evaluate, in order. */ + @Getter + private final List evaluatedFlagKeys = new CopyOnWriteArrayList<>(); + + /** Every evaluation request the relay proxy received, in order. */ + @Getter + private final List evaluateRequestsHistory = new CopyOnWriteArrayList<>(); /** * lastRequestBody contains the body of the last request. */ @@ -35,23 +52,25 @@ public class GoffApiMock { public MockResponse dispatch(RecordedRequest request) { switch (mode) { case ENDPOINT_ERROR: - return new MockResponse().setResponseCode(500); + return new MockResponse().setResponseCode(500).setBody(ofrepErrorBody); case API_KEY_MISSING: - return new MockResponse().setResponseCode(401); + return new MockResponse().setResponseCode(401).setBody(ofrepErrorBody); case INVALID_API_KEY: - return new MockResponse().setResponseCode(403); + return new MockResponse().setResponseCode(403).setBody(ofrepErrorBody); } lastRequestBody = request.getBody().readUtf8(); assert request.getPath() != null; - if (request.getPath().startsWith("/ofrep/v1/evaluate/flags/")) { + // routes are matched with contains() so that an endpoint carrying a path prefix + // (https://host/gofeatureflagproxy/) still reaches the right handler + if (request.getPath().contains("/ofrep/v1/evaluate/flags/")) { return handleEvaluateFlags(request); } - if (request.getPath().startsWith("/v1/data/collector")) { + if (request.getPath().contains("/v1/data/collector")) { collectorCallCount++; return handleCollector(request); } - if (request.getPath().startsWith("/v1/flag/configuration")) { + if (request.getPath().contains("/v1/flag/configuration")) { configurationCallCount++; return handleFlagConfiguration(request); } @@ -67,8 +86,17 @@ public GoffApiMock(final MockMode mode) { @SneakyThrows public MockResponse handleEvaluateFlags(RecordedRequest request) { assert request.getPath() != null; - String flagName = request.getPath().replace("/ofrep/v1/evaluate/flags/", ""); + String flagName = request.getPath() + .substring( + request.getPath().indexOf("/ofrep/v1/evaluate/flags/") + "/ofrep/v1/evaluate/flags/".length()); + evaluatedFlagKeys.add(flagName); + evaluateRequestsHistory.add(request); switch (flagName) { + case "flag-the-proxy-answers-slowly": + return new MockResponse() + .setResponseCode(200) + .setHeadersDelay(5, java.util.concurrent.TimeUnit.SECONDS) + .setBody(TestUtils.readMockResponse(ofrepResponseDir, "flag-with-a-broken-query.json")); case "timeout": Thread.sleep(500); return new MockResponse() @@ -79,11 +107,15 @@ public MockResponse handleEvaluateFlags(RecordedRequest request) { .setResponseCode(400) .setBody(TestUtils.readMockResponse(ofrepResponseDir, flagName + ".json")); case "401": - return new MockResponse().setResponseCode(401); + return new MockResponse().setResponseCode(401).setBody(ofrepErrorBody); case "403": - return new MockResponse().setResponseCode(403); + return new MockResponse().setResponseCode(403).setBody(ofrepErrorBody); case "404": - return new MockResponse().setResponseCode(404); + return new MockResponse().setResponseCode(404).setBody(ofrepErrorBody); + case "flag-the-proxy-answers-badly": + // an error status with no body at all: the OFREP client reads the body first, so it + // raises instead of returning an evaluation carrying an error code + return new MockResponse().setResponseCode(500); case "500": return new MockResponse() .setResponseCode(500) @@ -94,7 +126,7 @@ public MockResponse handleEvaluateFlags(RecordedRequest request) { .setResponseCode(200) .setBody(TestUtils.readMockResponse(ofrepResponseDir, flagName + ".json")); } catch (Exception e) { - return new MockResponse().setResponseCode(404); + return new MockResponse().setResponseCode(404).setBody(ofrepErrorBody); } } } @@ -117,6 +149,47 @@ public MockResponse handleCollector(RecordedRequest request) { return new MockResponse().setResponseCode(200).setBody("{\"ingestedContentCount\":0}"); } + /** + * a configuration carrying fields this provider has no property for, at the top level, inside a + * flag and inside the enrichment: all of them must be tolerated and none discarded. + */ + private MockResponse unknownResponseFieldConfig() { + return new MockResponse() + .setResponseCode(200) + .setBody("{\"schemaVersion\": 2," + + " \"aFieldFromANewerRelayProxy\": {\"nested\": [1, 2, 3]}," + + " \"flags\": {\"TEST\": {\"variations\": {\"on\": true}," + + " \"defaultRule\": {\"variation\": \"on\"}," + + " \"aFieldFromANewerEngine\": {\"deep\": {\"deeper\": 1}}}}," + + " \"evaluationContextEnrichment\": {\"anUnknownEnrichmentKey\": 1}}") + .addHeader(Const.HTTP_HEADER_ETAG, "\"unknown-response-field\""); + } + + /** a flag whose targeting query cannot be parsed, which makes the engine trap. */ + private static final String BROKEN_QUERY_FLAG = "{\"variations\": {\"on\": true}," + + " \"targeting\": [{\"query\": \"((((\", \"variation\": \"on\"}]," + + " \"defaultRule\": {\"variation\": \"on\"}}"; + + /** + * flags the evaluation engine refuses. A flag with no default rule answers FLAG_CONFIG; the + * others make it trap, which answers GENERAL. The three trapping flags differ only in what the + * relay proxy then does with them: answer, refuse, or reply unreadably. + */ + private MockResponse misconfiguredFlagsConfig() { + return new MockResponse() + .setResponseCode(200) + .setBody("{\"flags\": {" + + " \"flag-without-default-rule\": {\"variations\": {\"on\": true}}," + + " \"flag-with-a-broken-query\": " + BROKEN_QUERY_FLAG + "," + + " \"flag-the-proxy-does-not-have\": " + BROKEN_QUERY_FLAG + "," + + " \"flag-the-proxy-answers-badly\": " + BROKEN_QUERY_FLAG + "," + + " \"flag-the-proxy-answers-slowly\": " + BROKEN_QUERY_FLAG + "," + + " \"flag-the-proxy-serves-as-a-decimal\": " + BROKEN_QUERY_FLAG + "," + + " \"healthy-flag\": {\"variations\": {\"on\": true}," + + " \"defaultRule\": {\"variation\": \"on\"}}}}") + .addHeader(Const.HTTP_HEADER_ETAG, "\"misconfigured-flags\""); + } + @SneakyThrows public MockResponse handleFlagConfiguration(RecordedRequest request) { var configLocation = "valid-all-types.json"; @@ -126,6 +199,15 @@ public MockResponse handleFlagConfiguration(RecordedRequest request) { break; case ENDPOINT_ERROR_404: return new MockResponse().setResponseCode(404); + case EMPTY_FLAG_CONFIG: + // a valid configuration that happens to contain no flag + return new MockResponse() + .setResponseCode(200) + .setBody("{\"flags\": {}}") + .addHeader(Const.HTTP_HEADER_ETAG, "\"empty-flag-config\"") + .addHeader(Const.HTTP_HEADER_LAST_MODIFIED, "Wed, 21 Oct 2015 07:28:00 GMT"); + case MISCONFIGURED_FLAGS: + return misconfiguredFlagsConfig(); case SERVE_OLD_CONFIGURATION: if (configurationCallCount > 1) { // we serve an old configuration after the 1st call. @@ -140,6 +222,90 @@ public MockResponse handleFlagConfiguration(RecordedRequest request) { configLocation = configurationCallCount > 1 ? "valid-all-types-config-change.json" : "valid-all-types.json"; break; + case NOT_MODIFIED_THEN_CHANGE: + if (configurationCallCount == 2) { + // a 304 on the 2nd call: the polling daemon must survive it and keep polling + return new MockResponse().setResponseCode(304); + } + configLocation = + configurationCallCount > 2 ? "valid-all-types-config-change.json" : "valid-all-types.json"; + break; + case FAIL_REFRESH_AFTER_INIT: + // the initial fetch succeeds, then every poll fails: enough in a row and the + // configuration in hand can no longer be vouched for + if (configurationCallCount > 1) { + return new MockResponse().setResponseCode(500); + } + break; + case FAIL_TWICE_THEN_RECOVER: + // two failures, a success, then failures again: the run must restart from zero + if (configurationCallCount == 2 || configurationCallCount == 3 || configurationCallCount > 4) { + return new MockResponse().setResponseCode(500); + } + break; + case FAIL_UNTIL_RECOVERY: + // the initial fetch succeeds, three polls fail, then refreshes work again: the + // stale condition has an end, and the end has to be announced + if (configurationCallCount > 1 && configurationCallCount <= 4) { + return new MockResponse().setResponseCode(500); + } + break; + case STALE_AFTER_A_RECOVERY: + // stale, a recovery, then stale again: recovering has to re-arm the failure run + // rather than leave the provider unable to go stale a second time + if ((configurationCallCount > 1 && configurationCallCount <= 4) || configurationCallCount > 5) { + return new MockResponse().setResponseCode(500); + } + break; + case NOT_MODIFIED_AFTER_STALE: + // the same three failures, but the refresh that ends them is a 304 rather than a + // configuration: it never reaches the consumer, yet it is a refresh that worked + if (configurationCallCount > 1 && configurationCallCount <= 4) { + return new MockResponse().setResponseCode(500); + } + if (configurationCallCount > 4) { + return new MockResponse().setResponseCode(304); + } + break; + case NOT_MODIFIED_DURING_FAILURES: + // two failures, a 304, two more failures: four failed polls in a row unless the 304 + // in the middle counts as the successful refresh it is + if (configurationCallCount == 2 + || configurationCallCount == 3 + || configurationCallCount == 5 + || configurationCallCount == 6) { + return new MockResponse().setResponseCode(500); + } + if (configurationCallCount > 1) { + return new MockResponse().setResponseCode(304); + } + break; + case SAME_CONFIG_CHANGING_ETAG: + // identical content behind a fresh validator on every poll: the ETag alone cannot + // tell "changed" from "fetched", so only the content can + return new MockResponse() + .setResponseCode(200) + .setBody(TestUtils.readMockResponse("flag_config_responses/", configLocation)) + .addHeader(Const.HTTP_HEADER_ETAG, "\"etag-" + configurationCallCount + "\"") + .addHeader(Const.HTTP_HEADER_LAST_MODIFIED, "Wed, 21 Oct 2015 07:28:00 GMT"); + case UNKNOWN_RESPONSE_FIELD: + return unknownResponseFieldConfig(); + case CONFIG_CHANGES_EVERY_POLL: + // alternating configurations, so every poll sees a validator it has not stored + configLocation = + configurationCallCount % 2 == 0 ? "valid-all-types-config-change.json" : "valid-all-types.json"; + break; + case ENRICHMENT_CHANGES_AFTER_INIT: + // the flags never change, only the enrichment the engine evaluates them with + return new MockResponse() + .setResponseCode(200) + .setBody("{\"flags\": {\"TEST\": {\"variations\": {\"on\": true, \"off\": false}," + + " \"targeting\": [{\"query\": \"env eq \\\"staging\\\"\", \"variation\": \"on\"}]," + + " \"defaultRule\": {\"variation\": \"off\"}}}," + + " \"evaluationContextEnrichment\": {\"env\": \"" + + (configurationCallCount > 1 ? "staging" : "production") + "\"}}") + .addHeader( + Const.HTTP_HEADER_ETAG, "\"enrichment-" + Math.min(configurationCallCount, 2) + "\""); case SIMPLE_CONFIG: configLocation = "valid-flag-config.json"; break; @@ -167,6 +333,60 @@ public MockResponse handleFlagConfiguration(RecordedRequest request) { return new MockResponse().setResponseCode(404); case "500": return new MockResponse().setResponseCode(500); + case "unknown-flag-field": + // a flag carrying fields this provider has no model for: they must survive intact + return new MockResponse() + .setResponseCode(200) + .setBody("{\"flags\": {\"TEST\": {\"variations\": {\"on\": true}," + + " \"defaultRule\": {\"variation\": \"on\"}," + + " \"aFieldFromANewerEngine\": {\"nested\": [1, 2, 3]}," + + " \"bucketingKey\": \"teamId\"}}}") + .addHeader(Const.HTTP_HEADER_ETAG, "\"unknown-flag-field\""); + case "unknown-response-field": + return unknownResponseFieldConfig(); + case "no-flags": + // a 200 whose body carries no flags key at all + return new MockResponse() + .setResponseCode(200) + .setBody("{}") + .addHeader(Const.HTTP_HEADER_ETAG, "\"an-advanced-etag\"") + .addHeader(Const.HTTP_HEADER_LAST_MODIFIED, "Wed, 21 Oct 2015 07:28:00 GMT"); + case "null-flags": + // a 200 whose flags key is explicitly null + return new MockResponse() + .setResponseCode(200) + .setBody("{\"flags\": null, \"evaluationContextEnrichment\": {\"env\": \"production\"}}") + .addHeader(Const.HTTP_HEADER_ETAG, "\"an-advanced-etag\"") + .addHeader(Const.HTTP_HEADER_LAST_MODIFIED, "Wed, 21 Oct 2015 07:28:00 GMT"); + case "trailing-tokens": + // a 200 whose valid, empty flag map is followed by a stray token + return new MockResponse() + .setResponseCode(200) + .setBody("{\"flags\": {}}}") + .addHeader(Const.HTTP_HEADER_ETAG, "\"an-advanced-etag\""); + case "null-body": + // a 200 whose body is the JSON literal null + return new MockResponse() + .setResponseCode(200) + .setBody("null") + .addHeader(Const.HTTP_HEADER_ETAG, "\"an-advanced-etag\""); + case "null-enrichment": + // a nil Go map marshals to null: valid, and means "no enrichment" + return new MockResponse() + .setResponseCode(200) + .setBody("{\"flags\": {\"TEST\": {\"variations\": {\"on\": true, \"off\": false}," + + " \"defaultRule\": {\"variation\": \"on\"}}}," + + " \"evaluationContextEnrichment\": null}") + .addHeader(Const.HTTP_HEADER_ETAG, "\"null-enrichment\""); + case "304-with-etag": + // a 304 that echoes the validator back, as the relay proxy does + return new MockResponse() + .setResponseCode(304) + .addHeader(Const.HTTP_HEADER_ETAG, "\"" + configLocation + "\"") + .addHeader(Const.HTTP_HEADER_LAST_MODIFIED, "Wed, 21 Oct 2015 07:28:00 GMT"); + case "304-without-etag": + // a 304 carrying no validator at all + return new MockResponse().setResponseCode(304); case "invalid-lastmodified-header": return new MockResponse() .setResponseCode(200) @@ -188,9 +408,22 @@ public enum MockMode { ENDPOINT_ERROR, ENDPOINT_ERROR_404, CHANGE_CONFIG_AFTER_1ST_EVAL, + NOT_MODIFIED_THEN_CHANGE, SIMPLE_CONFIG, DEFAULT, SERVE_OLD_CONFIGURATION, + CONFIG_CHANGES_EVERY_POLL, + UNKNOWN_RESPONSE_FIELD, + SAME_CONFIG_CHANGING_ETAG, + FAIL_REFRESH_AFTER_INIT, + FAIL_TWICE_THEN_RECOVER, + NOT_MODIFIED_DURING_FAILURES, + FAIL_UNTIL_RECOVERY, + NOT_MODIFIED_AFTER_STALE, + STALE_AFTER_A_RECOVERY, SCHEDULED_ROLLOUT_FLAG_CONFIG, + EMPTY_FLAG_CONFIG, + MISCONFIGURED_FLAGS, + ENRICHMENT_CHANGES_AFTER_INIT, } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/JsonValueUtilTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/JsonValueUtilTest.java new file mode 100644 index 0000000000..1591e7c9be --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/JsonValueUtilTest.java @@ -0,0 +1,36 @@ +package dev.openfeature.contrib.providers.gofeatureflag.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class JsonValueUtilTest { + private static final BigInteger BEYOND_LONG = new BigInteger("18446744073709551616"); + + @Test + void testWidenBigIntegers_TopLevel() { + assertEquals(18446744073709551616.0, JsonValueUtil.widenBigIntegers(BEYOND_LONG)); + } + + @Test + void testWidenBigIntegers_Nested() { + Object widened = JsonValueUtil.widenBigIntegers( + Map.of("limit", BEYOND_LONG, "tiers", List.of(Map.of("max", BEYOND_LONG)))); + + assertEquals( + Map.of("limit", 18446744073709551616.0, "tiers", List.of(Map.of("max", 18446744073709551616.0))), + widened); + } + + @Test + void testWidenBigIntegers_OtherValuesUnchanged() { + assertEquals(42L, JsonValueUtil.widenBigIntegers(42L)); + assertEquals("text", JsonValueUtil.widenBigIntegers("text")); + assertEquals(List.of(1, true, "a"), JsonValueUtil.widenBigIntegers(List.of(1, true, "a"))); + assertNull(JsonValueUtil.widenBigIntegers(null)); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/MetadataUtilTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/MetadataUtilTest.java index 3beaabe305..42964e403f 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/MetadataUtilTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/MetadataUtilTest.java @@ -2,9 +2,11 @@ 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 dev.openfeature.sdk.ImmutableMetadata; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -48,14 +50,25 @@ void testConvertFlagMetadata_WithVariousTypes() { } @Test - void testConvertFlagMetadata_UnsupportedType() { - // Test with a map containing an unsupported type + void testConvertFlagMetadata_StructuredValueStaysParseable() { Map flagMetadata = new HashMap<>(); - flagMetadata.put("key1", new RuntimeException()); // Unsupported type + flagMetadata.put("nested", Map.of("a", 1)); + flagMetadata.put("list", List.of(1, 2, 3)); ImmutableMetadata metadata = MetadataUtil.convertFlagMetadata(flagMetadata); + assertEquals("{\"a\":1}", metadata.getString("nested")); + assertEquals("[1,2,3]", metadata.getString("list")); + } - assertNotNull(metadata); - assertEquals("java.lang.RuntimeException", metadata.getString("key1")); + @Test + void testConvertFlagMetadata_NullValueIsSkipped() { + Map flagMetadata = new HashMap<>(); + flagMetadata.put("present", "value"); + flagMetadata.put("absent", null); + + ImmutableMetadata metadata = MetadataUtil.convertFlagMetadata(flagMetadata); + + assertEquals("value", metadata.getString("present")); + assertNull(metadata.getString("absent")); } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasmTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasmTest.java new file mode 100644 index 0000000000..9f7de9fc69 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasmTest.java @@ -0,0 +1,38 @@ +package dev.openfeature.contrib.providers.gofeatureflag.wasm; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import dev.openfeature.contrib.providers.gofeatureflag.TestUtils; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import java.nio.charset.StandardCharsets; +import lombok.SneakyThrows; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class EvaluationWasmTest { + + @SneakyThrows + @DisplayName("the engine should answer each canonical ABI vector with its canonical output") + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"valid", "missing-targeting-key", "invalid"}) + void theEngineShouldAnswerEachCanonicalAbiVectorWithItsCanonicalOutput(String vector) { + try (val instance = new EvaluationWasm()) { + instance.preWarmWasm(); + + val output = instance.evaluateRaw( + TestUtils.readMockResponse("wasm_inputs/", vector + ".json").getBytes(StandardCharsets.UTF_8)); + + assertEquals( + Const.DESERIALIZE_OBJECT_MAPPER.readTree( + TestUtils.readMockResponse("wasm_outputs/", vector + ".json")), + Const.DESERIALIZE_OBJECT_MAPPER + .reader() + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readTree(output), + "the bundled engine must answer the Appendix B.3 vector exactly as canon does"); + } + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java new file mode 100644 index 0000000000..5228ea5c16 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPoolTest.java @@ -0,0 +1,464 @@ +package dev.openfeature.contrib.providers.gofeatureflag.wasm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.dylibso.chicory.runtime.ExportFunction; +import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.FlagContext; +import dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.WasmInput; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.Reason; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import lombok.SneakyThrows; +import lombok.val; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.Property; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class WasmEvaluatorPoolTest { + + private static WasmInputFixture input() { + return new WasmInputFixture(); + } + + private static final class WasmInputFixture { + private final dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.WasmInput value; + + @SneakyThrows + private WasmInputFixture() { + this.value = dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.WasmInput.builder() + .flagKey("TEST") + .flag(Const.DESERIALIZE_OBJECT_MAPPER.readTree( + "{\"variations\":{\"on\":true},\"defaultRule\":{\"variation\":\"on\"}}")) + .flagContext(FlagContext.builder().defaultSdkValue(false).build()) + .build(); + } + } + + /** a factory handing out prepared instances, recording every one it is asked for. */ + private static final class RecordingFactory implements Supplier { + private final List handedOut = new ArrayList<>(); + private final List queue; + + private RecordingFactory(List queue) { + this.queue = new ArrayList<>(queue); + } + + @Override + public EvaluationWasm get() { + val instance = queue.remove(0); + handedOut.add(instance); + return instance; + } + } + + private static EvaluationWasm instanceThatIsPoisonedAfterEvaluating(boolean poisoned) { + val instance = mock(EvaluationWasm.class); + when(instance.evaluate(any())).thenReturn(new GoFeatureFlagResponse()); + when(instance.isPoisoned()).thenReturn(poisoned); + return instance; + } + + @SneakyThrows + @DisplayName("a trapped instance should be discarded and rebuilt, never served again") + @Test + void aTrappedInstanceShouldBeDiscardedAndRebuiltNeverServedAgain() { + val trapped = instanceThatIsPoisonedAfterEvaluating(true); + val replacement = instanceThatIsPoisonedAfterEvaluating(false); + val factory = new RecordingFactory(List.of(trapped, replacement)); + + val pool = new WasmEvaluatorPool(1, factory); + pool.evaluate(input().value); + + assertEquals(List.of(trapped, replacement), factory.handedOut, "a replacement should have been built"); + + // the very next evaluation must not land on the poisoned instance + pool.evaluate(input().value); + assertNotSame(trapped, factory.handedOut.get(1)); + } + + @SneakyThrows + @DisplayName("a healthy instance should be returned to the pool unchanged") + @Test + void aHealthyInstanceShouldBeReturnedToThePoolUnchanged() { + val healthy = instanceThatIsPoisonedAfterEvaluating(false); + val factory = new RecordingFactory(List.of(healthy)); + + val pool = new WasmEvaluatorPool(1, factory); + pool.evaluate(input().value); + pool.evaluate(input().value); + + assertEquals(1, factory.handedOut.size(), "no instance should have been rebuilt"); + assertSame(healthy, factory.handedOut.get(0)); + } + + @SneakyThrows + @DisplayName("the warm-up should be an evaluation the engine accepts") + @Test + void theWarmUpShouldBeAnEvaluationTheEngineAccepts() { + val instance = new EvaluationWasm(); + val field = EvaluationWasm.class.getDeclaredField("WARM_UP_INPUT"); + field.setAccessible(true); + + // preWarmWasm discards the answer, so the input is replayed here to see what it was + instance.preWarmWasm(); + val answer = Const.DESERIALIZE_OBJECT_MAPPER.readValue( + instance.evaluateRaw((byte[]) field.get(instance)), GoFeatureFlagResponse.class); + + assertFalse(instance.isPoisoned()); + assertEquals("", answer.getErrorCode(), answer.getErrorDetails()); + assertEquals(true, answer.getValue(), "the targeting rule should match, that is what warms the rule parser"); + } + + @SneakyThrows + @DisplayName("a real instance should not be poisoned by a successful evaluation") + @Test + void aRealInstanceShouldNotBePoisonedByASuccessfulEvaluation() { + val instance = new EvaluationWasm(); + instance.preWarmWasm(); + + val got = instance.evaluate(input().value); + + assertEquals(true, got.getValue()); + assertFalse(instance.isPoisoned()); + } + + @SneakyThrows + @DisplayName("a guarded input should answer PARSE_ERROR without poisoning the instance") + @Test + void aGuardedInputShouldAnswerParseErrorWithoutPoisoningTheInstance() { + val instance = new EvaluationWasm(); + instance.preWarmWasm(); + + val deep = new StringBuilder(); + for (int i = 0; i < 400; i++) { + deep.append("{\"a\":"); + } + deep.append("1"); + for (int i = 0; i < 400; i++) { + deep.append("}"); + } + val evalContext = Const.DESERIALIZE_OBJECT_MAPPER.readValue( + "{\"targetingKey\":\"k\",\"deep\":" + deep + "}", java.util.Map.class); + + val wasmInput = dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.WasmInput.builder() + .flagKey("TEST") + .flag(Const.DESERIALIZE_OBJECT_MAPPER.readTree( + "{\"variations\":{\"on\":true},\"defaultRule\":{\"variation\":\"on\"}}")) + .evalContext(evalContext) + .flagContext(FlagContext.builder().defaultSdkValue(false).build()) + .build(); + + val got = instance.evaluate(wasmInput); + + assertEquals("PARSE_ERROR", got.getErrorCode()); + assertFalse(instance.isPoisoned(), "a guarded input is not a guest fault, the instance is still healthy"); + } + + /** + * An input the bundled binary traps on. The guards of 10.1 turn over-large inputs into a + * PARSE_ERROR, but a query that is malformed rather than over-large still faults the guest, which + * is why GOFF-WASM-013 asks for trap handling whichever binary ships. + */ + @SneakyThrows + private static WasmInput trappingInput() { + return WasmInput.builder() + .flagKey("TEST") + .flag(Const.DESERIALIZE_OBJECT_MAPPER.readTree("{\"variations\":{\"on\":true}," + + "\"targeting\":[{\"query\":\"((((\",\"variation\":\"on\"}]," + + "\"defaultRule\":{\"variation\":\"on\"}}")) + .evalContext(Const.DESERIALIZE_OBJECT_MAPPER.readValue("{\"targetingKey\":\"k\"}", java.util.Map.class)) + .flagContext(FlagContext.builder().defaultSdkValue(false).build()) + .build(); + } + + @SneakyThrows + @DisplayName("a guest's panic should reach the provider's log, not the process's standard streams") + @Test + void aGuestsPanicShouldReachTheProvidersLogNotTheProcessStandardStreams() { + val logger = (org.apache.logging.log4j.core.Logger) LogManager.getLogger(EvaluationWasm.class); + val logged = new CopyOnWriteArrayList(); + val appender = new AbstractAppender("guest-output-capture", null, null, true, Property.EMPTY_ARRAY) { + @Override + public void append(LogEvent event) { + if (Level.ERROR.equals(event.getLevel())) { + logged.add(event.getMessage().getFormattedMessage()); + } + } + }; + val processStreams = new ByteArrayOutputStream(); + val processStdout = System.out; + val processStderr = System.err; + appender.start(); + logger.addAppender(appender); + System.setOut(new PrintStream(processStreams, true, StandardCharsets.UTF_8)); + System.setErr(new PrintStream(processStreams, true, StandardCharsets.UTF_8)); + try (val instance = new EvaluationWasm()) { + instance.preWarmWasm(); + instance.evaluate(trappingInput()); + } finally { + System.setOut(processStdout); + System.setErr(processStderr); + logger.removeAppender(appender); + appender.stop(); + } + + assertTrue( + logged.stream().anyMatch(line -> line.contains("panic: runtime error")), + "the engine's panic did not reach the log as an error: " + logged); + assertFalse( + processStreams.toString(StandardCharsets.UTF_8).contains("panic"), + "the engine still writes to the process's standard streams"); + } + + @SneakyThrows + @DisplayName("a real trap should poison the instance") + @Test + void aRealTrapShouldPoisonTheInstance() { + val instance = new EvaluationWasm(); + instance.preWarmWasm(); + + val got = instance.evaluate(trappingInput()); + + assertEquals(ErrorCode.GENERAL.name(), got.getErrorCode()); + assertEquals("Trapped on unreachable instruction", got.getErrorDetails()); + assertTrue(instance.isPoisoned(), "a guest fault left the instance usable"); + } + + /** + * Counts the calls an instance makes to the guest's free, by swapping the export for one that + * delegates. The requirement is about the call being made at all, and nothing the instance + * returns reveals whether it was. + */ + @SneakyThrows + private static AtomicInteger countFreeCalls(final EvaluationWasm instance) { + val calls = new AtomicInteger(); + val field = EvaluationWasm.class.getDeclaredField("free"); + field.setAccessible(true); + val real = (ExportFunction) field.get(instance); + field.set(instance, (ExportFunction) args -> { + calls.incrementAndGet(); + return real.apply(args); + }); + return calls; + } + + @SneakyThrows + @DisplayName("a real trap should not have the input buffer freed on it") + @Test + void aRealTrapShouldNotHaveTheInputBufferFreedOnIt() { + val instance = new EvaluationWasm(); + instance.preWarmWasm(); + val freeCalls = countFreeCalls(instance); + + instance.evaluate(trappingInput()); + + assertEquals(0, freeCalls.get(), "free was called on a trapped instance"); + } + + @SneakyThrows + @DisplayName("an evaluation that did not trap should have its input buffer freed") + @Test + void anEvaluationThatDidNotTrapShouldHaveItsInputBufferFreed() { + val instance = new EvaluationWasm(); + instance.preWarmWasm(); + val freeCalls = countFreeCalls(instance); + + instance.evaluate(input().value); + + // the guard is on the trap, not on freeing in general + assertEquals(1, freeCalls.get()); + } + + @SneakyThrows + @DisplayName("an Error raised by the guest should poison the instance and answer GENERAL") + @Test + void anErrorRaisedByTheGuestShouldPoisonTheInstanceAndAnswerGeneral() { + val instance = new EvaluationWasm(); + instance.preWarmWasm(); + val freeCalls = countFreeCalls(instance); + val field = EvaluationWasm.class.getDeclaredField("evaluate"); + field.setAccessible(true); + field.set(instance, (ExportFunction) args -> { + throw new OutOfMemoryError("Java heap space"); + }); + + val got = instance.evaluate(input().value); + + assertEquals(ErrorCode.GENERAL.name(), got.getErrorCode()); + assertEquals("Java heap space", got.getErrorDetails()); + assertTrue(instance.isPoisoned(), "the guest was aborted mid-execution, its memory cannot be trusted"); + assertEquals(0, freeCalls.get(), "free was called on an aborted instance"); + } + + @SneakyThrows + @DisplayName("an instance poisoned on an interrupted thread should still be rebuilt") + @Test + void anInstancePoisonedOnAnInterruptedThreadShouldStillBeRebuilt() { + val interrupted = mock(EvaluationWasm.class); + when(interrupted.evaluate(any())).thenAnswer(invocation -> { + Thread.currentThread().interrupt(); + return new GoFeatureFlagResponse(); + }); + when(interrupted.isPoisoned()).thenReturn(true); + val instances = new ArrayList>(List.of(() -> interrupted, EvaluationWasm::new)); + + val pool = new WasmEvaluatorPool(1, () -> instances.remove(0).get()); + val executor = Executors.newSingleThreadExecutor(); + try { + val callerKeptItsInterrupt = executor.submit(() -> { + pool.evaluate(input().value); + return Thread.interrupted(); + }) + .get(10, TimeUnit.SECONDS); + val next = executor.submit(() -> pool.evaluate(input().value)).get(10, TimeUnit.SECONDS); + + assertTrue(callerKeptItsInterrupt, "the caller's interrupt should be kept"); + assertEquals(true, next.getValue(), "the rebuild failed on the interrupted thread"); + } finally { + executor.shutdownNow(); + pool.close(); + } + } + + @SneakyThrows + @DisplayName("a failed rebuild should neither escape nor leave the pool without an instance") + @Test + void aFailedRebuildShouldNeitherEscapeNorLeaveThePoolWithoutAnInstance() { + val trapped = instanceThatIsPoisonedAfterEvaluating(true); + val healthy = instanceThatIsPoisonedAfterEvaluating(false); + val instances = new ArrayList>(List.of( + () -> trapped, + () -> { + throw new OutOfMemoryError("Java heap space"); + }, + () -> healthy)); + val pool = new WasmEvaluatorPool(1, () -> instances.remove(0).get()); + val executor = Executors.newSingleThreadExecutor(); + try { + pool.evaluate(input().value); + executor.submit(() -> pool.evaluate(input().value)).get(2, TimeUnit.SECONDS); + + verify(healthy).evaluate(any()); + } finally { + executor.shutdownNow(); + pool.close(); + } + } + + @SneakyThrows + @DisplayName("a real trap should leave the pool able to serve the next evaluation") + @Test + void aRealTrapShouldLeaveThePoolAbleToServeTheNextEvaluation() { + try (val pool = new WasmEvaluatorPool(1)) { + val trapped = pool.evaluate(trappingInput()); + val next = pool.evaluate(input().value); + + assertEquals(ErrorCode.GENERAL.name(), trapped.getErrorCode()); + // the poisoned instance was discarded and rebuilt rather than handed out again + assertEquals(true, next.getValue()); + // the engine reports no error as an empty string, not as null + assertEquals("", next.getErrorCode()); + } + } + + @SneakyThrows + @DisplayName("closing the pool should release every instance it holds") + @Test + void closingThePoolShouldReleaseEveryInstanceItHolds() { + val first = instanceThatIsPoisonedAfterEvaluating(false); + val second = instanceThatIsPoisonedAfterEvaluating(false); + val pool = new WasmEvaluatorPool(2, new RecordingFactory(List.of(first, second))); + + pool.close(); + + verify(first).close(); + verify(second).close(); + } + + @SneakyThrows + @DisplayName("a discarded instance should be released rather than left holding its descriptors") + @Test + void aDiscardedInstanceShouldBeReleasedRatherThanLeftHoldingItsDescriptors() { + val trapped = instanceThatIsPoisonedAfterEvaluating(true); + val replacement = instanceThatIsPoisonedAfterEvaluating(false); + val pool = new WasmEvaluatorPool(1, new RecordingFactory(List.of(trapped, replacement))); + + pool.evaluate(input().value); + + verify(trapped).close(); + verify(replacement, never()).close(); + } + + @SneakyThrows + @DisplayName("a closed pool should answer with an error instead of blocking on an empty queue") + @Test + void aClosedPoolShouldAnswerWithAnErrorInsteadOfBlockingOnAnEmptyQueue() { + val instance = instanceThatIsPoisonedAfterEvaluating(false); + val pool = new WasmEvaluatorPool(1, new RecordingFactory(List.of(instance))); + pool.close(); + + val got = pool.evaluate(input().value); + + assertEquals(ErrorCode.GENERAL.name(), got.getErrorCode()); + assertEquals(Reason.ERROR.name(), got.getReason()); + } + + @SneakyThrows + @DisplayName("closing the pool should release an evaluation waiting for an instance") + @Test + void closingThePoolShouldReleaseAnEvaluationWaitingForAnInstance() { + val busy = new CountDownLatch(1); + val release = new CountDownLatch(1); + val instance = mock(EvaluationWasm.class); + when(instance.evaluate(any())).thenAnswer(invocation -> { + busy.countDown(); + release.await(); + return new GoFeatureFlagResponse(); + }); + val pool = new WasmEvaluatorPool(1, new RecordingFactory(List.of(instance))); + val executor = Executors.newFixedThreadPool(2); + try { + val holder = executor.submit(() -> pool.evaluate(input().value)); + busy.await(); + // the only instance is busy, so this evaluation waits for it + val waiter = executor.submit(() -> pool.evaluate(input().value)); + Thread.sleep(100L); + + pool.close(); + release.countDown(); + holder.get(2, TimeUnit.SECONDS); + + // the returning instance is closed rather than handed over, so nothing else wakes the waiter + val got = waiter.get(2, TimeUnit.SECONDS); + assertEquals(ErrorCode.GENERAL.name(), got.getErrorCode()); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInputTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInputTest.java new file mode 100644 index 0000000000..b3bd8a9329 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInputTest.java @@ -0,0 +1,72 @@ +package dev.openfeature.contrib.providers.gofeatureflag.wasm.bean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openfeature.contrib.providers.gofeatureflag.util.Const; +import java.util.LinkedHashMap; +import lombok.SneakyThrows; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class WasmInputTest { + @SneakyThrows + @DisplayName("the flag should reach the engine exactly as it was received") + @Test + void theFlagShouldReachTheEngineExactlyAsItWasReceived() { + // a flag carrying a field no version of this provider ever modelled, and an explicit null + val rawFlag = "{\"variations\":{\"on\":true},\"defaultRule\":{\"variation\":\"on\"}," + + "\"nullField\":null,\"aFieldFromANewerEngine\":{\"nested\":[1,2,3]}}"; + + val wasmInput = WasmInput.builder() + .flagKey("TEST") + .flag(Const.DESERIALIZE_OBJECT_MAPPER.readTree(rawFlag)) + .flagContext(FlagContext.builder().defaultSdkValue(false).build()) + .build(); + + val serialized = new String(Const.SERIALIZE_WASM_MAPPER.writeValueAsBytes(wasmInput)); + val flagSentToEngine = + Const.DESERIALIZE_OBJECT_MAPPER.readTree(serialized).get("flag"); + + // byte-for-byte equivalent: no field dropped, reordered into a lossy model or reconstructed, + // and the NON_NULL serialisation inclusion does not strip nulls inside the opaque flag. + assertEquals(Const.DESERIALIZE_OBJECT_MAPPER.readTree(rawFlag), flagSentToEngine); + } + + @SneakyThrows + @DisplayName("a null map value should reach the engine rather than be dropped") + @Test + void aNullMapValueShouldReachTheEngine() { + val nested = new LinkedHashMap(); + nested.put("innerNull", null); + nested.put("innerOk", 7); + val enrichment = new LinkedHashMap(); + enrichment.put("present", "value"); + enrichment.put("explicitNull", null); + enrichment.put("nested", nested); + val evalContext = new LinkedHashMap(); + evalContext.put("targetingKey", "user-key"); + evalContext.put("aContextAttributeSetToNull", null); + + val wasmInput = WasmInput.builder() + .flagKey("TEST") + .flag(Const.DESERIALIZE_OBJECT_MAPPER.readTree("{\"variations\":{\"on\":true}}")) + .evalContext(evalContext) + .flagContext(FlagContext.builder() + .defaultSdkValue(false) + .evaluationContextEnrichment(enrichment) + .build()) + .build(); + + val sent = Const.DESERIALIZE_OBJECT_MAPPER.readTree( + new String(Const.SERIALIZE_WASM_MAPPER.writeValueAsBytes(wasmInput))); + val sentEnrichment = sent.get("flagContext").get("evaluationContextEnrichment"); + + assertTrue(sentEnrichment.get("explicitNull").isNull()); + assertEquals("value", sentEnrichment.get("present").asText()); + assertTrue(sentEnrichment.get("nested").get("innerNull").isNull()); + assertEquals(7, sentEnrichment.get("nested").get("innerOk").asInt()); + assertTrue(sent.get("evalContext").get("aContextAttributeSetToNull").isNull()); + } +} diff --git a/providers/go-feature-flag/src/test/resources/api_events/valid-response.json b/providers/go-feature-flag/src/test/resources/api_events/valid-response.json index 673edeaebd..6855e04782 100644 --- a/providers/go-feature-flag/src/test/resources/api_events/valid-response.json +++ b/providers/go-feature-flag/src/test/resources/api_events/valid-response.json @@ -9,6 +9,7 @@ "creationDate": 1617970547, "key": "xxx", "kind": "feature", + "source": "INPROCESS", "userKey": "ABCD", "value": true, "variation": "enabled", diff --git a/providers/go-feature-flag/src/test/resources/log4j2-test.xml b/providers/go-feature-flag/src/test/resources/log4j2-test.xml index ca4fab9d98..a5e384dce9 100644 --- a/providers/go-feature-flag/src/test/resources/log4j2-test.xml +++ b/providers/go-feature-flag/src/test/resources/log4j2-test.xml @@ -6,6 +6,8 @@ + + diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-the-proxy-serves-as-a-decimal.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-the-proxy-serves-as-a-decimal.json new file mode 100644 index 0000000000..42dcc7179f --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-the-proxy-serves-as-a-decimal.json @@ -0,0 +1,7 @@ +{ + "key": "flag-the-proxy-serves-as-a-decimal", + "value": 101.25, + "reason": "TARGETING_MATCH", + "variant": "on", + "metadata": {} +} diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-with-a-broken-query.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-with-a-broken-query.json new file mode 100644 index 0000000000..7098293b3b --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-with-a-broken-query.json @@ -0,0 +1,10 @@ +{ + "key": "flag-with-a-broken-query", + "value": true, + "reason": "TARGETING_MATCH", + "variant": "on", + "metadata": { + "gofeatureflag_cacheable": true, + "description": "a flag only the relay proxy can evaluate" + } +} diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_absent.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_absent.json new file mode 100644 index 0000000000..b4859bbab7 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_absent.json @@ -0,0 +1,6 @@ +{ + "key": "metadata_absent", + "value": true, + "reason": "TARGETING_MATCH", + "variant": "enabled" +} diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_with_version.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_with_version.json new file mode 100644 index 0000000000..913ee6c955 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_with_version.json @@ -0,0 +1,11 @@ +{ + "key": "metadata_with_version", + "value": true, + "reason": "TARGETING_MATCH", + "variant": "enabled", + "metadata": { + "gofeatureflag_cacheable": true, + "gofeatureflag_version": "1.2.3", + "description": "A flag carrying both relay proxy metadata keys" + } +} diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_without_goff_keys.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_without_goff_keys.json new file mode 100644 index 0000000000..4563e84e5e --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/metadata_without_goff_keys.json @@ -0,0 +1,9 @@ +{ + "key": "metadata_without_goff_keys", + "value": true, + "reason": "TARGETING_MATCH", + "variant": "enabled", + "metadata": { + "description": "A relay proxy that adds no gofeatureflag_ keys" + } +} diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/string_key.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/string_key.json new file mode 100644 index 0000000000..f41ea9473a --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/string_key.json @@ -0,0 +1,7 @@ +{ + "key": "string_key", + "value": "answered by the relay proxy", + "reason": "TARGETING_MATCH", + "variant": "remoteVariant", + "metadata": {} +} diff --git a/providers/go-feature-flag/src/test/resources/provider_tests/flags.yaml b/providers/go-feature-flag/src/test/resources/provider_tests/flags.yaml new file mode 100644 index 0000000000..fd38583609 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/provider_tests/flags.yaml @@ -0,0 +1,190 @@ +bool_targeting_match: + variations: + Default: false + "False": false + "True": true + targeting: + - query: email eq "john.doe@gofeatureflag.org" + variation: "True" + defaultRule: + percentage: + "False": 0 + "True": 100 + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +disabled_bool: + variations: + Default: false + "False": false + "True": true + defaultRule: + percentage: + "False": 0 + "True": 100 + disable: true + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +disabled_float: + variations: + Default: 103.25 + "False": 101.25 + "True": 100.25 + defaultRule: + percentage: + "False": 0 + "True": 100 + disable: true + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +disabled_int: + variations: + Default: 103 + "False": 101 + "True": 100 + defaultRule: + percentage: + "False": 0 + "True": 100 + disable: true + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +disabled_interface: + variations: + Default: + test: default + "False": + test: "false" + "True": + test: test1 + test2: false + test3: 123.3 + test4: 1 + defaultRule: + percentage: + "False": 0 + "True": 100 + disable: true + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +disabled_string: + variations: + Default: CC0002 + "False": CC0001 + "True": CC0000 + defaultRule: + percentage: + "False": 0 + "True": 100 + disable: true + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +double_key: + variations: + Default: 103.25 + "False": 101.25 + "True": 100.25 + targeting: + - query: email eq "john.doe@gofeatureflag.org" + variation: "True" + defaultRule: + percentage: + "False": 0 + "True": 100 + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +integer_key: + variations: + Default: 103 + "False": 101 + "True": 100 + targeting: + - query: email eq "john.doe@gofeatureflag.org" + variation: "True" + defaultRule: + percentage: + "False": 0 + "True": 100 + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +object_key: + variations: + Default: + test: default + "False": + test: "false" + "True": + test: test1 + test2: false + test3: 123.3 + test4: 1 + targeting: + - query: email eq "john.doe@gofeatureflag.org" + variation: "True" + defaultRule: + percentage: + "False": 0 + "True": 100 + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +string_key: + variations: + Default: CC0002 + "False": CC0001 + "True": CC0000 + targeting: + - query: email eq "john.doe@gofeatureflag.org" + variation: "True" + defaultRule: + percentage: + "False": 0 + "True": 100 + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +string_key_with_version: + variations: + Default: CC0002 + "False": CC0001 + "True": CC0000 + targeting: + - query: email eq "john.doe@gofeatureflag.org" + variation: "True" + defaultRule: + percentage: + "False": 0 + "True": 100 + metadata: + description: this is a test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/916 +flag-use-evaluation-context-enrichment: + variations: + A: A + B: B + targeting: + - query: environment eq "integration-test" + variation: A + defaultRule: + variation: B +boolean_semver_targeting_match: + variations: + "False": false + "True": true + targeting: + - query: version ge 10.0.0-10 + variation: "True" + defaultRule: + percentage: + "False": 100 + "True": 0 + metadata: + description: this is a semver matching test + pr_link: https://github.com/thomaspoignant/go-feature-flag/pull/4764 diff --git a/providers/go-feature-flag/src/test/resources/provider_tests/goff-proxy-authenticated.yaml b/providers/go-feature-flag/src/test/resources/provider_tests/goff-proxy-authenticated.yaml new file mode 100644 index 0000000000..49208a75b3 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/provider_tests/goff-proxy-authenticated.yaml @@ -0,0 +1,14 @@ +server: + mode: http + port: 1032 +pollingInterval: 1000 +startWithRetrieverError: false +retriever: + kind: file + path: ./openfeature/provider_tests/flags.yaml +exporter: + kind: log +authorizedKeys: + evaluation: + - authorized_token +enableSwagger: true diff --git a/providers/go-feature-flag/src/test/resources/provider_tests/goff-proxy.yaml b/providers/go-feature-flag/src/test/resources/provider_tests/goff-proxy.yaml new file mode 100644 index 0000000000..e5133e29ce --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/provider_tests/goff-proxy.yaml @@ -0,0 +1,13 @@ +server: + mode: http + port: 1031 +pollingInterval: 1000 +startWithRetrieverError: false +retriever: + kind: file + path: ./openfeature/provider_tests/flags.yaml +exporter: + kind: log +enableSwagger: true +evaluationContextEnrichment: + environment: integration-test diff --git a/providers/go-feature-flag/src/test/resources/wasm_inputs/invalid.json b/providers/go-feature-flag/src/test/resources/wasm_inputs/invalid.json new file mode 100644 index 0000000000..8319f3115b --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/wasm_inputs/invalid.json @@ -0,0 +1,38 @@ +{ + "flagKey": "TEST", + "flag": { + "variations": { + "enable": true, + "disable": false + }, + "targeting": [ + { + "name": "targetingID rule", + "query": "targetingKey eq \"random-key\"", + "percentage": { + "enable": 90, + "disable": 10 + } + } + ], + "defaultRule": { + "variation": "disable" + }, + "metadata": { + "description": "test flag", + "type": "boolean" + } + }, + "evalContext": { + "targetingKey": "random-key", + "name": "foo", + "age": 42, + "fullname": "foo bar", + "email": "foo.bar@gofeatureflag.org" + }, + "flagContext": { + "evaluationContextEnrichment": { + "env": "production" + }, + "defaultSdkValue": false + \ No newline at end of file diff --git a/providers/go-feature-flag/src/test/resources/wasm_inputs/missing-targeting-key.json b/providers/go-feature-flag/src/test/resources/wasm_inputs/missing-targeting-key.json new file mode 100644 index 0000000000..5784fc6669 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/wasm_inputs/missing-targeting-key.json @@ -0,0 +1,38 @@ +{ + "flagKey": "TEST", + "flag": { + "variations": { + "enable": true, + "disable": false + }, + "targeting": [ + { + "name": "targetingID rule", + "query": "targetingKey eq \"random-key\"", + "percentage": { + "enable": 90, + "disable": 10 + } + } + ], + "defaultRule": { + "variation": "disable" + }, + "metadata": { + "description": "test flag", + "type": "boolean" + } + }, + "evalContext": { + "name": "foo", + "age": 42, + "fullname": "foo bar", + "email": "foo.bar@gofeatureflag.org" + }, + "flagContext": { + "evaluationContextEnrichment": { + "env": "production" + }, + "defaultSdkValue": false + } +} \ No newline at end of file diff --git a/providers/go-feature-flag/src/test/resources/wasm_outputs/invalid.json b/providers/go-feature-flag/src/test/resources/wasm_outputs/invalid.json new file mode 100644 index 0000000000..5f130c7b13 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/wasm_outputs/invalid.json @@ -0,0 +1,11 @@ +{ + "trackEvents": false, + "variationType": "", + "failed": false, + "version": "", + "reason": "", + "errorCode": "PARSE_ERROR", + "errorDetails": "unexpected end of JSON input", + "value": null, + "cacheable": false +} diff --git a/providers/go-feature-flag/src/test/resources/wasm_outputs/missing-targeting-key.json b/providers/go-feature-flag/src/test/resources/wasm_outputs/missing-targeting-key.json new file mode 100644 index 0000000000..4c89f4a40b --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/wasm_outputs/missing-targeting-key.json @@ -0,0 +1,15 @@ +{ + "trackEvents": true, + "variationType": "SdkDefault", + "failed": true, + "version": "", + "reason": "ERROR", + "errorCode": "TARGETING_KEY_MISSING", + "errorDetails": "Error: Empty targeting key", + "value": false, + "cacheable": false, + "metadata": { + "description": "test flag", + "type": "boolean" + } +} \ No newline at end of file diff --git a/providers/go-feature-flag/src/test/resources/wasm_outputs/valid.json b/providers/go-feature-flag/src/test/resources/wasm_outputs/valid.json new file mode 100644 index 0000000000..a2a3d13638 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/wasm_outputs/valid.json @@ -0,0 +1,15 @@ +{ + "trackEvents": true, + "variationType": "enable", + "failed": false, + "version": "", + "reason": "TARGETING_MATCH_SPLIT", + "errorCode": "", + "value": true, + "cacheable": true, + "metadata": { + "description": "test flag", + "evaluatedRuleName": "targetingID rule", + "type": "boolean" + } +} diff --git a/providers/go-feature-flag/wasm-releases b/providers/go-feature-flag/wasm-releases index 1eda3b7f5b..76bf27bab8 160000 --- a/providers/go-feature-flag/wasm-releases +++ b/providers/go-feature-flag/wasm-releases @@ -1 +1 @@ -Subproject commit 1eda3b7f5b6696554acf454a488bc33da5f5a062 +Subproject commit 76bf27bab805b2fdc564a0f8557c03d1af414d70