diff --git a/providers/go-feature-flag/README.md b/providers/go-feature-flag/README.md index e4d12d2b4..d90b89387 100644 --- a/providers/go-feature-flag/README.md +++ b/providers/go-feature-flag/README.md @@ -1,108 +1,125 @@ -# GO Feature Flag Java Provider +# GO Feature Flag - OpenFeature Java provider +[![Maven Central Version](https://img.shields.io/maven-central/v/dev.openfeature.contrib.providers/go-feature-flag?color=blue&style=flat-square)](https://search.maven.org/artifact/dev.openfeature.contrib.providers/go-feature-flag) -GO Feature Flag provider allows you to connect to your [GO Feature Flag relay proxy](https://gofeatureflag.org) instance. -## How to use this provider? +> [!WARNING] +> This version of the provider requires to use GO Feature Flag relay-proxy `v1.45.0` or above. +> If you have an older version of the relay-proxy, please use the version `0.4.3` of the provider. -To use your instance please follow this example: +This is the official OpenFeature Java provider for accessing your feature flags with GO Feature Flag. + +In conjuction with the [OpenFeature SDK](https://openfeature.dev/docs/reference/concepts/provider) you will be able to evaluate your feature flags in your java/kotlin applications. + +For documentation related to flags management in GO Feature Flag, refer to the [GO Feature Flag documentation website](https://gofeatureflag.org/docs). + +### Functionalities: + +- Manage the integration of the OpenFeature Java SDK and GO Feature Flag relay-proxy. +- 2 types of evaluations available: + - **In process**: fetch the flag configuration from the GO Feature Flag relay-proxy API and evaluate the flags directly in the provider. + - **Remote**: Call the GO Feature Flag relay-proxy for each flag evaluation. +- Collect and send evaluation data to the GO Feature Flag relay-proxy for statistics and monitoring purposes. +- Support the OpenFeature [tracking API](https://openfeature.dev/docs/reference/concepts/tracking/) to associate metrics or KPIs with feature flag evaluation contexts. + +## Dependency Setup + + +```xml + + dev.openfeature.contrib.providers + go-feature-flag + 0.4.3 + +``` + + +## Getting started +### Initialize the provider +GO Feature Flag provider needs to be created and then set in the global OpenFeatureAPI. + +The only required option to create a `GoFeatureFlagProvider` is the endpoint to your GO Feature Flag relay-proxy instance. ```java import dev.openfeature.contrib.providers.gofeatureflag; +//... -// ... FeatureProvider provider = new GoFeatureFlagProvider( - GoFeatureFlagProviderOptions - .builder() - .endpoint("https://my-gofeatureflag-instance.org") - .timeout(1000) - .build()); + GoFeatureFlagProviderOptions.builder() + .endpoint("https://my-gofeatureflag-instance.org") + .build()); OpenFeatureAPI.getInstance().setProviderAndWait(provider); - // ... - -Client client = OpenFeatureAPI.getInstance().getClient("my-provider"); +Client client = OpenFeatureAPI.getInstance().getClient("my-goff-provider"); // targetingKey is mandatory for each evaluation String targetingKey = "ad0c6f75-f5d6-4b17-b8eb-6c923d8d4698"; EvaluationContext evaluationContext = new ImmutableContext(targetingKey); -FlagEvaluationDetails booleanFlagEvaluationDetails = client.getBooleanDetails("feature_flag1", false, evaluationContext); -Boolean value = booleanFlagEvaluationDetails.getValue(); - -// ... - -provider.shutdown(); +// Example of a boolean flag evaluation +FlagEvaluationDetails booleanFlagEvaluation = client.getBooleanValue("bool_targeting_match", false, evaluationContext); ``` -You will have a new instance ready to be used with your `open-feature` java SDK. - -### Options +The evaluation context is the way for the client to specify contextual data that GO Feature Flag uses to evaluate the feature flags, it allows to define rules on the flag. -| name | mandatory | Description | -|-----------------------------------|-----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **`endpoint`** | `true` | endpoint contains the DNS of your GO Feature Flag relay proxy _(ex: https://mydomain.com/gofeatureflagproxy/)_ | -| **`timeout`** | `false` | timeout in millisecond we are waiting when calling the go-feature-flag relay proxy API. _(default: 10000)_ | -| **`maxIdleConnections`** | `false` | maxIdleConnections is the maximum number of connexions in the connexion pool. _(default: 1000)_ | -| **`keepAliveDuration`** | `false` | keepAliveDuration is the time in millisecond we keep the connexion 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)_ | -| **`enableCache`** | `false` | enable cache value. _(default: true)_ | -| **`cacheConfig`** | `false` | If cache custom configuration is wanted, you should provide a [Caffeine](https://github.com/ben-manes/caffeine) configuration object. _(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)_ | -| **`flagChangePollingIntervalMs`** | `false` | 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)_ | -| **`disableDataCollection`** | `false` | set to true if you don't want to collect the usage of flags retrieved in the cache. _(default: false)_ | +The `targetingKey` is mandatory for GO Feature Flag in order to evaluate the feature flag, it could be the id of a user, a session ID or anything you find relevant to use as identifier during the evaluation. -## Breaking changes +### Configure the provider +You can configure the provider with several options to customize its behavior. The following options are available: -### 0.4.0 - Cache Implementation Change: Guava to Caffeine -In this release, we have updated the cache implementation from Guava to Caffeine. This change was made because Caffeine is now the recommended caching solution by the maintainers of Guava due to its performance improvements and enhanced features. +| 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` | -Because of this, the cache configuration on `GoFeatureFlagProviderOptions` that used Guava's `CacheBuilder` is now handled by `Caffeine`. +### 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"**: -#### How to migrate +```java +Client client = OpenFeatureAPI.getInstance().getClient("my-goff-provider"); +FlagEvaluationDetails booleanFlagEvaluation = client.getBooleanValue("bool_targeting_match", false, evaluationContext); +``` -Configuration cache with Guava used to be like this: +GO Feature Flag supports different all OpenFeature supported types of feature flags, it means that you can use all the accessor directly ```java -import com.google.common.cache.CacheBuilder; -// ... -CacheBuilder guavaCacheBuilder = CacheBuilder.newBuilder() - .initialCapacity(100) - .maximumSize(2000); +// Boolean +client.getBooleanValue("my-flag", false, evaluationContext); -FeatureProvider provider = new GoFeatureFlagProvider( - GoFeatureFlagProviderOptions - .builder() - .endpoint("https://my-gofeatureflag-instance.org") - .cacheBuilder(guavaCacheBuilder) - .build()); +// String +client.getStringValue("my-flag", "default", evaluationContext); -OpenFeatureAPI.getInstance().setProviderAndWait(provider); +// Integer +client.getIntegerValue("my-flag", 1, evaluationContext); -// ... -``` +// Double +client.getDoubleValue("my-flag", 1.1, evaluationContext); -Now with Caffeine it should be like this: +// Object +client.getObjectDetails("my-flag",Value.objectToValue(new MutableStructure().add("default", "true")), evaluationContext); +``` -```java -import com.github.benmanes.caffeine.cache.Caffeine; -// ... -Caffeine caffeineCacheConfig = Caffeine.newBuilder() - .initialCapacity(100) - .maximumSize(2000); +## How it works +### In process evaluation +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. -FeatureProvider provider = new GoFeatureFlagProvider( - GoFeatureFlagProviderOptions - .builder() - .endpoint("https://my-gofeatureflag-instance.org") - .cacheConfig(caffeineCacheConfig) - .build()); +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. -OpenFeatureAPI.getInstance().setProviderAndWait(provider); +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. -// ... -``` +### Remote evaluation +When the provider is configured to use remote evaluation, it will call the GO Feature Flag relay-proxy for each flag evaluation. -For a complete list of customizations options available in Caffeine, please refer to the [Caffeine documentation](https://github.com/ben-manes/caffeine/wiki) for more details. \ No newline at end of file +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. diff --git a/providers/go-feature-flag/download-wasm.sh b/providers/go-feature-flag/download-wasm.sh new file mode 100755 index 000000000..4ade92b2d --- /dev/null +++ b/providers/go-feature-flag/download-wasm.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash + +# This script downloads the wasm file from the go-feature-flag repository and adds it to the build. + +wasm_version="v1.45.0" # {{wasm_version}} + +# Set the repository owner and name +repo_owner="thomaspoignant" +repo_name="go-feature-flag" +file_suffix=".wasi" +target_dir="./src/main/resources/wasm/" + +# Function to find the download URL +find_download_url() { + local release_tag=$1 + local file_suffix=$2 + + # Get the assets for the specific release + assets=$(curl -s "https://api.github.com/repos/$repo_owner/$repo_name/releases/tags/$wasm_version" | jq -r '.assets') + + if [ -z "$assets" ]; then + echo "Error: No assets found for release $wasm_version" + return 1 + fi + + # Find the asset that matches the file prefix + download_url=$(echo "$assets" | jq -r ".[] | select(.name | endswith(\"$file_suffix\")) | .browser_download_url") + + if [ -z "$download_url" ]; then + echo "Error: No asset found with prefix '$file_suffix' in release $wasm_version" + return 1 + fi + echo "$download_url" +} + +# Function to download the file +download_file() { + local url=$1 + local target_dir=$2 + + if [ -z "$url" ]; then + echo "Error: Download URL is empty." + return 1 + fi + + if [ -z "$target_dir" ]; then + echo "Error: Target directory is empty." + return 1 + fi + + # Extract the filename from the URL + local filename=$(basename "$url") + + # Check if the directory exists + if [ ! -d "$target_dir" ]; then + mkdir -p "$target_dir" # Create the directory if it doesn't exist + fi + + # Use curl to download the file with progress + echo "Downloading $filename to $target_dir..." + curl -L -o "$target_dir/$filename" "$url" + if [ $? -ne 0 ]; then + echo "Error: Download failed." + return 1 + fi + echo "Download successful!" +} + +# Main script logic +download_url=$(find_download_url "$latest_release" "$file_suffix") +if [ $? -ne 0 ]; then + echo "Error: Failed to find the download URL for release $latest_release." + exit 1 +fi + +download_file "$download_url" "$target_dir" +if [ $? -ne 0 ]; then + echo "Error: Failed to download the file. $download_url" + exit 1 +fi + +ls "$target_dir" + +echo "Done." diff --git a/providers/go-feature-flag/pom.xml b/providers/go-feature-flag/pom.xml index 0d606cc47..518e65908 100644 --- a/providers/go-feature-flag/pom.xml +++ b/providers/go-feature-flag/pom.xml @@ -39,27 +39,20 @@ com.fasterxml.jackson.core - jackson-databind + jackson-core 2.19.0 - com.squareup.okhttp3 - okhttp - 4.12.0 - - - - com.squareup.okhttp3 - mockwebserver - 4.12.0 - test + com.fasterxml.jackson.core + jackson-databind + 2.19.0 - com.github.ben-manes.caffeine - caffeine - 2.9.3 + com.fasterxml.jackson.core + jackson-annotations + 2.19.0 @@ -82,10 +75,72 @@ - com.google.guava - guava - 33.4.8-jre + com.dylibso.chicory + runtime + 1.2.1 + + + + com.dylibso.chicory + wasm + 1.2.1 + + + + com.dylibso.chicory + wasi + 1.2.1 + + + + com.squareup.okhttp3 + mockwebserver + 4.12.0 + test + + + + com.squareup.okhttp3 + okhttp + 4.12.0 + test + + + + com.squareup.okio + okio-jvm + 3.12.0 + test + + com.github.spotbugs + spotbugs-annotations + 4.9.3 + provided + + + + + org.codehaus.mojo + exec-maven-plugin + + + download-latest-github-asset + generate-resources + + exec + + + bash + + ${project.basedir}/download-wasm.sh + + + + + + + diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/EvaluationResponse.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/EvaluationResponse.java deleted file mode 100644 index 4652ea880..000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/EvaluationResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag; - -import dev.openfeature.sdk.ProviderEvaluation; -import lombok.Builder; -import lombok.Getter; - -/** - * EvaluationResponse wrapping the provider evaluation. - * - * @param evaluation type - */ -@Builder -@Getter -public class EvaluationResponse { - private ProviderEvaluation providerEvaluation; - private Boolean cacheable; -} 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 fba29b9bf..77503acda 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 @@ -1,52 +1,57 @@ package dev.openfeature.contrib.providers.gofeatureflag; -import com.fasterxml.jackson.core.JsonProcessingException; -import dev.openfeature.contrib.providers.gofeatureflag.bean.ConfigurationChange; -import dev.openfeature.contrib.providers.gofeatureflag.controller.CacheController; -import dev.openfeature.contrib.providers.gofeatureflag.controller.GoFeatureFlagController; -import dev.openfeature.contrib.providers.gofeatureflag.exception.ConfigurationChangeEndpointNotFound; -import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidEndpoint; +import dev.openfeature.contrib.providers.gofeatureflag.api.GoFeatureFlagApi; +import dev.openfeature.contrib.providers.gofeatureflag.bean.EvaluationType; +import dev.openfeature.contrib.providers.gofeatureflag.bean.IEvent; +import dev.openfeature.contrib.providers.gofeatureflag.bean.TrackingEvent; +import dev.openfeature.contrib.providers.gofeatureflag.evaluator.IEvaluator; +import dev.openfeature.contrib.providers.gofeatureflag.evaluator.InProcessEvaluator; +import dev.openfeature.contrib.providers.gofeatureflag.evaluator.RemoteEvaluator; import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; -import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidTypeInCache; 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; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.EventProvider; import dev.openfeature.sdk.Hook; import dev.openfeature.sdk.Metadata; import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.ProviderEventDetails; -import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Tracking; +import dev.openfeature.sdk.TrackingEventDetails; import dev.openfeature.sdk.Value; -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 io.reactivex.rxjava3.subjects.PublishSubject; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; import java.util.List; -import java.util.concurrent.TimeUnit; +import java.util.Map; +import java.util.function.Consumer; import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.NotNull; +import lombok.val; /** - * GoFeatureFlagProvider is the JAVA provider implementation for the feature flag solution GO - * Feature Flag. + * GoFeatureFlagProvider is the JAVA provider implementation for the feature flag solution GO Feature Flag. */ @Slf4j -@SuppressWarnings({"checkstyle:NoFinalizer"}) -public class GoFeatureFlagProvider extends EventProvider { - public static final long DEFAULT_POLLING_CONFIG_FLAG_CHANGE_INTERVAL_MS = 2L * 60L * 1000L; - protected static final String CACHED_REASON = Reason.CACHED.name(); - private static final String NAME = "GO Feature Flag Provider"; - +public final class GoFeatureFlagProvider extends EventProvider implements Tracking { + /** Options to configure the provider. */ private final GoFeatureFlagProviderOptions options; + /** Service to evaluate the flags. */ + private final EvaluationService evalService; + /** List of the hooks used by the provider. */ private final List hooks = new ArrayList<>(); + /** API layer to contact GO Feature Flag. */ + private final GoFeatureFlagApi api; + /** EventPublisher is the system collecting all the information to send to GO Feature Flag. */ + 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; - private Disposable flagChangeDisposable; - private GoFeatureFlagController gofeatureflagController; - private CacheController cacheCtrl; /** * Constructor of the provider. @@ -54,208 +59,152 @@ public class GoFeatureFlagProvider extends EventProvider { * @param options - options to configure the provider * @throws InvalidOptions - if options are invalid */ - public GoFeatureFlagProvider(GoFeatureFlagProviderOptions options) throws InvalidOptions { - this.validateInputOptions(options); + public GoFeatureFlagProvider(final GoFeatureFlagProviderOptions options) throws InvalidOptions { + if (options == null) { + throw new InvalidOptions("No options provided"); + } + options.validate(); this.options = options; + this.api = GoFeatureFlagApi.builder().options(options).build(); + this.evalService = new EvaluationService(getEvaluator(this.api)); + + 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; + } } @Override public Metadata getMetadata() { - return () -> NAME; + return () -> "GO Feature Flag Provider"; } @Override - @SuppressFBWarnings({"EI_EXPOSE_REP"}) public List getProviderHooks() { - return this.hooks; + return new ArrayList<>(this.hooks); } @Override public ProviderEvaluation getBooleanEvaluation( String key, Boolean defaultValue, EvaluationContext evaluationContext) { - return getEvaluation(key, defaultValue, evaluationContext, Boolean.class); + return this.evalService.getEvaluation(key, defaultValue, evaluationContext, Boolean.class); } @Override public ProviderEvaluation getStringEvaluation( String key, String defaultValue, EvaluationContext evaluationContext) { - return getEvaluation(key, defaultValue, evaluationContext, String.class); + return this.evalService.getEvaluation(key, defaultValue, evaluationContext, String.class); } @Override public ProviderEvaluation getIntegerEvaluation( String key, Integer defaultValue, EvaluationContext evaluationContext) { - return getEvaluation(key, defaultValue, evaluationContext, Integer.class); + return this.evalService.getEvaluation(key, defaultValue, evaluationContext, Integer.class); } @Override public ProviderEvaluation getDoubleEvaluation( String key, Double defaultValue, EvaluationContext evaluationContext) { - return getEvaluation(key, defaultValue, evaluationContext, Double.class); + return this.evalService.getEvaluation(key, defaultValue, evaluationContext, Double.class); } @Override public ProviderEvaluation getObjectEvaluation( String key, Value defaultValue, EvaluationContext evaluationContext) { - return getEvaluation(key, defaultValue, evaluationContext, Value.class); + return this.evalService.getEvaluation(key, defaultValue, evaluationContext, Value.class); } @Override public void initialize(EvaluationContext evaluationContext) throws Exception { super.initialize(evaluationContext); - this.gofeatureflagController = - GoFeatureFlagController.builder().options(options).build(); - this.hooks.add(new EnrichEvaluationContextHook(options.getExporterMetadata())); - - if (options.getEnableCache() == null || options.getEnableCache()) { - this.cacheCtrl = CacheController.builder().options(options).build(); - - if (!this.options.isDisableDataCollection()) { - this.dataCollectorHook = new DataCollectorHook(DataCollectorHookOptions.builder() - .flushIntervalMs(options.getFlushIntervalMs()) - .gofeatureflagController(this.gofeatureflagController) - .maxPendingEvents(options.getMaxPendingEvents()) - .build()); - this.hooks.add(this.dataCollectorHook); - } - this.flagChangeDisposable = this.startCheckFlagConfigurationChangesDaemon(); + this.evalService.init(); + this.hooks.add(new EnrichEvaluationContextHook(this.options.getExporterMetadata())); + // 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() + .eventsPublisher(this.eventsPublisher) + .collectUnCachedEvaluation(true) + .evalService(this.evalService) + .build()); + + this.hooks.add(this.dataCollectorHook); } - super.emitProviderReady(ProviderEventDetails.builder() - .message("Provider is ready to call the API") - .build()); log.info("finishing initializing provider"); } - /** - * startCheckFlagConfigurationChangesDaemon is a daemon that will check if the flag configuration - * has changed. - * - * @return Disposable - the subscription to the observable - */ - @NotNull private Disposable startCheckFlagConfigurationChangesDaemon() { - long pollingIntervalMs = options.getFlagChangePollingIntervalMs() != null - ? options.getFlagChangePollingIntervalMs() - : DEFAULT_POLLING_CONFIG_FLAG_CHANGE_INTERVAL_MS; - - PublishSubject stopSignal = PublishSubject.create(); - Observable intervalObservable = Observable.interval(pollingIntervalMs, TimeUnit.MILLISECONDS); - Observable apiCallObservable = intervalObservable - // as soon something is published in stopSignal, the interval will stop - .takeUntil(stopSignal) - .flatMap(tick -> Observable.fromCallable(() -> this.gofeatureflagController.configurationHasChanged()) - .onErrorResumeNext(e -> { - log.error("error while calling flag change API", e); - if (e instanceof ConfigurationChangeEndpointNotFound) { - // emit an item to stop the interval to stop the loop - stopSignal.onNext(new Object()); - } - return Observable.empty(); - })) - .subscribeOn(Schedulers.io()); - - return apiCallObservable.subscribe( - response -> { - if (response == ConfigurationChange.FLAG_CONFIGURATION_UPDATED) { - log.info("clean up the cache because the flag configuration has changed"); - this.cacheCtrl.invalidateAll(); - super.emitProviderConfigurationChanged(ProviderEventDetails.builder() - .message("GO Feature Flag Configuration changed, clearing the cache") - .build()); - } else { - log.debug("flag configuration has not changed: {}", response); - } - }, - throwable -> log.error("error while calling flag change API, error: {}", throwable.getMessage())); + @Override + public void shutdown() { + super.shutdown(); + this.evalService.destroy(); + if (this.dataCollectorHook != null) { + this.dataCollectorHook.shutdown(); + } } - /** - * getEvaluation is the function resolving the flag, it will 1st check in the cache and if it is - * not available will call the evaluation endpoint to get the value of the flag. - * - * @param key - name of the feature flag - * @param defaultValue - value used if something is not working as expected - * @param evaluationContext - EvaluationContext used for the request - * @param expectedType - type expected for the value - * @param the type of your evaluation - * @return a ProviderEvaluation that contains the open-feature response - */ - @SuppressWarnings("unchecked") - private ProviderEvaluation getEvaluation( - String key, T defaultValue, EvaluationContext evaluationContext, Class expectedType) { - try { - if (this.cacheCtrl == null) { - return this.gofeatureflagController - .evaluateFlag(key, defaultValue, evaluationContext, expectedType) - .getProviderEvaluation(); - } + @Override + public void track(final String eventName) { + this.track(eventName, null, null); + } - ProviderEvaluation cachedProviderEvaluation = this.cacheCtrl.getIfPresent(key, evaluationContext); - if (cachedProviderEvaluation == null) { - EvaluationResponse proxyRes = - this.gofeatureflagController.evaluateFlag(key, defaultValue, evaluationContext, expectedType); + @Override + public void track(final String eventName, final EvaluationContext evaluationContext) { + this.track(eventName, evaluationContext, null); + } - if (Boolean.TRUE.equals(proxyRes.getCacheable())) { - this.cacheCtrl.put(key, evaluationContext, proxyRes.getProviderEvaluation()); - } - return proxyRes.getProviderEvaluation(); - } - cachedProviderEvaluation.setReason(CACHED_REASON); - if (cachedProviderEvaluation.getValue().getClass() != expectedType) { - throw new InvalidTypeInCache( - expectedType, cachedProviderEvaluation.getValue().getClass()); - } - return (ProviderEvaluation) cachedProviderEvaluation; - } catch (JsonProcessingException e) { - log.error("Error building key for user", e); - return this.gofeatureflagController - .evaluateFlag(key, defaultValue, evaluationContext, expectedType) - .getProviderEvaluation(); - } catch (InvalidTypeInCache e) { - log.warn(e.getMessage(), e); - return this.gofeatureflagController - .evaluateFlag(key, defaultValue, evaluationContext, expectedType) - .getProviderEvaluation(); - } + @Override + public void track(final String eventName, final TrackingEventDetails trackingEventDetails) { + this.track(eventName, null, trackingEventDetails); } @Override - public void shutdown() { - log.debug("shutdown"); - if (this.dataCollectorHook != null) { - this.dataCollectorHook.shutdown(); - } - if (this.flagChangeDisposable != null) { - this.flagChangeDisposable.dispose(); - } - if (this.cacheCtrl != null) { - this.cacheCtrl.invalidateAll(); - } + public void track(final String eventName, final EvaluationContext context, final TrackingEventDetails details) { + val trackingEvent = TrackingEvent.builder() + .evaluationContext((context != null) ? context.asObjectMap() : Collections.emptyMap()) + .userKey(context != null ? context.getTargetingKey() : "undefined-targetingKey") + .contextKind(EvaluationContextUtil.isAnonymousUser(context) ? "anonymousUser" : "user") + .kind("tracking") + .key(eventName) + .trackingEventDetails(details != null ? details.asObjectMap() : Collections.emptyMap()) + .creationDate(System.currentTimeMillis() / 1000L) + .build(); + this.eventsPublisher.add(trackingEvent); } /** - * validateInputOptions is validating the different options provided when creating the provider. + * Get the evaluator based on the evaluation type. + * It will initialize the evaluator based on the evaluation type. * - * @param options - Options used while creating the provider - * @throws InvalidOptions - if no options are provided - * @throws InvalidEndpoint - if the endpoint provided is not valid + * @return the evaluator */ - private void validateInputOptions(GoFeatureFlagProviderOptions options) throws InvalidOptions { - if (options == null) { - throw new InvalidOptions("No options provided"); - } - - if (options.getEndpoint() == null || options.getEndpoint().isEmpty()) { - throw new InvalidEndpoint("endpoint is a mandatory field when initializing the provider"); + private IEvaluator getEvaluator(GoFeatureFlagApi api) { + // Select the evaluator based on the evaluation type + if (options.getEvaluationType() == null || options.getEvaluationType() == EvaluationType.IN_PROCESS) { + Consumer emitProviderConfigurationChanged = this::emitProviderConfigurationChanged; + return new InProcessEvaluator(api, this.options, emitProviderConfigurationChanged); } + return new RemoteEvaluator(api); } /** - * DO NOT REMOVE, spotbugs: CT_CONSTRUCTOR_THROW. + * publishEvents is calling the GO Feature Flag data/collector api to store the flag usage for + * analytics. * - * @deprecated (Kept for compatibility with OpenFeatureAPI) + * @param eventsList - list of the event to send to GO Feature Flag */ - @Deprecated - protected final void finalize() { - // DO NOT REMOVE, spotbugs: CT_CONSTRUCTOR_THROW + private void publishEvents(List eventsList) { + this.api.sendEventToDataCollector(eventsList, this.exporterMetadata); } } 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 0eff72ff5..cfa45664a 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 @@ -1,40 +1,50 @@ package dev.openfeature.contrib.providers.gofeatureflag; -import com.github.benmanes.caffeine.cache.Caffeine; -import dev.openfeature.sdk.ProviderEvaluation; +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.net.MalformedURLException; +import java.net.URL; +import java.util.List; import java.util.Map; import lombok.Builder; import lombok.Getter; +import lombok.val; -/** GoFeatureFlagProviderOptions contains the options to initialise the provider. */ +/** + * GoFeatureFlagProviderOptions contains the options to initialise the provider. + */ @Builder @Getter public class GoFeatureFlagProviderOptions { - + /** + * evaluationType is the type of evaluation you want to use. + * - If you want to have a local evaluation, you should use IN_PROCESS. + * - If you want to have an evaluation on the relay-proxy directly, you should use REMOTE. + * Default: IN_PROCESS + */ + private EvaluationType evaluationType; /** * (mandatory) endpoint contains the DNS of your GO Feature Flag relay proxy. example: * https://mydomain.com/gofeatureflagproxy/ */ private String endpoint; - /** * (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. @@ -42,50 +52,17 @@ public class GoFeatureFlagProviderOptions { * Default: null */ private String apiKey; - - /** - * (optional) If cache custom configuration is wanted, you should provide a cache configuration - * caffeine object. Example: - * - *
-     * GoFeatureFlagProviderOptions.builder()
-     *   .caffeineConfig(
-     *      Caffeine.newBuilder()
-     *          .initialCapacity(100)
-     *          .maximumSize(100000)
-     *          .expireAfterWrite(Duration.ofMillis(5L * 60L * 1000L))
-     *          .build()
-     *    )
-     *   .build();
-     * 
-     * 
- * Default: CACHE_TTL_MS: 5min CACHE_INITIAL_CAPACITY: 100 CACHE_MAXIMUM_SIZE: 100000 - */ - private Caffeine> cacheConfig; - - /** (optional) enable cache value. Default: true */ - private Boolean enableCache; - /** * (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 */ 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 */ private Integer maxPendingEvents; - - /** - * (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 - */ - private Long flagChangePollingIntervalMs; - /** * (optional) disableDataCollection set to true if you don't want to collect the usage of flags * retrieved in the cache. default: false @@ -95,8 +72,50 @@ public class GoFeatureFlagProviderOptions { /** * (optional) exporterMetadata is the metadata we send to the GO Feature Flag relay proxy when we report the * evaluation data usage. - * ‼️Important: If you are using a GO Feature Flag relay proxy before version v1.41.0, the information of this - * field will not be added to your feature events. */ private Map exporterMetadata; + + /** + * (optional) 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.

+ */ + 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 + */ + private Long flagChangePollingIntervalMs; + + /** + * Validate the options provided to the provider. + * + * @throws InvalidOptions - if options are invalid + */ + public void validate() throws InvalidOptions { + if (getEndpoint() == null || getEndpoint().isEmpty()) { + throw new InvalidEndpoint("endpoint is a mandatory field when initializing the provider"); + } + + try { + new URL(getEndpoint()); + } catch (MalformedURLException e) { + throw new InvalidEndpoint("malformed endpoint: " + getEndpoint()); + } + + if (getExporterMetadata() != null) { + val acceptableExporterMetadataTypes = List.of("String", "Boolean", "Integer", "Double"); + for (Map.Entry entry : getExporterMetadata().entrySet()) { + if (!acceptableExporterMetadataTypes.contains( + entry.getValue().getClass().getSimpleName())) { + throw new InvalidExporterMetadata( + "exporterMetadata can only contain String, Boolean, Integer or Double"); + } + } + } + } } 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 new file mode 100644 index 000000000..365c8a10c --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApi.java @@ -0,0 +1,317 @@ +package dev.openfeature.contrib.providers.gofeatureflag.api; + +import com.fasterxml.jackson.core.JsonProcessingException; +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.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; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.text.SimpleDateFormat; +import java.time.Duration; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import lombok.Builder; +import lombok.extern.slf4j.Slf4j; +import lombok.val; + +/** + * GoFeatureFlagApi is the class to contact the GO Feature Flag relay proxy. + */ +@Slf4j +public final class GoFeatureFlagApi { + /** apiKey contains the token to use while calling GO Feature Flag relay proxy. */ + private final String apiKey; + + /** httpClient is the instance of the HttpClient used by the provider. */ + private final HttpClient httpClient; + + /** endpoint is the endpoint of the GO Feature Flag relay proxy. */ + private final URI endpoint; + + /** timeout is the timeout in milliseconds for the HTTP requests. */ + private int timeout; + + /** + * GoFeatureFlagController is the constructor of the controller to contact the GO Feature Flag + * relay proxy. + * + * @param options - options to initialise the controller + * @throws InvalidOptions - if the options are invalid + */ + @Builder + private GoFeatureFlagApi(final GoFeatureFlagProviderOptions options) throws InvalidOptions { + if (options == null) { + throw new InvalidOptions("No options provided"); + } + options.validate(); + this.apiKey = options.getApiKey(); + + try { + this.endpoint = new URI(options.getEndpoint()); + } catch (URISyntaxException e) { + throw new InvalidEndpoint(e); + } + + // Register JavaTimeModule to be able to deserialized java.time.Instant Object + Const.SERIALIZE_OBJECT_MAPPER.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + Const.SERIALIZE_OBJECT_MAPPER.enable(SerializationFeature.INDENT_OUTPUT); + Const.SERIALIZE_OBJECT_MAPPER.registerModule(new JavaTimeModule()); + + timeout = options.getTimeout() == 0 ? 10000 : options.getTimeout(); + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(timeout)) + .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 + */ + public FlagConfigResponse 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); + + if (this.apiKey != null && !this.apiKey.isEmpty()) { + reqBuilder.header(Const.HTTP_HEADER_AUTHORIZATION, Const.BEARER_TOKEN + this.apiKey); + } + + 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()); + String body = response.body(); + switch (response.statusCode()) { + case HttpURLConnection.HTTP_OK: + case HttpURLConnection.HTTP_NOT_MODIFIED: + return handleFlagConfigurationSuccess(response, body); + case HttpURLConnection.HTTP_NOT_FOUND: + throw new FlagConfigurationEndpointNotFound(); + case HttpURLConnection.HTTP_UNAUTHORIZED: + case HttpURLConnection.HTTP_FORBIDDEN: + throw new ImpossibleToRetrieveConfiguration( + "retrieve flag configuration error: authentication/authorization error"); + case HttpURLConnection.HTTP_BAD_REQUEST: + throw new ImpossibleToRetrieveConfiguration( + "retrieve flag configuration error: Bad request: " + body); + default: + throw new ImpossibleToRetrieveConfiguration( + "retrieve flag configuration error: unexpected http code " + body); + } + } catch (final JsonProcessingException e) { + throw new ImpossibleToRetrieveConfiguration("retrieve flag configuration error", e); + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new ImpossibleToRetrieveConfiguration("retrieve flag configuration error", e); + } + } + + /** + * sendEventToDataCollector is calling the GO Feature Flag data/collector api to store the flag + * usage for analytics. + * + * @param eventsList - list of the event to send to GO Feature Flag + */ + public void sendEventToDataCollector(final List eventsList, final Map exporterMetadata) { + try { + ExporterRequest requestBody = new ExporterRequest(eventsList, exporterMetadata); + URI url = this.endpoint.resolve("/v1/data/collector"); + + 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: + log.info("Published {} events successfully: {}", eventsList.size(), body); + break; + case HttpURLConnection.HTTP_UNAUTHORIZED: + case HttpURLConnection.HTTP_FORBIDDEN: + throw new GeneralError("authentication/authorization error"); + case HttpURLConnection.HTTP_BAD_REQUEST: + throw new GeneralError("Bad request: " + body); + default: + throw new ImpossibleToSendEventsException( + String.format("Error while sending data to the relay-proxy exporter %s", body)); + } + } catch (final IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new ImpossibleToSendEventsException("Error while sending data for relay-proxy exporter", e); + } + } + + /** + * handleFlagConfigurationSuccess is handling the success response of the flag configuration + * request. + * + * @param response - response of the request + * @param body - body of the request + * @return FlagConfigResponse with the flag configuration + * @throws JsonProcessingException - if an error occurred while processing the json + */ + private FlagConfigResponse handleFlagConfigurationSuccess(final HttpResponse response, final String body) + throws JsonProcessingException { + var result = FlagConfigResponse.builder() + .etag(response.headers().firstValue(Const.HTTP_HEADER_ETAG).orElse(null)) + .lastUpdated(extractLastUpdatedFromHeaders(response)) + .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; + } + + /** + * extractLastUpdatedFromHeaders is extracting the Last-Modified header from the response. + * + * @param response - the HTTP response + * @return Date - the parsed Last-Modified date, or null if not present or parsing fails + */ + private Date extractLastUpdatedFromHeaders(final HttpResponse response) { + try { + String headerValue = response.headers() + .firstValue(Const.HTTP_HEADER_LAST_MODIFIED) + .orElse(null); + SimpleDateFormat lastModifiedHeaderFormatter = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); + return headerValue != null ? lastModifiedHeaderFormatter.parse(headerValue) : null; + } catch (Exception e) { + log.debug("Error parsing Last-Modified header: {}", e.getMessage()); + return null; + } + } + + /** + * prepareHttpRequest is preparing the request to be sent to the GO Feature Flag relay proxy. + * + * @param url - url of the request + * @param requestBody - body of the request + * @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 { + 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))); + + if (this.apiKey != null && !this.apiKey.isEmpty()) { + reqBuilder.header(Const.HTTP_HEADER_AUTHORIZATION, Const.BEARER_TOKEN + this.apiKey); + } + + return reqBuilder.build(); + } +} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/events/Events.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/ExporterRequest.java similarity index 63% rename from providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/events/Events.java rename to providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/ExporterRequest.java index c6742d08a..e514ddd5b 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/events/Events.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/ExporterRequest.java @@ -1,5 +1,6 @@ -package dev.openfeature.contrib.providers.gofeatureflag.hook.events; +package dev.openfeature.contrib.providers.gofeatureflag.api.bean; +import dev.openfeature.contrib.providers.gofeatureflag.bean.IEvent; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -8,7 +9,7 @@ /** Events data. */ @Getter -public class Events { +public class ExporterRequest { /** * meta contains the metadata of the events to be sent along the events. */ @@ -17,15 +18,15 @@ public class Events { /** * list of events to be sent to the data collector to collect the evaluation data. */ - private final List events; + private final List events; /** * Constructor. * - * @param events - list of events to be sent to the data collector to collect the evaluation data. + * @param events - list of events to be sent to the data collector to collect the evaluation data. * @param exporterMetadata - metadata of the events to be sent along the events. */ - public Events(List events, Map exporterMetadata) { + public ExporterRequest(List events, Map exporterMetadata) { this.events = new ArrayList<>(events); if (exporterMetadata != null) { this.meta.putAll(exporterMetadata); diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/FlagConfigApiRequest.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/FlagConfigApiRequest.java new file mode 100644 index 000000000..e03552612 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/FlagConfigApiRequest.java @@ -0,0 +1,14 @@ +package dev.openfeature.contrib.providers.gofeatureflag.api.bean; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * Represents the request body for the flag configuration API. + */ +@Data +@AllArgsConstructor +public class FlagConfigApiRequest { + private List flags; +} 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 new file mode 100644 index 000000000..4d5c9d6bd --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/FlagConfigApiResponse.java @@ -0,0 +1,22 @@ +package dev.openfeature.contrib.providers.gofeatureflag.api.bean; + +import com.fasterxml.jackson.annotation.JsonProperty; +import dev.openfeature.contrib.providers.gofeatureflag.bean.Flag; +import java.util.Map; +import lombok.Data; + +/** + * Represents the response body for the flag configuration API. + */ +@Data +public class FlagConfigApiResponse { + @JsonProperty("flags") + private Map flags; + + @JsonProperty("evaluationContextEnrichment") + private Map evaluationContextEnrichment; + + FlagConfigApiResponse() { + // Default constructor + } +} 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 new file mode 100644 index 000000000..479e5f79e --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepRequest.java @@ -0,0 +1,14 @@ +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 new file mode 100644 index 000000000..e1246aa26 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepResponse.java @@ -0,0 +1,53 @@ +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/BeanUtils.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/BeanUtils.java deleted file mode 100644 index f69d2d608..000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/BeanUtils.java +++ /dev/null @@ -1,18 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.bean; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import dev.openfeature.sdk.EvaluationContext; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -/** Bean utils. */ -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public class BeanUtils { - - private static final ObjectMapper objectMapper = new ObjectMapper(); - - public static String buildKey(EvaluationContext evaluationContext) throws JsonProcessingException { - return objectMapper.writeValueAsString(evaluationContext); - } -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ConfigurationChange.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ConfigurationChange.java deleted file mode 100644 index 0b77ea63a..000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ConfigurationChange.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.bean; - -/** ConfigurationChange is an enum to represent the change of the configuration. */ -public enum ConfigurationChange { - FLAG_CONFIGURATION_INITIALIZED, - FLAG_CONFIGURATION_UPDATED, - FLAG_CONFIGURATION_NOT_CHANGED -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/EvaluationType.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/EvaluationType.java new file mode 100644 index 000000000..b18a96eb5 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/EvaluationType.java @@ -0,0 +1,12 @@ +package dev.openfeature.contrib.providers.gofeatureflag.bean; + +/** + * This enum represents the type of evaluation that can be performed. + * + *

IN_PROCESS: The evaluation is done in the process of the application. + * REMOTE: The evaluation is done on the edge (e.g. CDN or API).

+ */ +public enum EvaluationType { + IN_PROCESS, + REMOTE +} 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 new file mode 100644 index 000000000..8e6b3052a --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ExperimentationRollout.java @@ -0,0 +1,13 @@ +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/hook/events/Event.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FeatureEvent.java similarity index 63% rename from providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/events/Event.java rename to providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FeatureEvent.java index efa50512d..ea8c48224 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/events/Event.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FeatureEvent.java @@ -1,13 +1,15 @@ -package dev.openfeature.contrib.providers.gofeatureflag.hook.events; +package dev.openfeature.contrib.providers.gofeatureflag.bean; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Builder; import lombok.Data; -/** Event data. */ +/** + * This class represents a feature event, this is used to send events evaluation events to the GO Feature Flag server. + */ @Builder @Data -public class Event { +public class FeatureEvent implements IEvent { private String contextKind; private Long creationDate; 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 new file mode 100644 index 000000000..e6ed6126f --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/Flag.java @@ -0,0 +1,14 @@ +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 new file mode 100644 index 000000000..ad301eadd --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FlagBase.java @@ -0,0 +1,21 @@ +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 new file mode 100644 index 000000000..a373135aa --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/FlagConfigResponse.java @@ -0,0 +1,18 @@ +package dev.openfeature.contrib.providers.gofeatureflag.bean; + +import java.util.Date; +import java.util.Map; +import lombok.Builder; +import lombok.Data; + +/** + * FlagConfigResponse is a class that represents the response of the flag configuration. + */ +@Data +@Builder +public class FlagConfigResponse { + 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/GoFeatureFlagRequest.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/GoFeatureFlagRequest.java deleted file mode 100644 index a0a98321f..000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/GoFeatureFlagRequest.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.bean; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -/** - * GoFeatureFlagRequest is the request send to the relay proxy. - * - * @param The default value we are using. - */ -@Getter -@AllArgsConstructor -public class GoFeatureFlagRequest { - private GoFeatureFlagUser user; - private T defaultValue; -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/GoFeatureFlagResponse.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/GoFeatureFlagResponse.java index 4d8274725..cbf9bfac3 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/GoFeatureFlagResponse.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/GoFeatureFlagResponse.java @@ -2,18 +2,22 @@ import java.util.Map; import lombok.Data; +import lombok.NoArgsConstructor; -/** GoFeatureFlagResponse is the response returned by the relay proxy. */ +/** + * GoFeatureFlagResponse is a class that represents the response from the Go Feature Flag service. + */ @Data +@NoArgsConstructor public class GoFeatureFlagResponse { - private boolean trackEvents; private String variationType; private boolean failed; private String version; private String reason; private String errorCode; - private String message; + private String errorDetails; private Object value; - private Boolean cacheable; + private boolean cacheable; + private boolean trackEvents; private Map metadata; } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/GoFeatureFlagUser.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/GoFeatureFlagUser.java deleted file mode 100644 index e8ce69945..000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/GoFeatureFlagUser.java +++ /dev/null @@ -1,53 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.bean; - -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.Value; -import dev.openfeature.sdk.exceptions.TargetingKeyMissingError; -import java.util.HashMap; -import java.util.Map; -import lombok.Builder; -import lombok.Getter; - -/** GoFeatureFlagUser is the representation of a user for GO Feature Flag. */ -@Builder -@Getter -public class GoFeatureFlagUser { - private static final String anonymousFieldName = "anonymous"; - private final String key; - private final boolean anonymous; - private final Map custom; - - /** - * fromEvaluationContext is transforming the evaluationContext into a GoFeatureFlagUser. - * - * @param ctx - EvaluationContext from open-feature - * @return GoFeatureFlagUser format for GO Feature Flag - */ - public static GoFeatureFlagUser fromEvaluationContext(EvaluationContext ctx) { - String key = ctx.getTargetingKey(); - if (key == null || key.isEmpty()) { - throw new TargetingKeyMissingError(); - } - boolean anonymous = isAnonymousUser(ctx); - Map custom = new HashMap<>(ctx.asObjectMap()); - if (ctx.getValue(anonymousFieldName) != null) { - custom.remove(anonymousFieldName); - } - return GoFeatureFlagUser.builder() - .anonymous(anonymous) - .key(key) - .custom(custom) - .build(); - } - - /** - * isAnonymousUser is checking if the user in the evaluationContext is anonymous. - * - * @param ctx - EvaluationContext from open-feature - * @return true if the user is anonymous, false otherwise - */ - public static boolean isAnonymousUser(EvaluationContext ctx) { - Value value = ctx.getValue(anonymousFieldName); - return value != null && value.asBoolean(); - } -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/IEvent.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/IEvent.java new file mode 100644 index 000000000..07c2e9875 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/IEvent.java @@ -0,0 +1,6 @@ +package dev.openfeature.contrib.providers.gofeatureflag.bean; + +/** + * Interface for all events that are sent to the GO Feature Flag server. + */ +public interface IEvent {} 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 new file mode 100644 index 000000000..6c39471e1 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ProgressiveRollout.java @@ -0,0 +1,12 @@ +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 new file mode 100644 index 000000000..843becfd7 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ProgressiveRolloutStep.java @@ -0,0 +1,14 @@ +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 new file mode 100644 index 000000000..dc7a59ad2 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/Rule.java @@ -0,0 +1,17 @@ +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 new file mode 100644 index 000000000..ca9a5d219 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ScheduledStep.java @@ -0,0 +1,14 @@ +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 new file mode 100644 index 000000000..b03fb02bb --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/TrackingEvent.java @@ -0,0 +1,54 @@ +package dev.openfeature.contrib.providers.gofeatureflag.bean; + +import java.util.Map; +import lombok.Builder; +import lombok.Data; + +/** + * TrackingEvent is a class that represents a tracking event for a feature flag. + * A tracking event is generated when we call the track method on the client. + */ +@Data +@Builder +public class TrackingEvent implements IEvent { + /** + * Kind for a feature event is feature. + * A feature event will only be generated if the trackEvents attribute of the flag is set to true. + */ + private String kind; + + /** + * ContextKind is the kind of context which generated an event. This will only be "anonymousUser" for events + * generated + * on behalf of an anonymous user or the reserved word "user" for events generated on behalf of a non-anonymous + * user + */ + private String contextKind; + + /** + * UserKey The key of the user object used in a feature flag evaluation. Details for the user object used in a + * feature + * flag evaluation as reported by the "feature" event are transmitted periodically with a separate index event. + */ + private String userKey; + + /** + * CreationDate When the feature flag was requested at Unix epoch time in milliseconds. + */ + private Long creationDate; + + /** + * Key of the event. + */ + private String key; + + /** + * EvaluationContext contains the evaluation context used for the tracking. + */ + private Map evaluationContext; + + /** + * TrackingDetails contains the details of the tracking event. + */ + private Map trackingEventDetails; +} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/controller/CacheController.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/controller/CacheController.java deleted file mode 100644 index 6f5611a4a..000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/controller/CacheController.java +++ /dev/null @@ -1,53 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.controller; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; -import dev.openfeature.contrib.providers.gofeatureflag.bean.BeanUtils; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.ProviderEvaluation; -import java.time.Duration; -import lombok.Builder; - -/** CacheController is a controller to manage the cache of the provider. */ -public class CacheController { - public static final long DEFAULT_CACHE_TTL_MS = 5L * 60L * 1000L; - public static final int DEFAULT_CACHE_INITIAL_CAPACITY = 100; - public static final int DEFAULT_CACHE_MAXIMUM_SIZE = 100000; - private final Cache> cache; - - @Builder - public CacheController(GoFeatureFlagProviderOptions options) { - this.cache = options.getCacheConfig() != null ? options.getCacheConfig().build() : buildDefaultCache(); - } - - private Cache> buildDefaultCache() { - return Caffeine.newBuilder() - .initialCapacity(DEFAULT_CACHE_INITIAL_CAPACITY) - .maximumSize(DEFAULT_CACHE_MAXIMUM_SIZE) - .expireAfterWrite(Duration.ofMillis(DEFAULT_CACHE_TTL_MS)) - .build(); - } - - public void put( - final String key, final EvaluationContext evaluationContext, final ProviderEvaluation providerEvaluation) - throws JsonProcessingException { - this.cache.put(buildCacheKey(key, evaluationContext), providerEvaluation); - } - - public ProviderEvaluation getIfPresent(final String key, final EvaluationContext evaluationContext) - throws JsonProcessingException { - return this.cache.getIfPresent(buildCacheKey(key, evaluationContext)); - } - - public void invalidateAll() { - this.cache.invalidateAll(); - } - - private String buildCacheKey(String key, EvaluationContext evaluationContext) throws JsonProcessingException { - String originalKey = key + "," + BeanUtils.buildKey(evaluationContext); - int hash = originalKey.hashCode(); - return String.valueOf(hash); - } -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/controller/GoFeatureFlagController.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/controller/GoFeatureFlagController.java deleted file mode 100644 index e2ca9f00c..000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/controller/GoFeatureFlagController.java +++ /dev/null @@ -1,373 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.controller; - -import static dev.openfeature.sdk.Value.objectToValue; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import dev.openfeature.contrib.providers.gofeatureflag.EvaluationResponse; -import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProviderOptions; -import dev.openfeature.contrib.providers.gofeatureflag.bean.ConfigurationChange; -import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagRequest; -import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; -import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagUser; -import dev.openfeature.contrib.providers.gofeatureflag.exception.ConfigurationChangeEndpointNotFound; -import dev.openfeature.contrib.providers.gofeatureflag.exception.ConfigurationChangeEndpointUnknownErr; -import dev.openfeature.contrib.providers.gofeatureflag.exception.GoFeatureFlagException; -import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidEndpoint; -import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; -import dev.openfeature.contrib.providers.gofeatureflag.hook.events.Event; -import dev.openfeature.contrib.providers.gofeatureflag.hook.events.Events; -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.GeneralError; -import dev.openfeature.sdk.exceptions.InvalidContextError; -import dev.openfeature.sdk.exceptions.OpenFeatureError; -import dev.openfeature.sdk.exceptions.TypeMismatchError; -import java.io.IOException; -import java.net.HttpURLConnection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; -import lombok.Builder; -import lombok.extern.slf4j.Slf4j; -import okhttp3.ConnectionPool; -import okhttp3.HttpUrl; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; -import okhttp3.ResponseBody; - -/** - * GoFeatureFlagController is the layer to contact the APIs and get the data from the - * GoFeatureFlagProvider. - */ -@Slf4j -@SuppressWarnings({"checkstyle:NoFinalizer"}) -public class GoFeatureFlagController { - public static final String APPLICATION_JSON = "application/json"; - public static final ObjectMapper requestMapper = new ObjectMapper(); - private static final ObjectMapper responseMapper = - new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - private static final String BEARER_TOKEN = "Bearer "; - - private static final String HTTP_HEADER_CONTENT_TYPE = "Content-Type"; - private static final String HTTP_HEADER_AUTHORIZATION = "Authorization"; - private static final String HTTP_HEADER_ETAG = "ETag"; - private static final String HTTP_HEADER_IF_NONE_MATCH = "If-None-Match"; - - /** apiKey contains the token to use while calling GO Feature Flag relay proxy. */ - private final String apiKey; - /** httpClient is the instance of the OkHttpClient used by the provider. */ - private final OkHttpClient httpClient; - - private final HttpUrl parsedEndpoint; - - /** exporterMetadata contains the metadata to send to the collector API. */ - private Map exporterMetadata = new HashMap<>(); - - /** - * etag contains the etag of the configuration, if null, it means that the configuration has never - * been retrieved. - */ - private String etag; - - /** - * GoFeatureFlagController is the constructor of the controller to contact the GO Feature Flag - * relay proxy. - * - * @param options - options to initialise the controller - * @throws InvalidOptions - if the options are invalid - */ - @Builder - private GoFeatureFlagController(final GoFeatureFlagProviderOptions options) throws InvalidOptions { - this.apiKey = options.getApiKey(); - this.exporterMetadata = options.getExporterMetadata() == null ? new HashMap<>() : options.getExporterMetadata(); - this.exporterMetadata.put("provider", "java"); - this.exporterMetadata.put("openfeature", true); - - this.parsedEndpoint = HttpUrl.parse(options.getEndpoint()); - if (this.parsedEndpoint == null) { - throw new InvalidEndpoint(); - } - - // Register JavaTimeModule to be able to deserialized java.time.Instant Object - requestMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); - requestMapper.enable(SerializationFeature.INDENT_OUTPUT); - requestMapper.registerModule(new JavaTimeModule()); - - int timeout = options.getTimeout() == 0 ? 10000 : options.getTimeout(); - long keepAliveDuration = options.getKeepAliveDuration() == null ? 7200000 : options.getKeepAliveDuration(); - int maxIdleConnections = options.getMaxIdleConnections() == 0 ? 1000 : options.getMaxIdleConnections(); - - this.httpClient = new OkHttpClient.Builder() - .connectTimeout(timeout, TimeUnit.MILLISECONDS) - .readTimeout(timeout, TimeUnit.MILLISECONDS) - .callTimeout(timeout, TimeUnit.MILLISECONDS) - .readTimeout(timeout, TimeUnit.MILLISECONDS) - .writeTimeout(timeout, TimeUnit.MILLISECONDS) - .connectionPool(new ConnectionPool(maxIdleConnections, keepAliveDuration, TimeUnit.MILLISECONDS)) - .build(); - } - - /** - * evaluateFlag is calling the GO Feature Flag relay proxy to get the evaluation of a flag. - * - * @param key - name of the flag - * @param defaultValue - default value - * @param evaluationContext - context of the evaluation - * @param expectedType - expected type of the flag - * @param - type of the flag - * @return EvaluationResponse with the evaluation of the flag - * @throws OpenFeatureError - if an error occurred while evaluating the flag - */ - public EvaluationResponse evaluateFlag( - String key, T defaultValue, EvaluationContext evaluationContext, Class expectedType) - throws OpenFeatureError { - try { - GoFeatureFlagUser user = GoFeatureFlagUser.fromEvaluationContext(evaluationContext); - GoFeatureFlagRequest goffRequest = new GoFeatureFlagRequest<>(user, defaultValue); - - HttpUrl url = this.parsedEndpoint - .newBuilder() - .addEncodedPathSegment("v1") - .addEncodedPathSegment("feature") - .addEncodedPathSegment(key) - .addEncodedPathSegment("eval") - .build(); - - Request.Builder reqBuilder = new Request.Builder() - .url(url) - .addHeader(HTTP_HEADER_CONTENT_TYPE, APPLICATION_JSON) - .post(RequestBody.create( - requestMapper.writeValueAsBytes(goffRequest), - MediaType.get("application/json; charset=utf-8"))); - - if (this.apiKey != null && !this.apiKey.isEmpty()) { - reqBuilder.addHeader(HTTP_HEADER_AUTHORIZATION, BEARER_TOKEN + this.apiKey); - } - - try (Response response = this.httpClient.newCall(reqBuilder.build()).execute()) { - if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED - || response.code() == HttpURLConnection.HTTP_FORBIDDEN) { - throw new GeneralError("authentication/authorization error"); - } - - ResponseBody responseBody = response.body(); - String body = responseBody != null ? responseBody.string() : ""; - GoFeatureFlagResponse goffResp = responseMapper.readValue(body, GoFeatureFlagResponse.class); - - if (response.code() == HttpURLConnection.HTTP_BAD_REQUEST) { - throw new InvalidContextError("Invalid context " + goffResp.getMessage()); - } - - if (response.code() == HttpURLConnection.HTTP_INTERNAL_ERROR) { - throw new GeneralError("Unknown error while retrieving flag " + goffResp.getMessage()); - } - - 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. - ProviderEvaluation providerEvaluation = ProviderEvaluation.builder() - .value(defaultValue) - .variant(goffResp.getVariationType()) - .reason(Reason.DISABLED.name()) - .build(); - - return EvaluationResponse.builder() - .providerEvaluation(providerEvaluation) - .cacheable(goffResp.getCacheable()) - .build(); - } - - if (ErrorCode.FLAG_NOT_FOUND.name().equalsIgnoreCase(goffResp.getErrorCode())) { - throw new FlagNotFoundError("Flag " + key + " was not found in your configuration"); - } - - // Convert the value received from the API. - T flagValue = convertValue(goffResp.getValue(), expectedType); - - if (flagValue.getClass() != expectedType) { - throw new TypeMismatchError("Flag value " - + key - + " had unexpected type " - + flagValue.getClass() - + ", expected " - + expectedType - + "."); - } - - ProviderEvaluation providerEvaluation = ProviderEvaluation.builder() - .errorCode(mapErrorCode(goffResp.getErrorCode())) - .reason(goffResp.getReason()) - .value(flagValue) - .variant(goffResp.getVariationType()) - .flagMetadata(MetadataUtil.convertFlagMetadata(goffResp.getMetadata())) - .build(); - - return EvaluationResponse.builder() - .providerEvaluation(providerEvaluation) - .cacheable(goffResp.getCacheable()) - .build(); - } - } catch (IOException e) { - throw new GeneralError("unknown error while retrieving flag " + key, e); - } - } - - /** - * sendEventToDataCollector is calling the GO Feature Flag data/collector api to store the flag - * usage for analytics. - * - * @param eventsList - list of the event to send to GO Feature Flag - */ - public void sendEventToDataCollector(List eventsList) { - try { - Events events = new Events(eventsList, this.exporterMetadata); - HttpUrl url = this.parsedEndpoint - .newBuilder() - .addEncodedPathSegment("v1") - .addEncodedPathSegment("data") - .addEncodedPathSegment("collector") - .build(); - - Request.Builder reqBuilder = new Request.Builder() - .url(url) - .addHeader(HTTP_HEADER_CONTENT_TYPE, APPLICATION_JSON) - .post(RequestBody.create( - requestMapper.writeValueAsBytes(events), MediaType.get("application/json; charset=utf-8"))); - - if (this.apiKey != null && !this.apiKey.isEmpty()) { - reqBuilder.addHeader(HTTP_HEADER_AUTHORIZATION, BEARER_TOKEN + this.apiKey); - } - - try (Response response = this.httpClient.newCall(reqBuilder.build()).execute()) { - if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED) { - throw new GeneralError("Unauthorized"); - } - if (response.code() >= HttpURLConnection.HTTP_BAD_REQUEST) { - throw new GeneralError("Bad request: " + response.body()); - } - - if (response.code() == HttpURLConnection.HTTP_OK) { - log.info("Published {} events successfully: {}", eventsList.size(), response.body()); - } - } catch (IOException e) { - throw new GeneralError("Impossible to send the usage data to GO Feature Flag", e); - } - } catch (JsonProcessingException e) { - throw new GeneralError("Impossible to convert data collector events", e); - } - } - - /** - * getFlagConfigurationEtag is retrieving the ETAG of the configuration. - * - * @return the ETAG of the configuration - * @throws GoFeatureFlagException if an error occurred while retrieving the ETAG - */ - public ConfigurationChange configurationHasChanged() throws GoFeatureFlagException { - HttpUrl url = this.parsedEndpoint - .newBuilder() - .addEncodedPathSegment("v1") - .addEncodedPathSegment("flag") - .addEncodedPathSegment("change") - .build(); - - Request.Builder reqBuilder = new Request.Builder() - .url(url) - .addHeader(HTTP_HEADER_CONTENT_TYPE, APPLICATION_JSON) - .get(); - - if (this.etag != null && !this.etag.isEmpty()) { - reqBuilder.addHeader(HTTP_HEADER_IF_NONE_MATCH, this.etag); - } - if (this.apiKey != null && !this.apiKey.isEmpty()) { - reqBuilder.addHeader(HTTP_HEADER_AUTHORIZATION, BEARER_TOKEN + this.apiKey); - } - - try (Response response = this.httpClient.newCall(reqBuilder.build()).execute()) { - if (response.code() == HttpURLConnection.HTTP_NOT_MODIFIED) { - return ConfigurationChange.FLAG_CONFIGURATION_NOT_CHANGED; - } - - if (response.code() == HttpURLConnection.HTTP_NOT_FOUND) { - throw new ConfigurationChangeEndpointNotFound(); - } - - if (!response.isSuccessful()) { - throw new ConfigurationChangeEndpointUnknownErr(); - } - - boolean isInitialConfiguration = this.etag == null; - this.etag = response.header(HTTP_HEADER_ETAG); - return isInitialConfiguration - ? ConfigurationChange.FLAG_CONFIGURATION_INITIALIZED - : ConfigurationChange.FLAG_CONFIGURATION_UPDATED; - } catch (IOException e) { - throw new ConfigurationChangeEndpointUnknownErr(e); - } - } - - /** - * 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; - } - } - - /** - * 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); - } - - /** - * DO NOT REMOVE, spotbugs: CT_CONSTRUCTOR_THROW. - * - * @deprecated (Used to avoid the warning of spotbugs, but it is not recommended to use it) - */ - @Deprecated - protected final void finalize() { - // DO NOT REMOVE, spotbugs: CT_CONSTRUCTOR_THROW - } -} 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 new file mode 100644 index 000000000..78f1d7aed --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/IEvaluator.java @@ -0,0 +1,38 @@ +package dev.openfeature.contrib.providers.gofeatureflag.evaluator; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; +import dev.openfeature.sdk.EvaluationContext; + +/** + * IEvaluator is an interface that represents the evaluation of a feature flag. + * It can have multiple implementations: REMOTE or IN-PROCESS. + */ +public interface IEvaluator { + /** + * Initialize the evaluator. + */ + void init(); + + /** + * Destroy the evaluator. + */ + void destroy(); + + /** + * Evaluate the flag. + * + * @param key - name of the flag + * @param defaultValue - default value + * @param evaluationContext - evaluation context + * @return the evaluation response + */ + GoFeatureFlagResponse evaluate(String key, Object defaultValue, EvaluationContext evaluationContext); + + /** + * Check if the flag is trackable or not. + * + * @param flagKey - name of the flag + * @return true if the flag is trackable, false otherwise + */ + boolean isFlagTrackable(String flagKey); +} 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 new file mode 100644 index 000000000..d8c752db7 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -0,0 +1,208 @@ +package dev.openfeature.contrib.providers.gofeatureflag.evaluator; + +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.wasm.EvaluationWasm; +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.ProviderEventDetails; +import dev.openfeature.sdk.Reason; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.schedulers.Schedulers; +import io.reactivex.rxjava3.subjects.PublishSubject; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import lombok.extern.slf4j.Slf4j; +import lombok.val; + +/** + * InProcessEvaluator is a class that represents the evaluation of a feature flag + * it calls an external WASM module to evaluate the feature flag. + */ +@Slf4j +public class InProcessEvaluator implements IEvaluator { + /** API to contact GO Feature Flag. */ + private final GoFeatureFlagApi api; + /** WASM evaluation engine. */ + private final EvaluationWasm evaluationEngine; + /** Options to configure the provider. */ + private final GoFeatureFlagProviderOptions options; + /** Method to call when we have a configuration change. */ + private final Consumer emitProviderConfigurationChanged; + /** Local copy of the flags' configuration. */ + private Map flags; + /** Evaluation context enrichment. */ + private Map evaluationContextEnrichment; + /** Last hash of the flags' configuration. */ + private String etag; + /** Last update of the flags' configuration. */ + private Date lastUpdate; + /** disposable which manage the polling of the flag configurations. */ + private Disposable configurationDisposable; + + /** + * 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 + */ + public InProcessEvaluator( + GoFeatureFlagApi api, + GoFeatureFlagProviderOptions options, + Consumer emitProviderConfigurationChanged) { + this.api = api; + this.flags = Collections.emptyMap(); + this.etag = ""; + this.options = options; + this.lastUpdate = new Date(0); + this.emitProviderConfigurationChanged = emitProviderConfigurationChanged; + this.evaluationEngine = new EvaluationWasm(); + } + + @Override + public GoFeatureFlagResponse evaluate(String key, Object defaultValue, EvaluationContext evaluationContext) { + if (this.flags.get(key) == null) { + val err = new GoFeatureFlagResponse(); + err.setReason(Reason.ERROR.name()); + err.setErrorCode(ErrorCode.FLAG_NOT_FOUND.name()); + err.setErrorDetails("Flag " + key + " was not found in your configuration"); + err.setValue(defaultValue); + return err; + } + val wasmInput = WasmInput.builder() + .flagContext(FlagContext.builder() + .defaultSdkValue(defaultValue) + .evaluationContextEnrichment(this.evaluationContextEnrichment) + .build()) + .evalContext(evaluationContext.asObjectMap()) + .flag(this.flags.get(key)) + .flagKey(key) + .build(); + return this.evaluationEngine.evaluate(wasmInput); + } + + @Override + public boolean isFlagTrackable(final String flagKey) { + Flag flag = this.flags.get(flagKey); + return flag != null && (flag.getTrackEvents() == null || flag.getTrackEvents()); + } + + @Override + public void init() { + val configFlags = api.retrieveFlagConfiguration(this.etag, options.getEvaluationFlagList()); + this.flags = configFlags.getFlags(); + this.etag = configFlags.getEtag(); + this.lastUpdate = configFlags.getLastUpdated(); + this.evaluationContextEnrichment = configFlags.getEvaluationContextEnrichment(); + // We call the WASM engine to avoid a cold start at the 1st evaluation + this.evaluationEngine.preWarmWasm(); + + // start the polling of the flag configuration + this.configurationDisposable = startCheckFlagConfigurationChangesDaemon(); + } + + @Override + public void destroy() { + if (this.configurationDisposable != null) { + this.configurationDisposable.dispose(); + } + } + + /** + * startCheckFlagConfigurationChangesDaemon is a daemon that will check if the flag configuration has changed. + * + * @return Disposable - the subscription to the observable + */ + private Disposable startCheckFlagConfigurationChangesDaemon() { + long pollingIntervalMs = options.getFlagChangePollingIntervalMs() != null + ? options.getFlagChangePollingIntervalMs() + : Const.DEFAULT_POLLING_CONFIG_FLAG_CHANGE_INTERVAL_MS; + + PublishSubject stopSignal = PublishSubject.create(); + Observable intervalObservable = Observable.interval(pollingIntervalMs, TimeUnit.MILLISECONDS); + Observable apiCallObservable = intervalObservable + // as soon something is published in stopSignal, the interval will stop + .takeUntil(stopSignal) + .flatMap(tick -> Observable.fromCallable( + () -> this.api.retrieveFlagConfiguration(this.etag, options.getEvaluationFlagList())) + .onErrorResumeNext(e -> { + log.error("error while calling flag configuration API", e); + return Observable.empty(); + })) + .subscribeOn(Schedulers.io()); + + return apiCallObservable.subscribe( + response -> { + if (response.getEtag().equals(this.etag)) { + log.debug("flag configuration has not changed: {}", response); + return; + } + + if (response.getLastUpdated().before(this.lastUpdate)) { + log.info("configuration received is older than the current one"); + return; + } + + log.info("flag configuration has changed"); + this.etag = response.getEtag(); + this.lastUpdate = response.getLastUpdated(); + val flagChanges = findFlagConfigurationChanges(this.flags, response.getFlags()); + this.flags = response.getFlags(); + this.evaluationContextEnrichment = response.getEvaluationContextEnrichment(); + 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())); + } + + /** + * findFlagConfigurationChanges is a function that will find the flags that have changed. + * + * @param originalFlags - list of original flags + * @param newFlags - list of new flags + * @return - list of flags that have changed + */ + private List findFlagConfigurationChanges( + 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()) { + String key = entry.getKey(); + Flag newFlag = entry.getValue(); + Flag originalFlag = originalFlags.get(key); + + if (originalFlag == null || !originalFlag.equals(newFlag)) { + changedFlags.add(key); + } + } + + // Find removed flags + for (String key : originalFlags.keySet()) { + if (!newFlags.containsKey(key)) { + changedFlags.add(key); + } + } + + return changedFlags; + } +} 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 new file mode 100644 index 000000000..15b60c5f8 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/RemoteEvaluator.java @@ -0,0 +1,45 @@ +package dev.openfeature.contrib.providers.gofeatureflag.evaluator; + +import dev.openfeature.contrib.providers.gofeatureflag.api.GoFeatureFlagApi; +import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; +import dev.openfeature.sdk.EvaluationContext; +import lombok.extern.slf4j.Slf4j; + +/** + * RemoteEvaluator is an implementation of the IEvaluator interface. + * It is used to evaluate the feature flags using the GO Feature Flag API. + */ +@Slf4j +public class RemoteEvaluator implements IEvaluator { + /** API to contact GO Feature Flag. */ + public final GoFeatureFlagApi api; + + /** + * Constructor of the evaluator. + * + * @param api - api service to evaluate the flags + */ + public RemoteEvaluator(GoFeatureFlagApi api) { + this.api = api; + } + + @Override + public GoFeatureFlagResponse evaluate(String key, Object defaultValue, EvaluationContext evaluationContext) { + return this.api.evaluateFlag(key, evaluationContext); + } + + @Override + public boolean isFlagTrackable(String flagKey) { + return true; + } + + @Override + public void init() { + // do nothing + } + + @Override + public void destroy() { + // do nothing + } +} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/ConfigurationChangeEndpointUnknownErr.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/ConfigurationChangeEndpointUnknownErr.java deleted file mode 100644 index 34f78f702..000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/ConfigurationChangeEndpointUnknownErr.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.exception; - -import lombok.experimental.StandardException; - -/** InvalidEndpoint is thrown when we don't have any endpoint in the configuration. */ -@StandardException -public class ConfigurationChangeEndpointUnknownErr extends GoFeatureFlagException {} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/FlagConfigurationEndpointNotFound.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/FlagConfigurationEndpointNotFound.java new file mode 100644 index 000000000..2e91850ed --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/FlagConfigurationEndpointNotFound.java @@ -0,0 +1,7 @@ +package dev.openfeature.contrib.providers.gofeatureflag.exception; + +import lombok.experimental.StandardException; + +/** Thrown when it is impossible to find the flag configuration endpoint. */ +@StandardException +public class FlagConfigurationEndpointNotFound extends GoFeatureFlagRuntimeException {} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/GoFeatureFlagRuntimeException.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/GoFeatureFlagRuntimeException.java new file mode 100644 index 000000000..6a638cc8e --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/GoFeatureFlagRuntimeException.java @@ -0,0 +1,7 @@ +package dev.openfeature.contrib.providers.gofeatureflag.exception; + +import lombok.experimental.StandardException; + +/** GoFeatureFlagException is the main exception for the provider. */ +@StandardException +public class GoFeatureFlagRuntimeException extends RuntimeException {} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/ImpossibleToRetrieveConfiguration.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/ImpossibleToRetrieveConfiguration.java new file mode 100644 index 000000000..d84299412 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/ImpossibleToRetrieveConfiguration.java @@ -0,0 +1,7 @@ +package dev.openfeature.contrib.providers.gofeatureflag.exception; + +import lombok.experimental.StandardException; + +/** Thrown when it is impossible to retrieve the flag configuration. */ +@StandardException +public class ImpossibleToRetrieveConfiguration extends GoFeatureFlagRuntimeException {} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/ImpossibleToSendEventsException.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/ImpossibleToSendEventsException.java new file mode 100644 index 000000000..2e40513be --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/ImpossibleToSendEventsException.java @@ -0,0 +1,9 @@ +package dev.openfeature.contrib.providers.gofeatureflag.exception; + +import lombok.experimental.StandardException; + +/** + * This exception is thrown when the SDK is unable to send events to the GO Feature Flag server. + */ +@StandardException +public class ImpossibleToSendEventsException extends GoFeatureFlagRuntimeException {} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/ConfigurationChangeEndpointNotFound.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/InvalidExporterMetadata.java similarity index 72% rename from providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/ConfigurationChangeEndpointNotFound.java rename to providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/InvalidExporterMetadata.java index ab6ea03dc..3adcfe9e0 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/ConfigurationChangeEndpointNotFound.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/InvalidExporterMetadata.java @@ -4,4 +4,4 @@ /** InvalidEndpoint is thrown when we don't have any endpoint in the configuration. */ @StandardException -public class ConfigurationChangeEndpointNotFound extends GoFeatureFlagException {} +public class InvalidExporterMetadata extends InvalidOptions {} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/InvalidTypeInCache.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/InvalidTypeInCache.java deleted file mode 100644 index 9bccbdc97..000000000 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/InvalidTypeInCache.java +++ /dev/null @@ -1,11 +0,0 @@ -package dev.openfeature.contrib.providers.gofeatureflag.exception; - -/** - * InvalidTypeInCache is thrown when the type of the flag from the cache is not the one expected. - */ -public class InvalidTypeInCache extends GoFeatureFlagException { - public InvalidTypeInCache(Class expected, Class got) { - super("cache value is not from the expected type, we try a remote evaluation," + " expected: " + expected - + ", got: " + got); - } -} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/WasmFileNotFound.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/WasmFileNotFound.java new file mode 100644 index 000000000..f3ca543c9 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/WasmFileNotFound.java @@ -0,0 +1,7 @@ +package dev.openfeature.contrib.providers.gofeatureflag.exception; + +import lombok.experimental.StandardException; + +/** This exception is thrown when the SDK is unable to send events to the GO Feature Flag server. */ +@StandardException +public class WasmFileNotFound extends GoFeatureFlagRuntimeException {} 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 41ac25088..4ea6101e4 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 @@ -1,17 +1,16 @@ package dev.openfeature.contrib.providers.gofeatureflag.hook; -import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagUser; +import dev.openfeature.contrib.providers.gofeatureflag.bean.FeatureEvent; +import dev.openfeature.contrib.providers.gofeatureflag.bean.IEvent; import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; -import dev.openfeature.contrib.providers.gofeatureflag.hook.events.Event; -import dev.openfeature.contrib.providers.gofeatureflag.hook.events.EventsPublisher; +import dev.openfeature.contrib.providers.gofeatureflag.service.EvaluationService; +import dev.openfeature.contrib.providers.gofeatureflag.service.EventsPublisher; +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.time.Duration; -import java.util.List; import java.util.Map; -import java.util.function.Consumer; import lombok.extern.slf4j.Slf4j; /** @@ -19,14 +18,13 @@ * Flag. */ @Slf4j -@SuppressWarnings({"checkstyle:NoFinalizer"}) -public class DataCollectorHook implements Hook> { - public static final long DEFAULT_FLUSH_INTERVAL_MS = Duration.ofMinutes(1).toMillis(); - public static final int DEFAULT_MAX_PENDING_EVENTS = 10000; +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; + private final EventsPublisher eventsPublisher; + /** evalService is the service to evaluate the flags. */ + private final EvaluationService evalService; /** * Constructor of the hook. @@ -34,30 +32,28 @@ public class DataCollectorHook implements Hook> { * @param options - Options to configure the hook * @throws InvalidOptions - Thrown when there is a missing configuration. */ - public DataCollectorHook(DataCollectorHookOptions options) throws InvalidOptions { + public DataCollectorHook(final DataCollectorHookOptions options) throws InvalidOptions { if (options == null) { - throw new InvalidOptions("No options provided"); + throw new InvalidOptions("DataCollectorHookOptions cannot be null"); } - long flushIntervalMs = - options.getFlushIntervalMs() == null ? DEFAULT_FLUSH_INTERVAL_MS : options.getFlushIntervalMs(); - int maxPendingEvents = - options.getMaxPendingEvents() == null ? DEFAULT_MAX_PENDING_EVENTS : options.getMaxPendingEvents(); - Consumer> publisher = this::publishEvents; - eventsPublisher = new EventsPublisher<>(publisher, flushIntervalMs, maxPendingEvents); + options.validate(); + eventsPublisher = options.getEventsPublisher(); + evalService = options.getEvalService(); this.options = options; } @Override public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) { - if ((this.options.getCollectUnCachedEvaluation() == null || !this.options.getCollectUnCachedEvaluation()) - && !Reason.CACHED.name().equals(details.getReason())) { + if (!this.evalService.isFlagTrackable(ctx.getFlagKey()) + || (!Boolean.TRUE.equals(this.options.getCollectUnCachedEvaluation()) + && !Reason.CACHED.name().equals(details.getReason()))) { return; } - Event event = Event.builder() + IEvent event = FeatureEvent.builder() .key(ctx.getFlagKey()) .kind("feature") - .contextKind(GoFeatureFlagUser.isAnonymousUser(ctx.getCtx()) ? "anonymousUser" : "user") + .contextKind(EvaluationContextUtil.isAnonymousUser(ctx.getCtx()) ? "anonymousUser" : "user") .defaultValue(false) .variation(details.getVariant()) .value(details.getValue()) @@ -69,10 +65,10 @@ public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) { @Override public void error(HookContext ctx, Exception error, Map hints) { - Event event = Event.builder() + IEvent event = FeatureEvent.builder() .key(ctx.getFlagKey()) .kind("feature") - .contextKind(GoFeatureFlagUser.isAnonymousUser(ctx.getCtx()) ? "anonymousUser" : "user") + .contextKind(EvaluationContextUtil.isAnonymousUser(ctx.getCtx()) ? "anonymousUser" : "user") .creationDate(System.currentTimeMillis() / 1000L) .defaultValue(true) .variation("SdkDefault") @@ -82,28 +78,9 @@ public void error(HookContext ctx, Exception error, Map hints) { eventsPublisher.add(event); } - /** - * publishEvents is calling the GO Feature Flag data/collector api to store the flag usage for - * analytics. - * - * @param eventsList - list of the event to send to GO Feature Flag - */ - private void publishEvents(List eventsList) { - this.options.getGofeatureflagController().sendEventToDataCollector(eventsList); - } - /** shutdown should be called when we stop the hook, it will publish the remaining event. */ public void shutdown() { - try { - if (eventsPublisher != null) { - eventsPublisher.shutdown(); - } - } catch (Exception e) { - log.error("error publishing events on shutdown", e); - } - } - - protected final void finalize() { - // DO NOT REMOVE, spotbugs: CT_CONSTRUCTOR_THROW + // eventsPublisher is required so no need to check if it is null + eventsPublisher.shutdown(); } } 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 0c709789b..ffb67400a 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,10 +1,11 @@ package dev.openfeature.contrib.providers.gofeatureflag.hook; -import dev.openfeature.contrib.providers.gofeatureflag.controller.GoFeatureFlagController; +import dev.openfeature.contrib.providers.gofeatureflag.bean.IEvent; 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; -import lombok.SneakyThrows; /** * DataCollectorHookOptions is the object containing all the options needed for the Data Collector @@ -13,49 +14,29 @@ @Builder @Getter public class DataCollectorHookOptions { - /** GoFeatureFlagController is the controller to contact the APIs. */ - private final GoFeatureFlagController gofeatureflagController; /** - * (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 + * collectUnCachedEvent (optional) set to true if you want to send all events not only the cached + * evaluations. */ - private Long flushIntervalMs; + private Boolean collectUnCachedEvaluation; /** - * (optional) max pending events aggregated before publishing for collection data to the proxy. - * When an event is added while events collection is full, the event is omitted. default: 10000 + * eventsPublisher is the system collecting all the information to send to GO Feature Flag. */ - private Integer maxPendingEvents; + private EventsPublisher eventsPublisher; + /** - * collectUnCachedEvent (optional) set to true if you want to send all events not only the cached - * evaluations. + * evalService is the service to evaluate the flags. */ - private Boolean collectUnCachedEvaluation; + private EvaluationService evalService; /** - * Override the builder() method to return our custom builder instead of the Lombok generated - * builder class. + * Validate the options provided to the data collector hook. * - * @return a custom builder with validation + * @throws InvalidOptions - if options are invalid */ - public static DataCollectorHookOptionsBuilder builder() { - return new CustomBuilder(); - } - - /** used only for the javadoc not to complain. */ - public static class DataCollectorHookOptionsBuilder {} - - /** CustomBuilder is ensuring the validation in the build method. */ - private static class CustomBuilder extends DataCollectorHookOptionsBuilder { - @SneakyThrows - public DataCollectorHookOptions build() { - if (super.flushIntervalMs != null && super.flushIntervalMs <= 0) { - throw new InvalidOptions("flushIntervalMs must be larger than 0"); - } - if (super.maxPendingEvents != null && super.maxPendingEvents <= 0) { - throw new InvalidOptions("maxPendingEvents must be larger than 0"); - } - return super.build(); + public void validate() throws InvalidOptions { + if (getEventsPublisher() == null) { + throw new InvalidOptions("No events publisher 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 d3c7d912d..68bfa2d93 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 @@ -25,6 +25,9 @@ public Optional before(HookContext ctx, Map before(HookContext ctx, Map expMetadata = new HashMap<>(); expMetadata.put("exporterMetadata", new Value(metadata)); mutableContext.add("gofeatureflag", new MutableStructure(expMetadata)); 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 new file mode 100644 index 000000000..56bc24d35 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EvaluationService.java @@ -0,0 +1,153 @@ +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); + + // 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(); + } + + if (ErrorCode.FLAG_NOT_FOUND.name().equalsIgnoreCase(goffResp.getErrorCode())) { + throw new FlagNotFoundError("Flag " + flagKey + " was not found in your configuration"); + } + + // 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/hook/events/EventsPublisher.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java similarity index 62% rename from providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/events/EventsPublisher.java rename to providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java index c6c9e60ad..32e1272f7 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/events/EventsPublisher.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/service/EventsPublisher.java @@ -1,6 +1,8 @@ -package dev.openfeature.contrib.providers.gofeatureflag.hook.events; +package dev.openfeature.contrib.providers.gofeatureflag.service; -import dev.openfeature.contrib.providers.gofeatureflag.util.ConcurrentUtils; +import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; +import dev.openfeature.contrib.providers.gofeatureflag.util.ConcurrentUtil; +import dev.openfeature.contrib.providers.gofeatureflag.validator.Validator; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; @@ -21,8 +23,7 @@ * @author Liran Mendelovich */ @Slf4j -public class EventsPublisher { - +public final class EventsPublisher { public final AtomicBoolean isShutdown = new AtomicBoolean(false); private final int maxPendingEvents; private final Consumer> publisher; @@ -32,15 +33,17 @@ public class EventsPublisher { private final Lock writeLock = readWriteLock.writeLock(); private final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1); - private List eventsList; + private final List eventsList; /** * Constructor. * - * @param publisher events publisher + * @param publisher events publisher * @param flushIntervalMs data flush interval */ - public EventsPublisher(Consumer> publisher, long flushIntervalMs, int maxPendingEvents) { + public EventsPublisher(Consumer> publisher, long flushIntervalMs, int maxPendingEvents) + throws InvalidOptions { + Validator.publisherOptions(flushIntervalMs, maxPendingEvents); eventsList = new CopyOnWriteArrayList<>(); this.publisher = publisher; this.maxPendingEvents = maxPendingEvents; @@ -55,20 +58,33 @@ public EventsPublisher(Consumer> publisher, long flushIntervalMs, int ma * @param event event for adding */ public void add(T event) { + log.debug("Adding event to events collection {}", event); if (isShutdown.get()) { log.error("This object was shut down. Omitting event."); return; } - if (eventsList.size() >= maxPendingEvents) { - log.warn("events collection is full. Omitting event."); - return; - } - readLock.lock(); + + var shouldPublish = false; try { - eventsList.add(event); + readLock.lock(); + shouldPublish = (eventsList != null) && (eventsList.size() >= maxPendingEvents); } finally { readLock.unlock(); } + + if (shouldPublish) { + log.warn("events collection is full. Publishing before adding new events."); + publish(); + } + + try { + writeLock.lock(); + if (eventsList != null) { + eventsList.add(event); + } + } finally { + writeLock.unlock(); + } } /** @@ -81,34 +97,26 @@ public int publish() { writeLock.lock(); try { if (eventsList.isEmpty()) { - log.info("Not publishing, no events"); - } else { - log.info("publishing {} events", eventsList.size()); - publisher.accept(new ArrayList<>(eventsList)); - publishedEvents = eventsList.size(); - eventsList = new CopyOnWriteArrayList<>(); + log.debug("Not publishing, no events"); + return publishedEvents; } + log.info("publishing {} events", eventsList.size()); + publisher.accept(new ArrayList<>(eventsList)); + publishedEvents = eventsList.size(); + eventsList.clear(); + return publishedEvents; } catch (Exception e) { log.error("Error publishing events", e); + return 0; } finally { writeLock.unlock(); } - return publishedEvents; } /** Shutdown. */ public void shutdown() { - log.info("shutdown"); - try { - log.info("draining remaining events"); - publish(); - } catch (Exception e) { - log.error("error publishing events on shutdown", e); - } - try { - ConcurrentUtils.shutdownAndAwaitTermination(scheduledExecutorService, 10); - } catch (Exception e) { - log.error("error publishing events on shutdown", e); - } + log.info("shutdown, draining remaining events"); + publish(); + ConcurrentUtil.shutdownAndAwaitTermination(scheduledExecutorService, 10); } } diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/ConcurrentUtils.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/ConcurrentUtil.java similarity index 89% rename from providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/ConcurrentUtils.java rename to providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/ConcurrentUtil.java index bea06f4e7..8f0f6e146 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/ConcurrentUtils.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/ConcurrentUtil.java @@ -13,7 +13,7 @@ */ @NoArgsConstructor(access = AccessLevel.PRIVATE) @Slf4j -public class ConcurrentUtils { +public class ConcurrentUtil { /** * Graceful shutdown a thread pool.
@@ -21,36 +21,30 @@ public class ConcurrentUtils { * href="https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ExecutorService.html"> * https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ExecutorService.html * - * @param pool thread pool + * @param pool thread pool * @param timeoutSeconds grace period timeout in seconds - timeout can be twice than this value, - * as first it waits for existing tasks to terminate, then waits for cancelled tasks to - * terminate. + * as first it waits for existing tasks to terminate, then waits for cancelled tasks to + * terminate. */ public static void shutdownAndAwaitTermination(ExecutorService pool, int timeoutSeconds) { // Disable new tasks from being submitted pool.shutdown(); try { - // Wait a while for existing tasks to terminate if (!pool.awaitTermination(timeoutSeconds, TimeUnit.SECONDS)) { - // Cancel currently executing tasks - best effort, based on interrupt handling // implementation. pool.shutdownNow(); - // Wait a while for tasks to respond to being cancelled if (!pool.awaitTermination(timeoutSeconds, TimeUnit.SECONDS)) { log.error("Thread pool did not shutdown all tasks after the timeout: {} seconds.", timeoutSeconds); } } } catch (InterruptedException e) { - log.info("Current thread interrupted during shutdownAndAwaitTermination, calling shutdownNow."); - // (Re-)Cancel if current thread also interrupted pool.shutdownNow(); - // Preserve interrupt status Thread.currentThread().interrupt(); } 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 new file mode 100644 index 000000000..38b4f8e8a --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java @@ -0,0 +1,31 @@ +package dev.openfeature.contrib.providers.gofeatureflag.util; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.util.StdDateFormat; +import java.time.Duration; + +/** + * Const is a utility class that contains constants used in the GoFeatureFlag provider. + */ +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_ETAG = "ETag"; + public static final String HTTP_HEADER_IF_NONE_MATCH = "If-None-Match"; + public static final String HTTP_HEADER_LAST_MODIFIED = "Last-Modified"; + // 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; + // 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) + .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 new file mode 100644 index 000000000..fc085bad8 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtil.java @@ -0,0 +1,30 @@ +package dev.openfeature.contrib.providers.gofeatureflag.util; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.Value; + +/** + * EvaluationContextUtil is a utility class that provides methods to work with the evaluation context. + * It is used to check if the user is anonymous or not. + */ +public class EvaluationContextUtil { + /** + * anonymousFieldName is the name of the field in the evaluation context that indicates + * if the user is anonymous. + */ + private static final String anonymousFieldName = "anonymous"; + + /** + * isAnonymousUser is checking if the user in the evaluationContext is anonymous. + * + * @param ctx - EvaluationContext from open-feature + * @return true if the user is anonymous, false otherwise + */ + public static boolean isAnonymousUser(final EvaluationContext ctx) { + if (ctx == null) { + return true; + } + Value value = ctx.getValue(anonymousFieldName); + return value != null && value.asBoolean(); + } +} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/validator/Validator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/validator/Validator.java new file mode 100644 index 000000000..9b7c8a00d --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/validator/Validator.java @@ -0,0 +1,25 @@ +package dev.openfeature.contrib.providers.gofeatureflag.validator; + +import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; + +/** + * Validator class is providing utils method to validate the options provided. + */ +public class Validator { + /** + * Validate the options provided to the publisher. + * + * @param flushIntervalMs - flush interval in milliseconds + * @param maxPendingEvents - max pending events + * @throws InvalidOptions - if options are invalid + */ + public static void publisherOptions(final Long flushIntervalMs, final Integer maxPendingEvents) + throws InvalidOptions { + if (flushIntervalMs != null && flushIntervalMs <= 0) { + throw new InvalidOptions("flushIntervalMs must be larger than 0"); + } + if (maxPendingEvents != null && maxPendingEvents <= 0) { + throw new InvalidOptions("maxPendingEvents must be larger than 0"); + } + } +} 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 new file mode 100644 index 000000000..8d1fe51cd --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/EvaluationWasm.java @@ -0,0 +1,163 @@ +package dev.openfeature.contrib.providers.gofeatureflag.wasm; + +import com.dylibso.chicory.runtime.ExportFunction; +import com.dylibso.chicory.runtime.HostFunction; +import com.dylibso.chicory.runtime.Instance; +import com.dylibso.chicory.runtime.Memory; +import com.dylibso.chicory.runtime.Store; +import com.dylibso.chicory.wasi.WasiExitException; +import com.dylibso.chicory.wasi.WasiOptions; +import com.dylibso.chicory.wasi.WasiPreview1; +import com.dylibso.chicory.wasm.Parser; +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; +import dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.WasmInput; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.Reason; +import java.io.File; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +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 { + private final Instance instance; + private final ExportFunction evaluate; + private final ExportFunction malloc; + private final ExportFunction free; + + /** + * Constructor of the EvaluationWasm. + * It initializes the WASM module and the host functions. + * + * @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()) + .build(); + val hostFunctions = wasi.toHostFunctions(); + val store = new Store().addFunction(hostFunctions); + store.addFunction(getProcExitFunc()); + this.instance = store.instantiate("evaluation", Parser.parse(getWasmFile())); + this.evaluate = this.instance.export("evaluate"); + this.malloc = this.instance.export("malloc"); + this.free = this.instance.export("free"); + } + + /** + * getWasmFile is a function that returns the path to the WASM file. + * It looks for the file in the classpath under the directory "wasm". + * + * @return the path to the WASM file + * @throws WasmFileNotFound - if the file is not found + */ + private File getWasmFile() throws WasmFileNotFound { + try { + ClassLoader classLoader = EvaluationWasm.class.getClassLoader(); + URL directoryUrl = classLoader.getResource("wasm"); + if (directoryUrl == null) { + throw new RuntimeException("Directory not found"); + } + Path dirPath = Paths.get(directoryUrl.toURI()); + try (val files = Files.list(dirPath)) { + return files.filter(path -> path.getFileName().toString().startsWith("gofeatureflag-evaluation") + && (path.getFileName().toString().endsWith(".wasi") + || path.getFileName().toString().endsWith(".wasm"))) + .findFirst() + .map(Path::toFile) + .orElseThrow( + () -> new RuntimeException("No file starting with 'gofeatureflag-evaluation' found")); + } + } catch (Exception e) { + throw new WasmFileNotFound(e); + } + } + + /** + * 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 + */ + private HostFunction 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; + }); + } + + /** + * 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. + */ + 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); + } + + /** + * Evaluate is a function that evaluates the feature flag using the WASM module. + * + * @param wasmInput - the object used to evaluate the feature flag + * @return the result of the evaluation + */ + public GoFeatureFlagResponse evaluate(WasmInput wasmInput) { + int len = 0; + 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]; + memory.write(ptr, message); + + // Call the wasm evaluate function + val resultPointer = this.evaluate.apply(ptr, len); + + // 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; + } finally { + if (len > 0) { + this.free.apply(ptr, len); + } + } + } +} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/FlagContext.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/FlagContext.java new file mode 100644 index 000000000..10da88aa4 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/FlagContext.java @@ -0,0 +1,20 @@ +package dev.openfeature.contrib.providers.gofeatureflag.wasm.bean; + +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * This class represents the context of a flag in the GO Feature Flag system. + * It contains the default SDK value and the evaluation context enrichment. + */ +@Builder +@Data +@AllArgsConstructor +@NoArgsConstructor +public class FlagContext { + private Object defaultSdkValue; + private Map evaluationContextEnrichment; +} 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 new file mode 100644 index 000000000..ef342fefd --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/bean/WasmInput.java @@ -0,0 +1,23 @@ +package dev.openfeature.contrib.providers.gofeatureflag.wasm.bean; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.Flag; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * This class represents the input to the WASM module. + * It contains the flag key, the flag, the evaluation context, and the flag context. + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class WasmInput { + private String flagKey; + private Flag flag; + private Map evalContext; + private FlagContext flagContext; +} diff --git a/providers/go-feature-flag/src/main/resources/wasm/.gitignore b/providers/go-feature-flag/src/main/resources/wasm/.gitignore new file mode 100644 index 000000000..82ffea01b --- /dev/null +++ b/providers/go-feature-flag/src/main/resources/wasm/.gitignore @@ -0,0 +1 @@ +gofeatureflag-evaluation_* \ No newline at end of file diff --git a/providers/go-feature-flag/src/main/resources/wasm/.gitkeep b/providers/go-feature-flag/src/main/resources/wasm/.gitkeep new file mode 100644 index 000000000..e69de29bb 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 ab0bbbcc2..fd3bb29b6 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,1046 +1,1047 @@ package dev.openfeature.contrib.providers.gofeatureflag; -import static dev.openfeature.contrib.providers.gofeatureflag.controller.GoFeatureFlagController.requestMapper; 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.assertTrue; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.github.benmanes.caffeine.cache.Caffeine; -import com.google.common.net.HttpHeaders; +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.sdk.Client; +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.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.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.net.URL; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Paths; import java.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; 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.jetbrains.annotations.NotNull; 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; @Slf4j class GoFeatureFlagProviderTest { - private int publishEventsRequestsReceived = 0; - private Map exporterMetadata; - private int flagChangeCallCounter = 0; - private boolean flagChanged404 = false; - private List requests = new ArrayList<>(); - - // Dispatcher is the configuration of the mock server to test the provider. - final Dispatcher dispatcher = new Dispatcher() { - @NotNull @SneakyThrows - @Override - public MockResponse dispatch(RecordedRequest request) { - requests.add(request); - assert request.getPath() != null; - if (request.getPath().contains("fail_500")) { - return new MockResponse().setResponseCode(500); - } - if (request.getPath().contains("fail_401")) { - return new MockResponse().setResponseCode(401); - } - if (request.getPath().startsWith("/v1/feature/")) { - String flagName = request.getPath().replace("/v1/feature/", "").replace("/eval", ""); - return new MockResponse().setResponseCode(200).setBody(readMockResponse(flagName + ".json")); - } - if (request.getPath().startsWith("/v1/data/collector")) { - String requestBody = request.getBody().readString(StandardCharsets.UTF_8); - Map map = requestMapper.readValue(requestBody, Map.class); - publishEventsRequestsReceived = ((List) map.get("events")).size(); - exporterMetadata = ((Map) map.get("meta")); - if (requestBody.contains("fail_500") && publishEventsRequestsReceived == 1) { - return new MockResponse().setResponseCode(502); - } - return new MockResponse().setResponseCode(200); - } - if (request.getPath().contains("/v1/flag/change")) { - flagChangeCallCounter++; - if (flagChanged404) { - return new MockResponse().setResponseCode(404); - } - if (flagChangeCallCounter == 2) { - return new MockResponse().setResponseCode(200).setHeader(HttpHeaders.ETAG, "7891011"); - } - if (request.getHeader(HttpHeaders.IF_NONE_MATCH) != null - && (request.getHeader(HttpHeaders.IF_NONE_MATCH).equals("123456") - || request.getHeader(HttpHeaders.IF_NONE_MATCH).equals("7891011"))) { - return new MockResponse().setResponseCode(304); - } - - return new MockResponse().setResponseCode(200).setHeader(HttpHeaders.ETAG, "123456"); - } - return new MockResponse().setResponseCode(404); - } - }; private MockWebServer server; + private GoffApiMock goffAPIMock; private HttpUrl baseUrl; - private MutableContext evaluationContext; - - private static final ImmutableMetadata defaultMetadata = ImmutableMetadata.builder() - .addString("pr_link", "https://github.com/thomaspoignant/go-feature-flag/pull/916") - .addInteger("version", 1) - .build(); - private String testName; @BeforeEach void beforeEach(TestInfo testInfo) throws IOException { - this.flagChangeCallCounter = 0; - this.flagChanged404 = false; - this.testName = testInfo.getDisplayName(); this.server = new MockWebServer(); - this.server.setDispatcher(dispatcher); + goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.DEFAULT); + this.server.setDispatcher(goffAPIMock.dispatcher); this.server.start(); - this.baseUrl = server.url(""); - - this.evaluationContext = new MutableContext(); - this.evaluationContext.setTargetingKey("d45e303a-38c2-11ed-a261-0242ac120002"); - this.evaluationContext.add("email", "john.doe@gofeatureflag.org"); - this.evaluationContext.add("firstname", "john"); - this.evaluationContext.add("lastname", "doe"); - this.evaluationContext.add("anonymous", false); - this.evaluationContext.add("professional", true); - this.evaluationContext.add("rate", 3.14); - this.evaluationContext.add("age", 30); - this.evaluationContext.add( - "company_info", new MutableStructure().add("name", "my_company").add("size", 120)); - List labels = new ArrayList<>(); - labels.add(new Value("pro")); - labels.add(new Value("beta")); - this.evaluationContext.add("labels", labels); + 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; - this.baseUrl = null; - OpenFeatureAPI.getInstance().shutdown(); + baseUrl = null; } - @SneakyThrows - @Test - void getMetadata_validate_name() { - assertEquals( - "GO Feature Flag Provider", - new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()) - .getMetadata() - .getName()); - } + @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_null() { + assertThrows(InvalidOptions.class, () -> new GoFeatureFlagProvider(null)); + } - @Test - void constructor_options_empty() { - assertThrows( - InvalidOptions.class, - () -> new GoFeatureFlagProvider( - GoFeatureFlagProviderOptions.builder().build())); - } + @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_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_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())); - } + @SneakyThrows + @Test + void constructor_options_valid_endpoint() { + assertDoesNotThrow(() -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint("http://localhost:1031") + .build())); + } - @SneakyThrows - @Test - void client_test() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = "clientTest"; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - Boolean value = client.getBooleanValue("bool_targeting_match", false); - assertEquals(Boolean.FALSE, value, "should evaluate to default value without context"); - FlagEvaluationDetails booleanFlagEvaluationDetails = - client.getBooleanDetails("bool_targeting_match", false, new ImmutableContext()); - assertEquals( - Boolean.FALSE, - booleanFlagEvaluationDetails.getValue(), - "should evaluate to default value with empty context"); - assertEquals( - ErrorCode.TARGETING_KEY_MISSING, - booleanFlagEvaluationDetails.getErrorCode(), - "should evaluate to default value with empty context"); - booleanFlagEvaluationDetails = - client.getBooleanDetails("bool_targeting_match", false, new ImmutableContext("targetingKey")); - assertEquals(Boolean.TRUE, booleanFlagEvaluationDetails.getValue(), "should evaluate with a valid context"); - } + @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())); + } - @SneakyThrows - @Test - void should_throw_an_error_if_endpoint_not_available() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = client.getBooleanDetails("fail_500", false, this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(false) - .flagKey("fail_500") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.GENERAL) - .errorMessage("unknown error while retrieving flag fail_500") - .build(); - assertEquals(want, got); - } + @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())); + } - @SneakyThrows - @Test - void should_throw_an_error_if_invalid_api_key() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .apiKey("invalid_api_key") - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = client.getBooleanDetails("fail_401", false, this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(false) - .flagKey("fail_401") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.GENERAL) - .errorMessage("authentication/authorization error") - .build(); - assertEquals(want, got); + @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())); + } } - @SneakyThrows - @Test - void should_throw_an_error_if_flag_does_not_exists() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = client.getBooleanDetails("flag_not_found", false, this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(false) - .flagKey("flag_not_found") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.FLAG_NOT_FOUND) - .errorMessage("Flag flag_not_found was not found in your configuration") - .build(); - assertEquals(want, got); - } + @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()); + } - @SneakyThrows - @Test - void should_throw_an_error_if_we_expect_a_boolean_and_got_another_type() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = client.getBooleanDetails("string_key", false, this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(false) - .flagKey("string_key") - .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 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()); + } - @SneakyThrows - @Test - void should_resolve_a_valid_boolean_flag_with_TARGETING_MATCH_reason() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(true) - .variant("True") - .flagKey("bool_targeting_match") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(defaultMetadata) - .build(); - assertEquals(want, got); - } + @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)); + } + } - @SneakyThrows - @Test - void should_resolve_a_valid_boolean_flag_with_TARGETING_MATCH_reason_without_error_code_in_payload() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = - client.getBooleanDetails("bool_targeting_match_no_error_code", false, this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(true) - .variant("True") - .flagKey("bool_targeting_match_no_error_code") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(defaultMetadata) - .build(); - assertEquals(want, got); - } + @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)); + } + } - @SneakyThrows - @Test - void should_resolve_a_valid_boolean_flag_with_TARGETING_MATCH_reason_cache_disabled() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .enableCache(false) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(true) - .variant("True") - .flagKey("bool_targeting_match") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(defaultMetadata) - .build(); - assertEquals(want, got); - got = client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - assertEquals(want, got); - } + @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); - @SneakyThrows - @Test - void should_resolve_from_cache() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(true) - .variant("True") - .flagKey("bool_targeting_match") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(defaultMetadata) - .build(); - assertEquals(want, got); - got = client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - FlagEvaluationDetails want2 = FlagEvaluationDetails.builder() - .value(true) - .variant("True") - .flagKey("bool_targeting_match") - .reason(Reason.CACHED.name()) - .flagMetadata(defaultMetadata) - .build(); - assertEquals(want2, got); - } + 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); + } - @SneakyThrows - @Test - void should_resolve_from_cache_max_size() { - Caffeine caffeine = Caffeine.newBuilder().maximumSize(1); - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .cacheConfig(caffeine) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(true) - .variant("True") - .flagKey("bool_targeting_match") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(defaultMetadata) - .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); + } - got = client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - FlagEvaluationDetails want2 = FlagEvaluationDetails.builder() - .value(true) - .variant("True") - .flagKey("bool_targeting_match") - .reason(Reason.CACHED.name()) - .flagMetadata(defaultMetadata) - .build(); - assertEquals(want2, 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); + } - FlagEvaluationDetails gotStr = - client.getStringDetails("string_key", "defaultValue", this.evaluationContext); - FlagEvaluationDetails wantStr = FlagEvaluationDetails.builder() - .value("CC0000") - .variant("True") - .flagKey("string_key") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(defaultMetadata) - .build(); - assertEquals(wantStr, gotStr); + @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); + } - gotStr = client.getStringDetails("string_key", "defaultValue", this.evaluationContext); - FlagEvaluationDetails wantStr2 = FlagEvaluationDetails.builder() - .value("CC0000") - .variant("True") - .flagKey("string_key") - .reason(Reason.CACHED.name()) - .flagMetadata(defaultMetadata) - .build(); - assertEquals(wantStr2, gotStr); - } + @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); + } - @SneakyThrows - @Test - void should_return_custom_reason_if_returned_by_relay_proxy() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = client.getBooleanDetails("unknown_reason", false, this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(true) - .variant("True") - .flagKey("unknown_reason") - .reason("CUSTOM_REASON") - .flagMetadata(defaultMetadata) - .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); + } - @SneakyThrows - @Test - void should_use_boolean_default_value_if_the_flag_is_disabled() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = client.getBooleanDetails("disabled", false, this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(false) - .variant("defaultSdk") - .flagKey("disabled") - .reason(Reason.DISABLED.name()) - .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); + } - @SneakyThrows - @Test - void should_throw_an_error_if_we_expect_a_string_and_got_another_type() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = - client.getStringDetails("bool_targeting_match", "defaultValue", this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value("defaultValue") - .flagKey("bool_targeting_match") - .reason(Reason.ERROR.name()) - .errorMessage( - "Flag value bool_targeting_match had unexpected type class java.lang.Boolean, expected class java.lang.String.") - .errorCode(ErrorCode.TYPE_MISMATCH) - .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); + } - @SneakyThrows - @Test - void should_resolve_a_valid_string_flag_with_TARGETING_MATCH_reason() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = - client.getStringDetails("string_key", "defaultValue", this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value("CC0000") - .flagKey("string_key") - .flagMetadata(defaultMetadata) - .variant("True") - .reason(Reason.TARGETING_MATCH.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); - @SneakyThrows - @Test - void should_use_string_default_value_if_the_flag_is_disabled() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = client.getStringDetails("disabled", "defaultValue", this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value("defaultValue") - .variant("defaultSdk") - .flagKey("disabled") - .reason(Reason.DISABLED.name()) - .build(); - assertEquals(want, got); - } + 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); - @SneakyThrows - @Test - void should_throw_an_error_if_we_expect_a_integer_and_got_another_type() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = - client.getIntegerDetails("bool_targeting_match", 200, this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(200) - .flagKey("bool_targeting_match") - .reason(Reason.ERROR.name()) - .errorMessage( - "Flag value bool_targeting_match had unexpected type class java.lang.Boolean, expected class java.lang.Integer.") - .errorCode(ErrorCode.TYPE_MISMATCH) - .build(); - assertEquals(want, got); - } + // 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); + } - @SneakyThrows - @Test - void should_resolve_a_valid_integer_flag_with_TARGETING_MATCH_reason() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = client.getIntegerDetails("integer_key", 200, this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(100) - .reason(Reason.TARGETING_MATCH.name()) - .variant("True") - .flagMetadata(defaultMetadata) - .flagKey("integer_key") - .build(); - assertEquals(want, got); - } + @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()); - @SneakyThrows - @Test - void should_use_integer_default_value_if_the_flag_is_disabled() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = client.getIntegerDetails("disabled", 200, this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(200) - .variant("defaultSdk") - .flagKey("disabled") - .reason(Reason.DISABLED.name()) - .build(); - assertEquals(want, got); - } + 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()); + } - @SneakyThrows - @Test - void should_throw_an_error_if_we_expect_a_integer_and_double_type() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = client.getIntegerDetails("double_key", 200, this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(200) - .flagKey("double_key") - .reason(Reason.ERROR.name()) - .errorMessage( - "Flag value double_key had unexpected type class java.lang.Double, expected class java.lang.Integer.") - .errorCode(ErrorCode.TYPE_MISMATCH) - .build(); - assertEquals(want, got); - } + @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); + }); - @SneakyThrows - @Test - void should_resolve_a_valid_double_flag_with_TARGETING_MATCH_reason() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = client.getDoubleDetails("double_key", 200.20, this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(100.25) - .reason(Reason.TARGETING_MATCH.name()) - .variant("True") - .flagMetadata(defaultMetadata) - .flagKey("double_key") - .build(); - assertEquals(want, got); - } + 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); + } - @SneakyThrows - @Test - void should_resolve_a_valid_double_flag_with_TARGETING_MATCH_reason_if_value_point_zero() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = - client.getDoubleDetails("double_point_zero_key", 200.20, this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(100.0) - .reason(Reason.TARGETING_MATCH.name()) - .variant("True") - .flagMetadata(defaultMetadata) - .flagKey("double_point_zero_key") - .build(); - assertEquals(want, got); - } + @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)); + } - @SneakyThrows - @Test - void should_use_double_default_value_if_the_flag_is_disabled() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = client.getDoubleDetails("disabled", 200.23, this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(200.23) - .variant("defaultSdk") - .flagKey("disabled") - .reason(Reason.DISABLED.name()) - .build(); - assertEquals(want, got); - } + @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); + }); - @SneakyThrows - @Test - void should_resolve_a_valid_value_flag_with_TARGETING_MATCH_reason() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = client.getObjectDetails("object_key", new Value(), this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(new Value(new MutableStructure() - .add("test", "test1") - .add("test2", false) - .add("test5", new Value()) - .add("test3", 123.3) - .add("test4", 1))) - .flagKey("object_key") - .reason(Reason.TARGETING_MATCH.name()) - .variant("True") - .flagMetadata(defaultMetadata) - .flagKey("object_key") - .build(); - assertEquals(want, got); - } + client.getBooleanDetails("bool_targeting_match", false, TestUtils.defaultEvaluationContext); + Thread.sleep(300L); + assertFalse(configurationChangedCalled.get()); + } - @SneakyThrows - @Test - void should_wrap_into_value_if_wrong_type() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = client.getObjectDetails("string_key", new Value(), this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(new Value("CC0000")) - .reason(Reason.TARGETING_MATCH.name()) - .variant("True") - .flagMetadata(defaultMetadata) - .flagKey("string_key") - .build(); - assertEquals(want, got); - } + @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); + } + } - @SneakyThrows - @Test - void should_throw_an_error_if_no_targeting_key() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = - client.getObjectDetails("string_key", new Value("CC0000"), new MutableContext()); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(new Value("CC0000")) - .flagKey("string_key") - .errorCode(ErrorCode.TARGETING_KEY_MISSING) - .reason(Reason.ERROR.name()) - .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); + } + } } - @SneakyThrows - @Test - void should_resolve_a_valid_value_flag_with_a_list() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = client.getObjectDetails("list_key", new Value(), this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(new Value(new ArrayList<>(Arrays.asList( - new Value("test"), - new Value("test1"), - new Value("test2"), - new Value("false"), - new Value("test3"))))) - .reason(Reason.TARGETING_MATCH.name()) - .variant("True") - .flagMetadata(defaultMetadata) - .flagKey("list_key") - .build(); - assertEquals(want, got); - } + @Nested + class DataCollectorHook { + @DisplayName("Should send the evaluation information to the data collector") + @SneakyThrows + @Test + void shouldSendTheEvaluationInformationToTheDataCollector() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .flushIntervalMs(150L) + .maxPendingEvents(100) + .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(250L); + assertEquals(1, goffAPIMock.getCollectorRequestsHistory().size()); + } - @SneakyThrows - @Test - void should_not_fail_if_receive_an_unknown_field_in_response() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = client.getBooleanDetails("unknown_field", false, this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(true) - .variant("True") - .flagKey("unknown_field") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata(defaultMetadata) - .build(); - assertEquals(want, got); - } + @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()); + } - @SneakyThrows - @Test - void should_not_fail_if_no_metadata_in_response() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = client.getBooleanDetails("no_metadata", false, this.evaluationContext); - FlagEvaluationDetails want = FlagEvaluationDetails.builder() - .value(true) - .variant("True") - .flagKey("no_metadata") - .reason(Reason.TARGETING_MATCH.name()) - .build(); - assertEquals(want, got); - } + @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()); + } - @SneakyThrows - @Test - void should_publish_events() { - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .enableCache(true) - .flushIntervalMs(150L) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - client.getBooleanDetails("fail_500", false, this.evaluationContext); - Thread.sleep(170L); - assertEquals(1, publishEventsRequestsReceived, "We should have 1 event waiting to be publish"); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - Thread.sleep(50L); - assertEquals( - 1, - publishEventsRequestsReceived, - "Nothing should be added in the waiting to be published list (stay to 1)"); - Thread.sleep(100); - assertEquals(3, publishEventsRequestsReceived, "We pass the flush interval, we should have 3 events"); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - Thread.sleep(150); - assertEquals( - 6, - publishEventsRequestsReceived, - "we have call 6 time more, so we should consider only those new calls"); + @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()); + } } - @SneakyThrows - @Test - void should_publish_events_context_without_anonymous() { - this.evaluationContext = new MutableContext("d45e303a-38c2-11ed-a261-0242ac120002"); - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .enableCache(true) - .flushIntervalMs(50L) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - client.getBooleanDetails("fail_500", false, this.evaluationContext); - Thread.sleep(100L); - assertEquals(1, publishEventsRequestsReceived, "We should have 1 event waiting to be publish"); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - Thread.sleep(100); - assertEquals(3, publishEventsRequestsReceived, "We pass the flush interval, we should have 3 events"); - } + @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); - @SneakyThrows - @Test - void should_not_get_cached_value_if_flag_configuration_changed() { - this.evaluationContext = new MutableContext("d45e303a-38c2-11ed-a261-0242ac120002"); - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .disableDataCollection(true) - .enableCache(true) - .flagChangePollingIntervalMs(50L) - .disableDataCollection(true) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - FlagEvaluationDetails got = - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - assertEquals(Reason.TARGETING_MATCH.name(), got.getReason()); - got = client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - assertEquals(Reason.CACHED.name(), got.getReason()); - got = client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - assertEquals(Reason.CACHED.name(), got.getReason()); - Thread.sleep(200L); - got = client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - assertEquals(Reason.TARGETING_MATCH.name(), got.getReason()); - } + 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")); - @SneakyThrows - @Test - void should_stop_calling_flag_change_if_receive_404() { - this.flagChanged404 = true; - this.evaluationContext = new MutableContext("d45e303a-38c2-11ed-a261-0242ac120002"); - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .enableCache(true) - .flagChangePollingIntervalMs(10L) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - Thread.sleep(150L); - assertEquals(1, this.flagChangeCallCounter); - } + Map want = new HashMap<>(); + want.put("context", context); + assertEquals(want, got); + } - @SneakyThrows - @Test - void should_send_exporter_metadata() { - Map customExporterMetadata = new HashMap<>(); - customExporterMetadata.put("version", "1.0.0"); - customExporterMetadata.put("intTest", 1234567890); - customExporterMetadata.put("doubleTest", 12345.67890); - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .enableCache(true) - .flushIntervalMs(150L) - .exporterMetadata(customExporterMetadata) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - Thread.sleep(150); + @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("version", "1.0.0"); - want.put("intTest", 1234567890); - want.put("doubleTest", 12345.6789); - want.put("openfeature", true); - want.put("provider", "java"); - assertEquals( - want, - this.exporterMetadata, - "we should have the exporter metadata in the last event sent to the data collector"); + Map want = new HashMap<>(); + want.put("context", context); + assertEquals(want, got); + } } - @SneakyThrows - @Test - void should_add_exporter_metadata_into_evaluation_call() { - Map customExporterMetadata = new HashMap<>(); - customExporterMetadata.put("version", "1.0.0"); - customExporterMetadata.put("intTest", 1234567890); - customExporterMetadata.put("doubleTest", 12345.67890); - GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() - .endpoint(this.baseUrl.toString()) - .timeout(1000) - .enableCache(true) - .flushIntervalMs(150L) - .exporterMetadata(customExporterMetadata) - .build()); - String providerName = this.testName; - OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g); - Client client = OpenFeatureAPI.getInstance().getClient(providerName); - client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext); - ObjectMapper objectMapper = new ObjectMapper(); - String want = objectMapper - .readValue( - "{ \"user\" : { \"key\" : \"d45e303a-38c2-11ed-a261-0242ac120002\", " - + "\"anonymous\" : false, \"custom\" : { \"firstname\" : \"john\", \"gofeatureflag\" : { " - + "\"exporterMetadata\" : { \"openfeature\" : true, \"provider\" : \"java\", \"doubleTest\" : 12345.6789, " - + "\"intTest\" : 1234567890, \"version\" : \"1.0.0\" } }, \"rate\" : 3.14, \"targetingKey\" : " - + "\"d45e303a-38c2-11ed-a261-0242ac120002\", \"company_info\" : { \"size\" : 120, \"name\" : \"my_company\" }, " - + "\"email\" : \"john.doe@gofeatureflag.org\", \"age\" : 30, \"lastname\" : \"doe\", \"professional\" : true, " - + "\"labels\" : [ \"pro\", \"beta\" ] } }, \"defaultValue\" : false }", - Object.class) - .toString(); - String got = objectMapper - .readValue(this.requests.get(0).getBody().readString(Charset.defaultCharset()), Object.class) - .toString(); - assertEquals(want, got, "we should have the exporter metadata in the last event sent to the data collector"); + @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); + } } - private String readMockResponse(String filename) throws Exception { - URL url = getClass().getClassLoader().getResource("mock_responses/" + filename); - assert url != null; - byte[] bytes = Files.readAllBytes(Paths.get(url.toURI())); - return new String(bytes); + @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()); + } } } diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/TestUtils.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/TestUtils.java new file mode 100644 index 000000000..d2b8e44f7 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/TestUtils.java @@ -0,0 +1,42 @@ +package dev.openfeature.contrib.providers.gofeatureflag; + +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.MutableStructure; +import dev.openfeature.sdk.Value; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +public class TestUtils { + public static final MutableContext defaultEvaluationContext = getDefaultEvaluationContext(); + + private static MutableContext getDefaultEvaluationContext() { + MutableContext context = new MutableContext(); + context.setTargetingKey("d45e303a-38c2-11ed-a261-0242ac120002"); + context.add("email", "john.doe@gofeatureflag.org"); + context.add("firstname", "john"); + context.add("lastname", "doe"); + context.add("anonymous", false); + context.add("professional", true); + context.add("rate", 3.14); + context.add("age", 30); + context.add( + "company_info", new MutableStructure().add("name", "my_company").add("size", 120)); + List labels = new ArrayList<>(); + labels.add(new Value("pro")); + labels.add(new Value("beta")); + context.add("labels", labels); + return context; + } + + public static String readMockResponse(String dir, String filename) throws Exception { + URL url = TestUtils.class.getClassLoader().getResource(dir + filename); + if (url == null) { + throw new IllegalArgumentException("File not found: " + dir + filename); + } + byte[] bytes = Files.readAllBytes(Paths.get(url.toURI())); + return new String(bytes); + } +} 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 new file mode 100644 index 000000000..63aeaa379 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/GoFeatureFlagApiTest.java @@ -0,0 +1,712 @@ +package dev.openfeature.contrib.providers.gofeatureflag.api; + +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 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.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.util.Const; +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; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +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; + +@Slf4j +public class GoFeatureFlagApiTest { + private MockWebServer server; + private GoffApiMock goffAPIMock; + private HttpUrl baseUrl; + + @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(""); + } + + @AfterEach + void afterEach() throws IOException { + this.server.close(); + this.server = null; + baseUrl = null; + } + + @Nested + class Constructor { + @SneakyThrows + @DisplayName("should throw invalid options if endpoint missing") + @Test + public void shouldThrowInvalidOptionsIfEndpointMissing() { + val options = GoFeatureFlagProviderOptions.builder().build(); + assertThrows( + InvalidEndpoint.class, + () -> GoFeatureFlagApi.builder().options(options).build()); + } + + @SneakyThrows + @DisplayName("should throw invalid options if endpoint empty") + @Test + public void shouldThrowInvalidOptionsIfEndpointEmpty() { + val options = GoFeatureFlagProviderOptions.builder().endpoint("").build(); + assertThrows( + InvalidEndpoint.class, + () -> GoFeatureFlagApi.builder().options(options).build()); + } + + @SneakyThrows + @DisplayName("should throw invalid options if endpoint invalid") + @Test + public void shouldThrowInvalidOptionsIfEndpointInvalid() { + val options = + GoFeatureFlagProviderOptions.builder().endpoint("ccccc").build(); + assertThrows( + InvalidEndpoint.class, + () -> GoFeatureFlagApi.builder().options(options).build()); + } + } + + @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()); + } + + @SneakyThrows + @DisplayName("request should have an api key") + @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(); + 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")); + } + + @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"); + } + + @SneakyThrows + @DisplayName("request should have the default headers") + @Test + public void requestShouldHaveDefaultHeaders() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.evaluateFlag("flag-key", TestUtils.defaultEvaluationContext); + + val got = server.takeRequest().getHeaders(); + assertEquals("application/json; charset=utf-8", got.get(Const.HTTP_HEADER_CONTENT_TYPE)); + } + + @SneakyThrows + @DisplayName("should error if timeout is reached") + @Test + public void shouldErrorIfTimeoutIsReached() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .timeout(200) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + assertThrows(GeneralError.class, () -> api.evaluateFlag("timeout", TestUtils.defaultEvaluationContext)); + } + + @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)); + } + + @SneakyThrows + @DisplayName("should error if response is a 403") + @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)); + } + + @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)); + } + + @SneakyThrows + @DisplayName("should error if response is a 400") + @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)); + } + + @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)); + } + + @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); + } + } + + @Nested + class SendEventToDataCollector { + @SneakyThrows + @DisplayName("request should have an api key") + @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(); + + List events = new ArrayList<>(); + Map exporterMetadata = new HashMap<>(); + api.sendEventToDataCollector(events, exporterMetadata); + + val want = Const.BEARER_TOKEN + apiKey; + assertEquals(want, server.takeRequest().getHeader("Authorization")); + } + + @SneakyThrows + @DisplayName("request should call the collector endpoint") + @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); + + val want = "/v1/data/collector"; + assertEquals(want, server.takeRequest().getPath()); + } + + @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(); + List events = new ArrayList<>(); + Map exporterMetadata = new HashMap<>(); + api.sendEventToDataCollector(events, exporterMetadata); + assertNull(server.takeRequest().getHeader("Authorization")); + } + + @SneakyThrows + @DisplayName("request should have the default headers") + @Test + public void requestShouldHaveDefaultHeaders() { + 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); + + val got = server.takeRequest().getHeaders(); + assertEquals("application/json; charset=utf-8", got.get(Const.HTTP_HEADER_CONTENT_TYPE)); + } + + @SneakyThrows + @DisplayName("request should have events in the body") + @Test + public void requestShouldHaveTheEvaluationContextInTheBody() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + List events = new ArrayList<>(); + events.add(FeatureEvent.builder() + .key("xxx") + .creationDate(1617970547L) + .contextKind("anonymousUser") + .kind("feature") + .userKey("ABCD") + .variation("enabled") + .value(true) + .defaultValue(false) + .build()); + + val trackingEvent = new MutableTrackingEventDetails(); + trackingEvent.add("toto", 123); + events.add(TrackingEvent.builder() + .creationDate(1617970548L) + .evaluationContext(TestUtils.defaultEvaluationContext.asObjectMap()) + .key("xxx") + .kind("tracking") + .contextKind("anonymousUser") + .userKey("ABCD") + .trackingEventDetails(trackingEvent.asObjectMap()) + .build()); + + Map exporterMetadata = new HashMap<>(); + exporterMetadata.put("provider", "go-feature-flag"); + exporterMetadata.put("intValue", 1); + api.sendEventToDataCollector(events, exporterMetadata); + + val wantStr = TestUtils.readMockResponse("api_events/", "valid-response.json"); + 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"); + } + + @SneakyThrows + @DisplayName("request should return a an error if 401 received") + @Test + public void requestShouldHaveReturn401() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + List events = new ArrayList<>(); + + Map exporterMetadata = new HashMap<>(); + exporterMetadata.put("error", 401); + assertThrows(GeneralError.class, () -> api.sendEventToDataCollector(events, exporterMetadata)); + } + + @SneakyThrows + @DisplayName("request should return a an error if 403 received") + @Test + public void requestShouldHaveReturn403() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + List events = new ArrayList<>(); + + Map exporterMetadata = new HashMap<>(); + exporterMetadata.put("error", 403); + assertThrows(GeneralError.class, () -> api.sendEventToDataCollector(events, exporterMetadata)); + } + + @SneakyThrows + @DisplayName("request should return a an error if 400 received") + @Test + public void requestShouldHaveReturn400() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + List events = new ArrayList<>(); + + Map exporterMetadata = new HashMap<>(); + exporterMetadata.put("error", 400); + assertThrows(GeneralError.class, () -> api.sendEventToDataCollector(events, exporterMetadata)); + } + + @SneakyThrows + @DisplayName("should return an error if a JsonException is thrown") + @Test + public void requestReturnAnErrorIfAJsonExceptionIsThrown() { + class CircularRef { + public CircularRef ref; + } + + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + List events = new ArrayList<>(); + Map exporterMetadata = new HashMap<>(); + exporterMetadata.put("error", 400); + CircularRef circularRef = new CircularRef(); + circularRef.ref = circularRef; + exporterMetadata.put("circularRef", circularRef); + assertThrows( + ImpossibleToSendEventsException.class, + () -> api.sendEventToDataCollector(events, exporterMetadata)); + } + } + + @Nested + class FlagConfiguration { + @SneakyThrows + @DisplayName("request should have an api key") + @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(); + api.retrieveFlagConfiguration(null, Collections.emptyList()); + + val want = Const.BEARER_TOKEN + apiKey; + assertEquals(want, server.takeRequest().getHeader("Authorization")); + } + + @SneakyThrows + @DisplayName("request should call the configuration endpoint") + @Test + public void requestShouldCallTheConfigurationEndpoint() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.retrieveFlagConfiguration(null, Collections.emptyList()); + + val want = "/v1/flag/configuration"; + assertEquals(want, server.takeRequest().getPath()); + } + + @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.retrieveFlagConfiguration(null, Collections.emptyList()); + assertNull(server.takeRequest().getHeader("Authorization")); + } + + @SneakyThrows + @DisplayName("request should have the default headers") + @Test + public void requestShouldHaveDefaultHeaders() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.retrieveFlagConfiguration(null, Collections.emptyList()); + val got = server.takeRequest().getHeaders(); + assertEquals("application/json; charset=utf-8", got.get(Const.HTTP_HEADER_CONTENT_TYPE)); + } + + @SneakyThrows + @DisplayName("request should have an if-none-match header if a etag is provided") + @Test + public void requestShouldHaveAnIfNoneMatchHeaderIfAETagIsProvided() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.retrieveFlagConfiguration("xxxx", Collections.emptyList()); + val got = server.takeRequest().getHeaders(); + assertEquals("xxxx", got.get(Const.HTTP_HEADER_IF_NONE_MATCH)); + } + + @SneakyThrows + @DisplayName("request should have flags in body if flags provided") + @Test + public void requestShouldHaveFlagsInBodyIfFlagsProvided() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + api.retrieveFlagConfiguration("xxxx", List.of("flag1", "flag2")); + val gotStr = goffAPIMock.getLastRequestBody(); + val wantStr = "{\"flags\":[\"flag1\",\"flag2\"]}"; + ObjectMapper objectMapper = new ObjectMapper(); + Object want = objectMapper.readTree(wantStr); + Object got = objectMapper.readTree(gotStr); + assertEquals(want, got, "The JSON strings are not equal"); + } + + @SneakyThrows + @DisplayName("request should return a an error if 401 received") + @Test + public void requestShouldHaveReturn401() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + assertThrows( + ImpossibleToRetrieveConfiguration.class, + () -> api.retrieveFlagConfiguration("401", Collections.emptyList())); + } + + @SneakyThrows + @DisplayName("request should return a an error if 403 received") + @Test + public void requestShouldHaveReturn403() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + assertThrows( + ImpossibleToRetrieveConfiguration.class, + () -> api.retrieveFlagConfiguration("403", Collections.emptyList())); + } + + @SneakyThrows + @DisplayName("request should return a an error if 400 received") + @Test + public void requestShouldHaveReturn400() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + assertThrows( + ImpossibleToRetrieveConfiguration.class, + () -> api.retrieveFlagConfiguration("400", Collections.emptyList())); + } + + @SneakyThrows + @DisplayName("request should return a an error if 500 received") + @Test + public void requestShouldHaveReturn500() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + assertThrows( + ImpossibleToRetrieveConfiguration.class, + () -> api.retrieveFlagConfiguration("500", Collections.emptyList())); + } + + @SneakyThrows + @DisplayName("request should return a an error if 404 received") + @Test + public void requestShouldHaveReturn404() { + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + assertThrows( + FlagConfigurationEndpointNotFound.class, + () -> api.retrieveFlagConfiguration("404", Collections.emptyList())); + } + + @SneakyThrows + @DisplayName("request should return a valid FlagConfigResponse if 200 received") + @Test + public void requestShouldHaveReturn200SimpleFlags() { + val s = new MockWebServer(); + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.SIMPLE_CONFIG); + s.setDispatcher(goffAPIMock.dispatcher); + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + val got = api.retrieveFlagConfiguration("valid", Collections.emptyList()); + 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); + val want = FlagConfigResponse.builder() + .flags(flags) + .etag("valid-flag-config.json") + .lastUpdated(new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz") + .parse("Wed, 21 Oct 2015 07:28:00 GMT")) + .evaluationContextEnrichment(evaluationContextEnrichment) + .build(); + assertEquals(want, got); + } + + @SneakyThrows + @DisplayName("request should not return last modified date if invalid header") + @Test + public void requestShouldNotReturnLastModifiedDateIfInvalidHeader() { + val s = new MockWebServer(); + val goffAPIMock = new GoffApiMock(GoffApiMock.MockMode.SIMPLE_CONFIG); + s.setDispatcher(goffAPIMock.dispatcher); + val options = GoFeatureFlagProviderOptions.builder() + .endpoint(s.url("").toString()) + .build(); + val api = GoFeatureFlagApi.builder().options(options).build(); + val got = api.retrieveFlagConfiguration("invalid-lastmodified-header", Collections.emptyList()); + 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); + val want = FlagConfigResponse.builder() + .flags(flags) + .etag("valid-flag-config.json") + .lastUpdated(null) + .evaluationContextEnrichment(evaluationContextEnrichment) + .build(); + assertEquals(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 new file mode 100644 index 000000000..b5aa5f0e7 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/api/bean/OfrepResponseTest.java @@ -0,0 +1,78 @@ +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/hook/DataCollectorHookTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookTest.java new file mode 100644 index 000000000..b947ed5f8 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookTest.java @@ -0,0 +1,23 @@ +package dev.openfeature.contrib.providers.gofeatureflag.hook; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; + +public class DataCollectorHookTest { + @SneakyThrows + @Test + void shouldErrorIfNoOptionsProvided() { + assertThrows(InvalidOptions.class, () -> new DataCollectorHook(null)); + } + + @SneakyThrows + @Test + void shouldErrorIfNoEventsPublisherProvided() { + assertThrows( + InvalidOptions.class, + () -> new DataCollectorHook(DataCollectorHookOptions.builder().build())); + } +} 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 new file mode 100644 index 000000000..523bb8d2d --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/hook/EnrichEvaluationContextHookTest.java @@ -0,0 +1,54 @@ +package dev.openfeature.contrib.providers.gofeatureflag.hook; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.openfeature.contrib.providers.gofeatureflag.TestUtils; +import dev.openfeature.sdk.FlagValueType; +import dev.openfeature.sdk.HookContext; +import java.util.Collections; +import java.util.Optional; +import lombok.SneakyThrows; +import lombok.val; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class EnrichEvaluationContextHookTest { + @DisplayName("Should return nothing if no options provided") + @SneakyThrows + @Test + void shouldReturnNothingIfNoOptionsProvided() { + EnrichEvaluationContextHook hook = new EnrichEvaluationContextHook(null); + val res = hook.before(null, null); + assertEquals(Optional.empty(), res); + } + + @DisplayName("Should return the same context if no metadata provided") + @SneakyThrows + @Test + void shouldReturnSameContextIfNoMetadataProvided() { + EnrichEvaluationContextHook hook = new EnrichEvaluationContextHook(null); + val hookContext = HookContext.builder() + .ctx(TestUtils.defaultEvaluationContext) + .flagKey("testFlagKey") + .type(FlagValueType.BOOLEAN) + .defaultValue("default") + .build(); + val res = hook.before(hookContext, null); + assertEquals(Optional.of(hookContext.getCtx()), res); + } + + @DisplayName("Should return the same context if no metadata provided") + @SneakyThrows + @Test + void shouldReturnSameContextIfMetadataEmpty() { + EnrichEvaluationContextHook hook = new EnrichEvaluationContextHook(Collections.emptyMap()); + val hookContext = HookContext.builder() + .ctx(TestUtils.defaultEvaluationContext) + .flagKey("testFlagKey") + .type(FlagValueType.BOOLEAN) + .defaultValue("default") + .build(); + val res = hook.before(hookContext, null); + assertEquals(Optional.of(hookContext.getCtx()), res); + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/ConcurrentUtilTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/ConcurrentUtilTest.java new file mode 100644 index 000000000..0fa78e734 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/ConcurrentUtilTest.java @@ -0,0 +1,52 @@ +package dev.openfeature.contrib.providers.gofeatureflag.util; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.Test; + +class ConcurrentUtilTest { + @Test + void testShutdownAndAwaitTermination_NormalShutdown() { + ExecutorService executor = Executors.newSingleThreadExecutor(); + executor.submit(() -> { + try { + Thread.sleep(100); // Simulate a short task + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + ConcurrentUtil.shutdownAndAwaitTermination(executor, 1); + + assertTrue(executor.isShutdown()); + } + + @Test + void testShutdownAndAwaitTermination_ForcedShutdown() { + ExecutorService executor = Executors.newSingleThreadExecutor(); + executor.submit(() -> { + try { + Thread.sleep(5000); // Simulate a long-running task + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + ConcurrentUtil.shutdownAndAwaitTermination(executor, 1); + + assertTrue(executor.isShutdown()); + } + + @Test + void testShutdownAndAwaitTermination_InterruptedException() { + ExecutorService executor = Executors.newSingleThreadExecutor(); + Thread.currentThread().interrupt(); // Simulate an interruption + + ConcurrentUtil.shutdownAndAwaitTermination(executor, 1); + + assertTrue(Thread.interrupted()); // Verify the interrupt status is preserved + assertTrue(executor.isShutdown()); + } +} 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 new file mode 100644 index 000000000..a19284ccc --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/EvaluationContextUtilTest.java @@ -0,0 +1,35 @@ +package dev.openfeature.contrib.providers.gofeatureflag.util; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openfeature.sdk.MutableContext; +import org.junit.jupiter.api.Test; + +class EvaluationContextUtilTest { + + @Test + void testIsAnonymousUser_WhenContextIsNull_ShouldReturnTrue() { + assertTrue(EvaluationContextUtil.isAnonymousUser(null), "Expected true when context is null"); + } + + @Test + void testIsAnonymousUser_WhenAnonymousFieldIsTrue_ShouldReturnTrue() { + MutableContext ctx = new MutableContext(); + ctx.add("anonymous", true); + assertTrue(EvaluationContextUtil.isAnonymousUser(ctx), "Expected true when anonymous field is true"); + } + + @Test + void testIsAnonymousUser_WhenAnonymousFieldIsFalse_ShouldReturnFalse() { + MutableContext ctx = new MutableContext(); + ctx.add("anonymous", false); + assertFalse(EvaluationContextUtil.isAnonymousUser(ctx), "Expected false when anonymous field is false"); + } + + @Test + void testIsAnonymousUser_WhenAnonymousFieldIsMissing_ShouldReturnFalse() { + MutableContext ctx = new MutableContext(); + assertFalse(EvaluationContextUtil.isAnonymousUser(ctx), "Expected false when anonymous field is missing"); + } +} 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 new file mode 100644 index 000000000..13e54ae48 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/GoffApiMock.java @@ -0,0 +1,196 @@ +package dev.openfeature.contrib.providers.gofeatureflag.util; + +import dev.openfeature.contrib.providers.gofeatureflag.TestUtils; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.Getter; +import lombok.SneakyThrows; +import lombok.val; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.RecordedRequest; +import okhttp3.mockwebserver.SocketPolicy; + +public class GoffApiMock { + private static final String ofrepResponseDir = "ofrep_evaluate_responses/"; + private final MockMode mode; + + @Getter + private List collectorRequestsHistory = new ArrayList<>(); + + @Getter + private int collectorCallCount = 0; + + private int configurationCallCount = 0; + /** + * lastRequestBody contains the body of the last request. + */ + @Getter + private String lastRequestBody = null; + + public final Dispatcher dispatcher = new Dispatcher() { + @SneakyThrows + @Override + public MockResponse dispatch(RecordedRequest request) { + switch (mode) { + case ENDPOINT_ERROR: + return new MockResponse().setResponseCode(500); + case API_KEY_MISSING: + return new MockResponse().setResponseCode(401); + case INVALID_API_KEY: + return new MockResponse().setResponseCode(403); + } + + lastRequestBody = request.getBody().readUtf8(); + assert request.getPath() != null; + if (request.getPath().startsWith("/ofrep/v1/evaluate/flags/")) { + return handleEvaluateFlags(request); + } + if (request.getPath().startsWith("/v1/data/collector")) { + collectorCallCount++; + return handleCollector(request); + } + if (request.getPath().startsWith("/v1/flag/configuration")) { + configurationCallCount++; + return handleFlagConfiguration(request); + } + + throw new UnsupportedOperationException("Unsupported request (mock is missing): " + request.getPath()); + } + }; + + public GoffApiMock(final MockMode mode) { + this.mode = mode; + } + + @SneakyThrows + public MockResponse handleEvaluateFlags(RecordedRequest request) { + assert request.getPath() != null; + String flagName = request.getPath().replace("/ofrep/v1/evaluate/flags/", ""); + switch (flagName) { + case "timeout": + Thread.sleep(500); + return new MockResponse() + .setResponseCode(200) + .setBody(TestUtils.readMockResponse(ofrepResponseDir, flagName + ".json")); + case "400": + return new MockResponse() + .setResponseCode(400) + .setBody(TestUtils.readMockResponse(ofrepResponseDir, flagName + ".json")); + case "401": + return new MockResponse().setResponseCode(401); + case "403": + return new MockResponse().setResponseCode(403); + case "404": + return new MockResponse().setResponseCode(404); + case "500": + return new MockResponse() + .setResponseCode(500) + .setBody(TestUtils.readMockResponse(ofrepResponseDir, flagName + ".json")); + default: + try { + return new MockResponse() + .setResponseCode(200) + .setBody(TestUtils.readMockResponse(ofrepResponseDir, flagName + ".json")); + } catch (Exception e) { + return new MockResponse().setResponseCode(404); + } + } + } + + @SneakyThrows + public MockResponse handleCollector(RecordedRequest request) { + collectorRequestsHistory.add(request); + Map reqBody = Const.DESERIALIZE_OBJECT_MAPPER.readValue(getLastRequestBody(), Map.class); + val meta = (Map) reqBody.get("meta"); + + if (meta.get("error") != null) { + val errorCode = Integer.valueOf(meta.get("error").toString()); + return new MockResponse().setResponseCode(errorCode); + } + + if (meta.get("errorNull") != null) { + return new MockResponse().setBody("").setSocketPolicy(SocketPolicy.DISCONNECT_DURING_RESPONSE_BODY); + } + + return new MockResponse().setResponseCode(200).setBody("{\"ingestedContentCount\":0}"); + } + + @SneakyThrows + public MockResponse handleFlagConfiguration(RecordedRequest request) { + var configLocation = "valid-all-types.json"; + switch (mode) { + case SCHEDULED_ROLLOUT_FLAG_CONFIG: + configLocation = "valid-scheduled-rollout.json"; + break; + case ENDPOINT_ERROR_404: + return new MockResponse().setResponseCode(404); + case SERVE_OLD_CONFIGURATION: + if (configurationCallCount > 1) { + // we serve an old configuration after the 1st call. + return new MockResponse() + .setResponseCode(200) + .setBody(TestUtils.readMockResponse("flag_config_responses/", configLocation)) + .addHeader(Const.HTTP_HEADER_ETAG, "different-etag") + .addHeader(Const.HTTP_HEADER_LAST_MODIFIED, "Wed, 21 Oct 2015 05:28:00 GMT"); + } + break; + case CHANGE_CONFIG_AFTER_1ST_EVAL: + configLocation = + configurationCallCount > 1 ? "valid-all-types-config-change.json" : "valid-all-types.json"; + break; + case SIMPLE_CONFIG: + configLocation = "valid-flag-config.json"; + break; + default: + configLocation = "valid-all-types.json"; + break; + } + + val etag = request.getHeaders().get(Const.HTTP_HEADER_IF_NONE_MATCH); + if (etag == null) { + return new MockResponse() + .setResponseCode(200) + .setBody(TestUtils.readMockResponse("flag_config_responses/", configLocation)) + .addHeader(Const.HTTP_HEADER_ETAG, configLocation) + .addHeader(Const.HTTP_HEADER_LAST_MODIFIED, "Wed, 21 Oct 2015 07:28:00 GMT"); + } + switch (etag) { + case "400": + return new MockResponse().setResponseCode(400); + case "401": + return new MockResponse().setResponseCode(401); + case "403": + return new MockResponse().setResponseCode(403); + case "404": + return new MockResponse().setResponseCode(404); + case "500": + return new MockResponse().setResponseCode(500); + case "invalid-lastmodified-header": + return new MockResponse() + .setResponseCode(200) + .setBody(TestUtils.readMockResponse("flag_config_responses/", configLocation)) + .addHeader(Const.HTTP_HEADER_ETAG, configLocation) + .addHeader(Const.HTTP_HEADER_LAST_MODIFIED, "Wed, 21 Oct 2015 07:2 GMT"); + default: + return new MockResponse() + .setResponseCode(200) + .setBody(TestUtils.readMockResponse("flag_config_responses/", configLocation)) + .addHeader(Const.HTTP_HEADER_ETAG, configLocation) + .addHeader(Const.HTTP_HEADER_LAST_MODIFIED, "Wed, 21 Oct 2015 07:28:00 GMT"); + } + } + + public enum MockMode { + API_KEY_MISSING, + INVALID_API_KEY, + ENDPOINT_ERROR, + ENDPOINT_ERROR_404, + CHANGE_CONFIG_AFTER_1ST_EVAL, + SIMPLE_CONFIG, + DEFAULT, + SERVE_OLD_CONFIGURATION, + SCHEDULED_ROLLOUT_FLAG_CONFIG, + } +} 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 new file mode 100644 index 000000000..3beaabe30 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/util/MetadataUtilTest.java @@ -0,0 +1,61 @@ +package dev.openfeature.contrib.providers.gofeatureflag.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import dev.openfeature.sdk.ImmutableMetadata; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class MetadataUtilTest { + + @Test + void testConvertFlagMetadata_NullInput() { + // Test when the input is null + ImmutableMetadata metadata = MetadataUtil.convertFlagMetadata(null); + assertNotNull(metadata); + } + + @Test + void testConvertFlagMetadata_EmptyInput() { + // Test when the input is an empty map + Map flagMetadata = new HashMap<>(); + ImmutableMetadata metadata = MetadataUtil.convertFlagMetadata(flagMetadata); + assertNotNull(metadata); + } + + @Test + void testConvertFlagMetadata_WithVariousTypes() { + // Test with a map containing various types of values + Map flagMetadata = new HashMap<>(); + flagMetadata.put("key1", 123L); // Long + flagMetadata.put("key2", 42); // Integer + flagMetadata.put("key3", 3.14f); // Float + flagMetadata.put("key4", 2.718); // Double + flagMetadata.put("key5", true); // Boolean + flagMetadata.put("key6", "stringValue"); // String + + ImmutableMetadata metadata = MetadataUtil.convertFlagMetadata(flagMetadata); + + assertNotNull(metadata); + assertEquals(123L, metadata.getLong("key1")); + assertEquals(42, metadata.getInteger("key2")); + assertEquals(3.14f, metadata.getFloat("key3")); + assertEquals(2.718, metadata.getDouble("key4")); + assertEquals(true, metadata.getBoolean("key5")); + assertEquals("stringValue", metadata.getString("key6")); + } + + @Test + void testConvertFlagMetadata_UnsupportedType() { + // Test with a map containing an unsupported type + Map flagMetadata = new HashMap<>(); + flagMetadata.put("key1", new RuntimeException()); // Unsupported type + + ImmutableMetadata metadata = MetadataUtil.convertFlagMetadata(flagMetadata); + + assertNotNull(metadata); + assertEquals("java.lang.RuntimeException", metadata.getString("key1")); + } +} 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 new file mode 100644 index 000000000..673edeaeb --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/api_events/valid-response.json @@ -0,0 +1,47 @@ +{ + "meta": { + "provider": "go-feature-flag", + "intValue": 1 + }, + "events": [ + { + "contextKind": "anonymousUser", + "creationDate": 1617970547, + "key": "xxx", + "kind": "feature", + "userKey": "ABCD", + "value": true, + "variation": "enabled", + "version": null, + "default": false + }, + { + "kind": "tracking", + "contextKind": "anonymousUser", + "userKey": "ABCD", + "creationDate": 1617970548, + "key": "xxx", + "evaluationContext": { + "firstname": "john", + "rate": 3.14, + "targetingKey": "d45e303a-38c2-11ed-a261-0242ac120002", + "company_info": { + "size": 120, + "name": "my_company" + }, + "anonymous": false, + "email": "john.doe@gofeatureflag.org", + "age": 30, + "lastname": "doe", + "professional": true, + "labels": [ + "pro", + "beta" + ] + }, + "trackingEventDetails": { + "toto": 123 + } + } + ] +} diff --git a/providers/go-feature-flag/src/test/resources/flag_config_responses/valid-all-types-config-change.json b/providers/go-feature-flag/src/test/resources/flag_config_responses/valid-all-types-config-change.json new file mode 100644 index 000000000..b0346028d --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/flag_config_responses/valid-all-types-config-change.json @@ -0,0 +1,248 @@ +{ + "flags": { + "bool_targeting_match": { + "variations": { + "disabled": false, + "enabled": true + }, + "targeting": [ + { + "query": "email eq \"doe.john@gofeatureflag.org\"", + "variation": "enabled" + } + ], + "defaultRule": { + "percentage": { + "enabled": 0, + "disabled": 100 + } + }, + "metadata": { + "description": "this is a test flag", + "defaultValue": false + } + }, + "new-flag-changed": { + "disable": true, + "variations": { + "disabled": false, + "enabled": true + }, + "defaultRule": { + "percentage": { + "enabled": 0, + "disabled": 100 + } + }, + "metadata": { + "description": "this is a test flag", + "defaultValue": false + } + }, + "disabled_float": { + "disable": true, + "variations": { + "high": 103.25, + "medium": 101.25, + "low": 100.25 + }, + "defaultRule": { + "percentage": { + "low": 0, + "medium": 0, + "high": 100 + } + }, + "metadata": { + "description": "this is a test", + "defaultValue": 100.25 + } + }, + "disabled_int": { + "disable": true, + "variations": { + "high": 103, + "medium": 101, + "low": 100 + }, + "defaultRule": { + "percentage": { + "low": 0, + "medium": 0, + "high": 100 + } + }, + "metadata": { + "description": "this is a test", + "defaultValue": 100 + } + }, + "disabled_interface": { + "disable": true, + "variations": { + "varA": { + "test": "john" + }, + "varB": { + "test": "doe" + } + }, + "defaultRule": { + "percentage": { + "varA": 0, + "varB": 100 + } + }, + "metadata": { + "description": "this is a test" + } + }, + "disabled_string": { + "disable": true, + "variations": { + "color1": "CC0002", + "color2": "CC0001", + "color3": "CC0000" + }, + "defaultRule": { + "percentage": { + "color3": 0, + "color2": 0, + "color1": 100 + } + }, + "metadata": { + "description": "this is a test", + "defaultValue": "CC0000" + } + }, + "double_key": { + "variations": { + "high": 103.25, + "medium": 101.25, + "low": 100.25 + }, + "targeting": [ + { + "query": "email eq \"john.doe@gofeatureflag.org\"", + "variation": "medium" + } + ], + "defaultRule": { + "percentage": { + "high": 0, + "medium": 0, + "low": 100 + } + }, + "metadata": { + "description": "this is a test flag", + "defaultValue": 100.25 + } + }, + "integer_key": { + "variations": { + "high": 103, + "medium": 101, + "low": 100 + }, + "defaultRule": { + "percentage": { + "low": 0, + "medium": 0, + "high": 100 + } + }, + "targeting": [ + { + "query": "email eq \"john.doe@gofeatureflag.org\"", + "variation": "medium" + } + ], + "metadata": { + "defaultValue": 1000, + "description": "this is a test flag" + } + }, + "object_key": { + "variations": { + "varA": { + "test": "default" + }, + "varB": { + "test": "false" + } + }, + "targeting": [ + { + "query": "email eq \"john.doe@gofeatureflag.org\"", + "variation": "varB" + } + ], + "defaultRule": { + "variation": "varA" + } + }, + "string_key": { + "trackEvents": false, + "variations": { + "color1": "CC0002", + "color2": "CC0001", + "color3": "CC0000" + }, + "defaultRule": { + "percentage": { + "color3": 0, + "color2": 0, + "color1": 100 + } + }, + "metadata": { + "description": "this is a test flag", + "defaultValue": "CC0000" + } + }, + "string_key_with_version": { + "variations": { + "color1": "CC0002", + "color2": "CC0001", + "color3": "CC0000" + }, + "defaultRule": { + "percentage": { + "color3": 0, + "color2": 0, + "color1": 100 + } + }, + "targeting": [ + { + "query": "email eq \"john.doe@gofeatureflag.org\"", + "variation": "color1" + } + ], + "metadata": { + "description": "this is a test", + "defaultValue": "CC0000" + } + }, + "flag-use-evaluation-context-enrichment": { + "variations": { + "A": "A", + "B": "B" + }, + "targeting": [ + { + "query": "environment eq \"integration-test\"", + "variation": "A" + } + ], + "defaultRule": { + "variation": "B" + } + } + }, + "evaluationContextEnrichment": { + "env": "production" + } +} diff --git a/providers/go-feature-flag/src/test/resources/flag_config_responses/valid-all-types.json b/providers/go-feature-flag/src/test/resources/flag_config_responses/valid-all-types.json new file mode 100644 index 000000000..27c8415b5 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/flag_config_responses/valid-all-types.json @@ -0,0 +1,248 @@ +{ + "flags": { + "bool_targeting_match": { + "variations": { + "disabled": false, + "enabled": true + }, + "targeting": [ + { + "query": "email eq \"john.doe@gofeatureflag.org\"", + "variation": "enabled" + } + ], + "defaultRule": { + "percentage": { + "enabled": 0, + "disabled": 100 + } + }, + "metadata": { + "description": "this is a test flag", + "defaultValue": false + } + }, + "disabled_bool": { + "disable": true, + "variations": { + "disabled": false, + "enabled": true + }, + "defaultRule": { + "percentage": { + "enabled": 0, + "disabled": 100 + } + }, + "metadata": { + "description": "this is a test flag", + "defaultValue": false + } + }, + "disabled_float": { + "disable": true, + "variations": { + "high": 103.25, + "medium": 101.25, + "low": 100.25 + }, + "defaultRule": { + "percentage": { + "low": 0, + "medium": 0, + "high": 100 + } + }, + "metadata": { + "description": "this is a test", + "defaultValue": 100.25 + } + }, + "disabled_int": { + "disable": true, + "variations": { + "high": 103, + "medium": 101, + "low": 100 + }, + "defaultRule": { + "percentage": { + "low": 0, + "medium": 0, + "high": 100 + } + }, + "metadata": { + "description": "this is a test", + "defaultValue": 100 + } + }, + "disabled_interface": { + "disable": true, + "variations": { + "varA": { + "test": "john" + }, + "varB": { + "test": "doe" + } + }, + "defaultRule": { + "percentage": { + "varA": 0, + "varB": 100 + } + }, + "metadata": { + "description": "this is a test" + } + }, + "disabled_string": { + "disable": true, + "variations": { + "color1": "CC0002", + "color2": "CC0001", + "color3": "CC0000" + }, + "defaultRule": { + "percentage": { + "color3": 0, + "color2": 0, + "color1": 100 + } + }, + "metadata": { + "description": "this is a test", + "defaultValue": "CC0000" + } + }, + "double_key": { + "variations": { + "high": 103.25, + "medium": 101.25, + "low": 100.25 + }, + "targeting": [ + { + "query": "email eq \"john.doe@gofeatureflag.org\"", + "variation": "medium" + } + ], + "defaultRule": { + "percentage": { + "high": 0, + "medium": 0, + "low": 100 + } + }, + "metadata": { + "description": "this is a test flag", + "defaultValue": 100.25 + } + }, + "integer_key": { + "variations": { + "high": 103, + "medium": 101, + "low": 100 + }, + "defaultRule": { + "percentage": { + "low": 0, + "medium": 0, + "high": 100 + } + }, + "targeting": [ + { + "query": "email eq \"john.doe@gofeatureflag.org\"", + "variation": "medium" + } + ], + "metadata": { + "defaultValue": 1000, + "description": "this is a test flag" + } + }, + "object_key": { + "variations": { + "varA": { + "test": "default" + }, + "varB": { + "test": "false" + } + }, + "targeting": [ + { + "query": "email eq \"john.doe@gofeatureflag.org\"", + "variation": "varB" + } + ], + "defaultRule": { + "variation": "varA" + } + }, + "string_key": { + "trackEvents": false, + "variations": { + "color1": "CC0002", + "color2": "CC0001", + "color3": "CC0000" + }, + "defaultRule": { + "percentage": { + "color3": 0, + "color2": 0, + "color1": 100 + } + }, + "metadata": { + "description": "this is a test flag", + "defaultValue": "CC0000" + } + }, + "string_key_with_version": { + "variations": { + "color1": "CC0002", + "color2": "CC0001", + "color3": "CC0000" + }, + "defaultRule": { + "percentage": { + "color3": 0, + "color2": 0, + "color1": 100 + } + }, + "targeting": [ + { + "query": "email eq \"john.doe@gofeatureflag.org\"", + "variation": "color1" + } + ], + "metadata": { + "description": "this is a test", + "defaultValue": "CC0000" + } + }, + "flag-use-evaluation-context-enrichment": { + "variations": { + "A": "A", + "B": "B" + }, + "targeting": [ + { + "query": "environment eq \"integration-test\"", + "variation": "A" + } + ], + "defaultRule": { + "variation": "B" + } + } + }, + "evaluationContextEnrichment": { + "env": "production" + } +} diff --git a/providers/go-feature-flag/src/test/resources/flag_config_responses/valid-flag-config.json b/providers/go-feature-flag/src/test/resources/flag_config_responses/valid-flag-config.json new file mode 100644 index 000000000..c3a720d60 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/flag_config_responses/valid-flag-config.json @@ -0,0 +1,25 @@ +{ + "flags": { + "TEST": { + "variations": { + "off": false, + "on": true + }, + "defaultRule": { + "variation": "off" + } + }, + "TEST2": { + "variations": { + "off": false, + "on": true + }, + "defaultRule": { + "variation": "on" + } + } + }, + "evaluationContextEnrichment": { + "env": "production" + } +} diff --git a/providers/go-feature-flag/src/test/resources/flag_config_responses/valid-scheduled-rollout.json b/providers/go-feature-flag/src/test/resources/flag_config_responses/valid-scheduled-rollout.json new file mode 100644 index 000000000..d93262364 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/flag_config_responses/valid-scheduled-rollout.json @@ -0,0 +1,58 @@ +{ + "flags": { + "my-flag": { + "variations": { + "disabled": false, + "enabled": true + }, + "defaultRule": { + "percentage": { + "enabled": 0, + "disabled": 100 + } + }, + "metadata": { + "description": "this is a test flag", + "defaultValue": false + }, + "scheduledRollout": [ + { + "targeting": [ + { + "query": "targetingKey eq \"d45e303a-38c2-11ed-a261-0242ac120002\"", + "variation": "enabled" + } + ], + "date": "2022-07-31T22:00:00.100Z" + } + ] + }, + "my-flag-scheduled-in-future": { + "variations": { + "disabled": false, + "enabled": true + }, + "defaultRule": { + "percentage": { + "enabled": 0, + "disabled": 100 + } + }, + "metadata": { + "description": "this is a test flag", + "defaultValue": false + }, + "scheduledRollout": [ + { + "targeting": [ + { + "query": "targetingKey eq \"d45e303a-38c2-11ed-a261-0242ac120002\"", + "variation": "enabled" + } + ], + "date": "3022-07-31T22:00:00.100Z" + } + ] + } + } +} 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 223d21a89..ca4fab9d9 100644 --- a/providers/go-feature-flag/src/test/resources/log4j2-test.xml +++ b/providers/go-feature-flag/src/test/resources/log4j2-test.xml @@ -10,4 +10,4 @@ - \ No newline at end of file + diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/400.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/400.json new file mode 100644 index 000000000..db4d36f12 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/400.json @@ -0,0 +1,5 @@ +{ + "key": "400", + "errorCode": "PROVIDER_NOT_READY", + "errorDetails": "The provider is not ready to serve requests" +} diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/500.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/500.json new file mode 100644 index 000000000..b958bd755 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/500.json @@ -0,0 +1,5 @@ +{ + "key": "500", + "errorCode": "PROVIDER_NOT_READY", + "errorDetails": "The provider is not ready to serve requests" +} diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/bool_flag.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/bool_flag.json new file mode 100644 index 000000000..54c0d0048 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/bool_flag.json @@ -0,0 +1,10 @@ +{ + "key": "bool_flag", + "value": true, + "reason": "TARGETING_MATCH", + "variant": "enabled", + "metadata": { + "gofeatureflag_cacheable": true, + "description": "A flag that is always off" + } +} diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/double_flag.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/double_flag.json new file mode 100644 index 000000000..6ae4da86a --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/double_flag.json @@ -0,0 +1,10 @@ +{ + "key": "double_flag", + "value": 100.11, + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "gofeatureflag_cacheable": true, + "description": "A flag that is always off" + } +} diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-key.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-key.json new file mode 100644 index 000000000..f5b8d0275 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/flag-key.json @@ -0,0 +1,10 @@ +{ + "key": "flag-key", + "value": false, + "reason": "STATIC", + "variant": "off", + "metadata": { + "gofeatureflag_cacheable": true, + "description": "A flag that is always off" + } +} diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/int_flag.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/int_flag.json new file mode 100644 index 000000000..388b516c5 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/int_flag.json @@ -0,0 +1,10 @@ +{ + "key": "int_flag", + "value": 100, + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "gofeatureflag_cacheable": true, + "description": "A flag that is always off" + } +} diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/invalid-json.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/invalid-json.json new file mode 100644 index 000000000..925ed2793 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/invalid-json.json @@ -0,0 +1,8 @@ +{ + "key": "flag-key", + "value": false, + "reason": "STATIC", + "variant": "off", + "metadata": { + "gofeatureflag_cacheable": true + } diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/object_flag.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/object_flag.json new file mode 100644 index 000000000..bbcfdcaa7 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/object_flag.json @@ -0,0 +1,13 @@ +{ + "key": "double_flag", + "value": { + "name": "foo", + "age": 100 + }, + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "gofeatureflag_cacheable": true, + "description": "A flag that is always off" + } +} diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/string_flag.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/string_flag.json new file mode 100644 index 000000000..373d418d5 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/string_flag.json @@ -0,0 +1,10 @@ +{ + "key": "string_flag", + "value": "string value", + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "gofeatureflag_cacheable": true, + "description": "A flag that is always off" + } +} diff --git a/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/timeout.json b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/timeout.json new file mode 100644 index 000000000..a559570a5 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/ofrep_evaluate_responses/timeout.json @@ -0,0 +1,9 @@ +{ + "key": "flag-key", + "value": false, + "reason": "STATIC", + "variant": "off", + "metadata": { + "gofeatureflag_cacheable": true + } +} diff --git a/providers/go-feature-flag/src/test/resources/wasm_inputs/valid.json b/providers/go-feature-flag/src/test/resources/wasm_inputs/valid.json new file mode 100644 index 000000000..1caf3dfa3 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/wasm_inputs/valid.json @@ -0,0 +1,39 @@ +{ + "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/release-please-config.json b/release-please-config.json index 2ac7f25f8..b8b99919e 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -32,7 +32,8 @@ "bump-patch-for-minor-pre-major": true, "versioning": "default", "extra-files": [ - "pom.xml" + "pom.xml", + "README.md" ] }, "providers/jsonlogic-eval-provider": {