diff --git a/providers/go-feature-flag/README.md b/providers/go-feature-flag/README.md
index d6d1a869c..ad42714f9 100644
--- a/providers/go-feature-flag/README.md
+++ b/providers/go-feature-flag/README.md
@@ -39,14 +39,16 @@ You will have a new instance ready to be used with your `open-feature` java SDK.
### Options
-| 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)_ |
-| **`cacheBuilder`** | `false` | If cache custom configuration is wanted, you should provide a cache builder. _(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)_ |
+| 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)_ |
+| **`cacheBuilder`** | `false` | If cache custom configuration is wanted, you should provide a cache builder. _(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)_ |
diff --git a/providers/go-feature-flag/pom.xml b/providers/go-feature-flag/pom.xml
index 7e190f01f..6581be3b6 100644
--- a/providers/go-feature-flag/pom.xml
+++ b/providers/go-feature-flag/pom.xml
@@ -68,6 +68,12 @@
2.0.13
+
+ io.reactivex.rxjava3
+ rxjava
+ 3.1.8
+
+
org.apache.logging.log4j
log4j-slf4j2-impl
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
index d83a10f47..4652ea880 100644
--- 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
@@ -13,5 +13,5 @@
@Getter
public class EvaluationResponse {
private ProviderEvaluation providerEvaluation;
- private Boolean cachable;
+ 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 4fa717b17..21c371e55 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,90 +1,55 @@
package dev.openfeature.contrib.providers.gofeatureflag;
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 com.google.common.cache.Cache;
-import com.google.common.cache.CacheBuilder;
-import dev.openfeature.contrib.providers.gofeatureflag.bean.BeanUtils;
-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.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.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.sdk.ErrorCode;
import dev.openfeature.sdk.EvaluationContext;
-import dev.openfeature.sdk.FeatureProvider;
+import dev.openfeature.sdk.EventProvider;
import dev.openfeature.sdk.Hook;
-import dev.openfeature.sdk.ImmutableMetadata;
import dev.openfeature.sdk.Metadata;
import dev.openfeature.sdk.ProviderEvaluation;
+import dev.openfeature.sdk.ProviderEventDetails;
import dev.openfeature.sdk.ProviderState;
import dev.openfeature.sdk.Reason;
import dev.openfeature.sdk.Value;
-import dev.openfeature.sdk.exceptions.FlagNotFoundError;
import dev.openfeature.sdk.exceptions.GeneralError;
-import dev.openfeature.sdk.exceptions.OpenFeatureError;
import dev.openfeature.sdk.exceptions.ProviderNotReadyError;
-import dev.openfeature.sdk.exceptions.TypeMismatchError;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
-import lombok.AccessLevel;
-import lombok.Getter;
+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 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;
+import org.jetbrains.annotations.NotNull;
-import java.io.IOException;
-import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
-import java.util.Map;
import java.util.concurrent.TimeUnit;
-import static dev.openfeature.sdk.Value.objectToValue;
-import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
-import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;
-
/**
* GoFeatureFlagProvider is the JAVA provider implementation for the feature flag solution GO Feature Flag.
*/
@Slf4j
@SuppressWarnings({"checkstyle:NoFinalizer"})
-public class GoFeatureFlagProvider implements FeatureProvider {
- public static final long DEFAULT_CACHE_TTL_MS = 1000;
- public static final int DEFAULT_CACHE_CONCURRENCY_LEVEL = 1;
- public static final int DEFAULT_CACHE_INITIAL_CAPACITY = 100;
- public static final int DEFAULT_CACHE_MAXIMUM_SIZE = 10000;
- public static final ObjectMapper requestMapper = new ObjectMapper();
+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";
- private static final ObjectMapper responseMapper = new ObjectMapper()
- .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+
private final GoFeatureFlagProviderOptions options;
- private DataCollectorHook dataCollectorHook;
private final List hooks = new ArrayList<>();
- private HttpUrl parsedEndpoint;
- // httpClient is the instance of the OkHttpClient used by the provider
- private OkHttpClient httpClient;
- // apiKey contains the token to use while calling GO Feature Flag relay proxy
- private String apiKey;
- @Getter(AccessLevel.PROTECTED)
- private Cache> cache;
+ private DataCollectorHook dataCollectorHook;
private ProviderState state = ProviderState.NOT_READY;
-
- protected final void finalize() {
- // DO NOT REMOVE, spotbugs: CT_CONSTRUCTOR_THROW
- }
+ private Disposable flagChangeDisposable;
+ private GoFeatureFlagController gofeatureflagController;
+ private CacheController cacheCtrl;
/**
* Constructor of the provider.
@@ -97,36 +62,6 @@ public GoFeatureFlagProvider(GoFeatureFlagProviderOptions options) throws Invali
this.options = options;
}
- /**
- * validateInputOptions is validating the different options provided when creating the provider.
- *
- * @param options - Options used while creating the provider
- * @throws InvalidOptions - if no options are provided
- * @throws InvalidEndpoint - if the endpoint provided is not valid
- */
- private void validateInputOptions(GoFeatureFlagProviderOptions options) throws InvalidEndpoint, 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");
- }
- }
-
- /**
- * buildDefaultCache is building a default cache configuration.
- *
- * @return the default cache configuration
- */
- private Cache> buildDefaultCache() {
- return CacheBuilder.newBuilder()
- .concurrencyLevel(DEFAULT_CACHE_CONCURRENCY_LEVEL)
- .initialCapacity(DEFAULT_CACHE_INITIAL_CAPACITY).maximumSize(DEFAULT_CACHE_MAXIMUM_SIZE)
- .expireAfterWrite(Duration.ofMillis(DEFAULT_CACHE_TTL_MS))
- .build();
- }
-
@Override
public Metadata getMetadata() {
return () -> NAME;
@@ -173,60 +108,73 @@ public ProviderEvaluation getObjectEvaluation(
return getEvaluation(key, defaultValue, evaluationContext, Value.class);
}
- /**
- * buildCacheKey is creating the entry key of the cache.
- *
- * @param key - the name of your feature flag
- * @param userKey - a representation of your user
- * @return the cache key
- */
- private String buildCacheKey(String key, String userKey) {
- return key + "," + userKey;
- }
-
@Override
public void initialize(EvaluationContext evaluationContext) throws Exception {
- FeatureProvider.super.initialize(evaluationContext);
-
- // 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());
-
- // init httpClient to call the GO Feature Flag API
- 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();
-
- this.parsedEndpoint = HttpUrl.parse(options.getEndpoint());
- if (this.parsedEndpoint == null) {
- throw new InvalidEndpoint();
- }
- this.apiKey = options.getApiKey();
- boolean enableCache = options.getEnableCache() == null || options.getEnableCache();
- if (enableCache) {
- this.cache = options.getCacheBuilder() != null ? options.getCacheBuilder().build() : buildDefaultCache();
- this.dataCollectorHook = new DataCollectorHook(DataCollectorHookOptions.builder()
- .flushIntervalMs(options.getFlushIntervalMs())
- .parsedEndpoint(parsedEndpoint)
- .maxPendingEvents(options.getMaxPendingEvents())
- .apiKey(options.getApiKey())
- .httpClient(this.httpClient)
- .build());
- this.hooks.add(this.dataCollectorHook);
+ super.initialize(evaluationContext);
+ this.gofeatureflagController = GoFeatureFlagController.builder().options(options).build();
+
+ 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();
}
state = ProviderState.READY;
+ super.emitProviderReady(ProviderEventDetails.builder().message("Provider is ready to call the API").build());
log.info("finishing initializing provider, state: {}", state);
}
+
+ /**
+ * 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 ProviderState getState() {
return state;
@@ -246,7 +194,6 @@ public ProviderState getState() {
@SuppressWarnings("unchecked")
private ProviderEvaluation getEvaluation(
String key, T defaultValue, EvaluationContext evaluationContext, Class> expectedType) {
- GoFeatureFlagUser user = GoFeatureFlagUser.fromEvaluationContext(evaluationContext);
try {
if (!ProviderState.READY.equals(state)) {
if (ProviderState.NOT_READY.equals(state)) {
@@ -260,196 +207,78 @@ private ProviderEvaluation getEvaluation(
throw new GeneralError("unknown error, provider state: " + state);
}
- if (cache == null) {
- return resolveEvaluationGoFeatureFlagProxy(key, defaultValue, user, expectedType)
+ if (this.cacheCtrl == null) {
+ return this.gofeatureflagController
+ .evaluateFlag(key, defaultValue, evaluationContext, expectedType)
.getProviderEvaluation();
}
- String cacheKey = buildCacheKey(key, BeanUtils.buildKey(user));
- ProviderEvaluation> cachedProviderEvaluation = cache.getIfPresent(cacheKey);
+ ProviderEvaluation> cachedProviderEvaluation = this.cacheCtrl.getIfPresent(key, evaluationContext);
if (cachedProviderEvaluation == null) {
- EvaluationResponse proxyRes = resolveEvaluationGoFeatureFlagProxy(
- key, defaultValue, user, expectedType);
- if (Boolean.TRUE.equals(proxyRes.getCachable())) {
- cache.put(cacheKey, proxyRes.getProviderEvaluation());
+ EvaluationResponse proxyRes = this.gofeatureflagController.evaluateFlag(
+ key, defaultValue, evaluationContext, expectedType);
+
+ 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 resolveEvaluationGoFeatureFlagProxy(key, defaultValue, user, expectedType).getProviderEvaluation();
+ return this.gofeatureflagController
+ .evaluateFlag(key, defaultValue, evaluationContext, expectedType)
+ .getProviderEvaluation();
} catch (InvalidTypeInCache e) {
log.warn(e.getMessage(), e);
- return resolveEvaluationGoFeatureFlagProxy(key, defaultValue, user, expectedType).getProviderEvaluation();
+ return this.gofeatureflagController
+ .evaluateFlag(key, defaultValue, evaluationContext, expectedType)
+ .getProviderEvaluation();
}
}
- /**
- * resolveEvaluationGoFeatureFlagProxy is calling the GO Feature Flag API to retrieve the flag value.
- *
- * @param key - name of the feature flag
- * @param defaultValue - value used if something is not working as expected
- * @param user - user (containing EvaluationContext) used for the request
- * @param expectedType - type expected for the value
- * @return a ProviderEvaluation that contains the open-feature response
- * @throws OpenFeatureError - if an error happen
- */
- private EvaluationResponse resolveEvaluationGoFeatureFlagProxy(
- String key, T defaultValue, GoFeatureFlagUser user, Class> expectedType
- ) throws OpenFeatureError {
- try {
- 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("Content-Type", "application/json")
- .post(RequestBody.create(
- requestMapper.writeValueAsBytes(goffRequest),
- MediaType.get("application/json; charset=utf-8")));
-
- if (this.apiKey != null && !"".equals(this.apiKey)) {
- reqBuilder.addHeader("Authorization", "Bearer " + this.apiKey);
- }
-
- try (Response response = this.httpClient.newCall(reqBuilder.build()).execute()) {
- if (response.code() == HTTP_UNAUTHORIZED) {
- throw new GeneralError("invalid token used to contact GO Feature Flag relay proxy instance");
- }
- if (response.code() >= HTTP_BAD_REQUEST) {
- throw new GeneralError("impossible to contact GO Feature Flag relay proxy instance");
- }
-
- ResponseBody responseBody = response.body();
- String body = responseBody != null ? responseBody.string() : "";
- GoFeatureFlagResponse goffResp =
- responseMapper.readValue(body, GoFeatureFlagResponse.class);
-
- 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).cachable(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(this.convertFlagMetadata(goffResp.getMetadata()))
- .build();
-
- return EvaluationResponse.builder()
- .providerEvaluation(providerEvaluation).cachable(goffResp.getCacheable()).build();
- }
- } catch (IOException e) {
- throw new GeneralError("unknown error while retrieving flag " + key, e);
+ @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();
}
}
/**
- * mapErrorCode is mapping the errorCode in string received by the API to our internal SDK ErrorCode enum.
+ * validateInputOptions is validating the different options provided when creating the provider.
*
- * @param errorCode - string of the errorCode received from the API
- * @return an item from the enum
+ * @param options - Options used while creating the provider
+ * @throws InvalidOptions - if no options are provided
+ * @throws InvalidEndpoint - if the endpoint provided is not valid
*/
- private ErrorCode mapErrorCode(String errorCode) {
- try {
- return ErrorCode.valueOf(errorCode);
- } catch (IllegalArgumentException e) {
- return null;
+ private void validateInputOptions(GoFeatureFlagProviderOptions options) throws InvalidOptions {
+ if (options == null) {
+ throw new InvalidOptions("No options provided");
}
- }
- /**
- * convertFlagMetadata is converting the flagMetadata object received from the server
- * to an ImmutableMetadata format known by Open Feature.
- *
- * @param flagMetadata - metadata received from the server
- * @return a converted metadata object.
- */
- private ImmutableMetadata convertFlagMetadata(Map flagMetadata) {
- ImmutableMetadata.ImmutableMetadataBuilder builder = ImmutableMetadata.builder();
- if (flagMetadata == null) {
- return builder.build();
+ if (options.getEndpoint() == null || options.getEndpoint().isEmpty()) {
+ throw new InvalidEndpoint("endpoint is a mandatory field when initializing the provider");
}
- flagMetadata.forEach((k, v) -> {
- if (v instanceof Long) {
- builder.addLong(k, (Long) v);
- } else if (v instanceof Integer) {
- builder.addInteger(k, (Integer) v);
- } else if (v instanceof Float) {
- builder.addFloat(k, (Float) v);
- } else if (v instanceof Double) {
- builder.addDouble(k, (Double) v);
- } else if (v instanceof Boolean) {
- builder.addBoolean(k, (Boolean) v);
- } else {
- builder.addString(k, v.toString());
- }
- });
- return builder.build();
}
/**
- * convertValue is converting the object return by the proxy response in the right type.
+ * DO NOT REMOVE, spotbugs: CT_CONSTRUCTOR_THROW.
*
- * @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
+ * @deprecated (Kept for compatibility with OpenFeatureAPI)
*/
- 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);
- }
-
-
- @Override
- public void shutdown() {
- log.info("shutdown");
- if (this.dataCollectorHook != null) {
- this.dataCollectorHook.shutdown();
- }
+ @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/GoFeatureFlagProviderOptions.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java
index 9590ce68c..c8d38fc30 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
@@ -50,7 +50,11 @@ public class GoFeatureFlagProviderOptions {
/**
* (optional) If cache custom configuration is wanted, you should provide
* a cache builder.
- * Default: null
+ * Default:
+ * CACHE_TTL_MS: 5min
+ * CACHE_CONCURRENCY_LEVEL: 1
+ * CACHE_INITIAL_CAPACITY: 100
+ * CACHE_MAXIMUM_SIZE: 100000
*/
private CacheBuilder> cacheBuilder;
@@ -70,8 +74,23 @@ public class GoFeatureFlagProviderOptions {
/**
* (optional) max pending events aggregated before publishing for collection data to the proxy.
- * When event is added while events collection is full, event is omitted.
+ * 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
+ */
+ private boolean disableDataCollection;
}
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
index 6e7ebb266..e405bd3b2 100644
--- 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
@@ -2,6 +2,7 @@
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.openfeature.sdk.EvaluationContext;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
@@ -13,7 +14,7 @@ public class BeanUtils {
private static final ObjectMapper objectMapper = new ObjectMapper();
- public static String buildKey(GoFeatureFlagUser goFeatureFlagUser) throws JsonProcessingException {
- return objectMapper.writeValueAsString(goFeatureFlagUser);
+ 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
new file mode 100644
index 000000000..ec39208a5
--- /dev/null
+++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/ConfigurationChange.java
@@ -0,0 +1,10 @@
+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/controller/CacheController.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/controller/CacheController.java
new file mode 100644
index 000000000..d3ca23f2f
--- /dev/null
+++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/controller/CacheController.java
@@ -0,0 +1,57 @@
+package dev.openfeature.contrib.providers.gofeatureflag.controller;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+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 lombok.Builder;
+
+import java.time.Duration;
+
+/**
+ * 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_CONCURRENCY_LEVEL = 1;
+ 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.getCacheBuilder() != null ? options.getCacheBuilder().build() : buildDefaultCache();
+ }
+
+ private Cache> buildDefaultCache() {
+ return CacheBuilder.newBuilder()
+ .concurrencyLevel(DEFAULT_CACHE_CONCURRENCY_LEVEL)
+ .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
new file mode 100644
index 000000000..ab357d657
--- /dev/null
+++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/controller/GoFeatureFlagController.java
@@ -0,0 +1,340 @@
+package dev.openfeature.contrib.providers.gofeatureflag.controller;
+
+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 com.google.common.net.HttpHeaders;
+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.OpenFeatureError;
+import dev.openfeature.sdk.exceptions.TypeMismatchError;
+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;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import static dev.openfeature.sdk.Value.objectToValue;
+import static org.apache.hc.core5.http.ContentType.APPLICATION_JSON;
+
+/**
+ * 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 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 ";
+
+ /**
+ * 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;
+
+ /**
+ * 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.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(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON.getMimeType())
+ .post(RequestBody.create(
+ requestMapper.writeValueAsBytes(goffRequest),
+ MediaType.get("application/json; charset=utf-8")));
+
+ if (this.apiKey != null && !"".equals(this.apiKey)) {
+ reqBuilder.addHeader(HttpHeaders.AUTHORIZATION, BEARER_TOKEN + this.apiKey);
+ }
+
+ try (Response response = this.httpClient.newCall(reqBuilder.build()).execute()) {
+ if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED) {
+ throw new GeneralError("invalid token used to contact GO Feature Flag relay proxy instance");
+ }
+ if (response.code() >= HttpURLConnection.HTTP_BAD_REQUEST) {
+ throw new GeneralError("impossible to contact GO Feature Flag relay proxy instance");
+ }
+
+ ResponseBody responseBody = response.body();
+ String body = responseBody != null ? responseBody.string() : "";
+ GoFeatureFlagResponse goffResp =
+ responseMapper.readValue(body, GoFeatureFlagResponse.class);
+
+ 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);
+ HttpUrl url = this.parsedEndpoint.newBuilder()
+ .addEncodedPathSegment("v1")
+ .addEncodedPathSegment("data")
+ .addEncodedPathSegment("collector")
+ .build();
+
+ Request.Builder reqBuilder = new Request.Builder()
+ .url(url)
+ .addHeader(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON.getMimeType())
+ .post(RequestBody.create(
+ requestMapper.writeValueAsBytes(events),
+ MediaType.get("application/json; charset=utf-8")));
+
+ if (this.apiKey != null && !this.apiKey.isEmpty()) {
+ reqBuilder.addHeader(HttpHeaders.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(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON.getMimeType())
+ .get();
+
+ if (this.etag != null && !this.etag.isEmpty()) {
+ reqBuilder.addHeader(HttpHeaders.IF_NONE_MATCH, this.etag);
+ }
+ if (this.apiKey != null && !this.apiKey.isEmpty()) {
+ reqBuilder.addHeader(HttpHeaders.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(HttpHeaders.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) {
+ 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/exception/ConfigurationChangeEndpointNotFound.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/ConfigurationChangeEndpointNotFound.java
new file mode 100644
index 000000000..7bbb05e91
--- /dev/null
+++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/ConfigurationChangeEndpointNotFound.java
@@ -0,0 +1,10 @@
+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 ConfigurationChangeEndpointNotFound extends GoFeatureFlagException {
+}
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
new file mode 100644
index 000000000..10590ad94
--- /dev/null
+++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/ConfigurationChangeEndpointUnknownErr.java
@@ -0,0 +1,10 @@
+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/hook/DataCollectorHook.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHook.java
index 94a2c0f27..181ddd3d6 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,40 +1,26 @@
package dev.openfeature.contrib.providers.gofeatureflag.hook;
-import com.fasterxml.jackson.core.JsonProcessingException;
-import dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProvider;
import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagUser;
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.hook.events.EventsPublisher;
import dev.openfeature.sdk.FlagEvaluationDetails;
import dev.openfeature.sdk.Hook;
import dev.openfeature.sdk.HookContext;
import dev.openfeature.sdk.Reason;
-import dev.openfeature.sdk.exceptions.GeneralError;
import lombok.extern.slf4j.Slf4j;
-import okhttp3.HttpUrl;
-import okhttp3.MediaType;
-import okhttp3.Request;
-import okhttp3.RequestBody;
-import okhttp3.Response;
-import java.io.IOException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
-import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
-import static java.net.HttpURLConnection.HTTP_OK;
-import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;
-
/**
* DataCollectorHook is an OpenFeature Hook in charge of sending the usage of the flag to GO Feature Flag.
*/
@Slf4j
@SuppressWarnings({"checkstyle:NoFinalizer"})
-public class DataCollectorHook implements Hook {
+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;
/**
@@ -46,10 +32,6 @@ public class DataCollectorHook implements Hook {
*/
private final EventsPublisher eventsPublisher;
- protected final void finalize() {
- // DO NOT REMOVE, spotbugs: CT_CONSTRUCTOR_THROW
- }
-
/**
* Constructor of the hook.
*
@@ -110,42 +92,7 @@ public void error(HookContext ctx, Exception error, Map hints) {
* @param eventsList - list of the event to send to GO Feature Flag
*/
private void publishEvents(List eventsList) {
- try {
- Events events = new Events(eventsList);
- HttpUrl url = this.options.getParsedEndpoint().newBuilder()
- .addEncodedPathSegment("v1")
- .addEncodedPathSegment("data")
- .addEncodedPathSegment("collector")
- .build();
-
- Request.Builder reqBuilder = new Request.Builder()
- .url(url)
- .addHeader("Content-Type", "application/json")
- .post(RequestBody.create(
- GoFeatureFlagProvider.requestMapper.writeValueAsBytes(events),
- MediaType.get("application/json; charset=utf-8")));
-
- if (this.options.getApiKey() != null && !this.options.getApiKey().isEmpty()) {
- reqBuilder.addHeader("Authorization", "Bearer " + this.options.getApiKey());
- }
-
- try (Response response = this.options.getHttpClient().newCall(reqBuilder.build()).execute()) {
- if (response.code() == HTTP_UNAUTHORIZED) {
- throw new GeneralError("Unauthorized");
- }
- if (response.code() >= HTTP_BAD_REQUEST) {
- throw new GeneralError("Bad request: " + response.body());
- }
-
- if (response.code() == 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);
- }
+ this.options.getGofeatureflagController().sendEventToDataCollector(eventsList);
}
/**
@@ -160,4 +107,8 @@ public void shutdown() {
log.error("error publishing events on shutdown", e);
}
}
+
+ 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/hook/DataCollectorHookOptions.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/hook/DataCollectorHookOptions.java
index 3f65ada2b..e81e4722b 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,12 +1,10 @@
package dev.openfeature.contrib.providers.gofeatureflag.hook;
-import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidEndpoint;
+import dev.openfeature.contrib.providers.gofeatureflag.controller.GoFeatureFlagController;
import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions;
import lombok.Builder;
import lombok.Getter;
import lombok.SneakyThrows;
-import okhttp3.HttpUrl;
-import okhttp3.OkHttpClient;
/**
* DataCollectorHookOptions is the object containing all the options needed for the Data Collector Hook.
@@ -15,22 +13,9 @@
@Getter
public class DataCollectorHookOptions {
/**
- * httpClient is the instance of the OkHttpClient used by the provider.
+ * GoFeatureFlagController is the controller to contact the APIs.
*/
- private OkHttpClient httpClient;
- /**
- * (mandatory) parsedEndpoint contains the DNS of your GO Feature Flag relay proxy.
- * example: https://mydomain.com/gofeatureflagproxy/
- */
- private HttpUrl parsedEndpoint;
- /**
- * (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.
- * (This feature is available only if you are using GO Feature Flag relay proxy v1.7.0 or above)
- * Default: null
- */
- private String apiKey;
+ 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
@@ -40,7 +25,7 @@ public class DataCollectorHookOptions {
private Long flushIntervalMs;
/**
* (optional) max pending events aggregated before publishing for collection data to the proxy.
- * When event is added while events collection is full, event is omitted.
+ * When an event is added while events collection is full, the event is omitted.
* default: 10000
*/
private Integer maxPendingEvents;
@@ -70,9 +55,6 @@ public static class DataCollectorHookOptionsBuilder {
private static class CustomBuilder extends DataCollectorHookOptionsBuilder {
@SneakyThrows
public DataCollectorHookOptions build() {
- if (super.parsedEndpoint == null) {
- throw new InvalidEndpoint("endpoint is a mandatory field when creating the hook");
- }
if (super.flushIntervalMs != null && super.flushIntervalMs <= 0) {
throw new InvalidOptions("flushIntervalMs must be larger than 0");
}
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/hook/events/EventsPublisher.java
index c188f1eb7..f14ac12d5 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/hook/events/EventsPublisher.java
@@ -1,7 +1,7 @@
package dev.openfeature.contrib.providers.gofeatureflag.hook.events;
-import dev.openfeature.contrib.providers.gofeatureflag.concurrent.ConcurrentUtils;
+import dev.openfeature.contrib.providers.gofeatureflag.util.ConcurrentUtils;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/concurrent/ConcurrentUtils.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/ConcurrentUtils.java
similarity index 96%
rename from providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/concurrent/ConcurrentUtils.java
rename to providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/ConcurrentUtils.java
index c9f3f908b..1f9bd80b5 100644
--- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/concurrent/ConcurrentUtils.java
+++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/ConcurrentUtils.java
@@ -1,4 +1,4 @@
-package dev.openfeature.contrib.providers.gofeatureflag.concurrent;
+package dev.openfeature.contrib.providers.gofeatureflag.util;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/MetadataUtil.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/MetadataUtil.java
new file mode 100644
index 000000000..af4cad073
--- /dev/null
+++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/MetadataUtil.java
@@ -0,0 +1,44 @@
+package dev.openfeature.contrib.providers.gofeatureflag.util;
+
+import dev.openfeature.sdk.ImmutableMetadata;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+import java.util.Map;
+
+/**
+ * MetadataUtil is a utility class to convert the metadata received from the server
+ * to an ImmutableMetadata format known by Open Feature.
+ */
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class MetadataUtil {
+ /**
+ * convertFlagMetadata is converting the flagMetadata object received from the server
+ * to an ImmutableMetadata format known by Open Feature.
+ *
+ * @param flagMetadata - metadata received from the server
+ * @return a converted metadata object.
+ */
+ public static ImmutableMetadata convertFlagMetadata(Map flagMetadata) {
+ ImmutableMetadata.ImmutableMetadataBuilder builder = ImmutableMetadata.builder();
+ if (flagMetadata == null) {
+ return builder.build();
+ }
+ flagMetadata.forEach((k, v) -> {
+ if (v instanceof Long) {
+ builder.addLong(k, (Long) v);
+ } else if (v instanceof Integer) {
+ builder.addInteger(k, (Integer) v);
+ } else if (v instanceof Float) {
+ builder.addFloat(k, (Float) v);
+ } else if (v instanceof Double) {
+ builder.addDouble(k, (Double) v);
+ } else if (v instanceof Boolean) {
+ builder.addBoolean(k, (Boolean) v);
+ } else {
+ builder.addString(k, v.toString());
+ }
+ });
+ return builder.build();
+ }
+}
diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java
index fddd9120b..6e7afe351 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
@@ -11,6 +11,7 @@
import java.util.Map;
import com.google.common.cache.CacheBuilder;
+import com.google.common.net.HttpHeaders;
import dev.openfeature.sdk.Client;
import dev.openfeature.sdk.ErrorCode;
import dev.openfeature.sdk.EvaluationContext;
@@ -40,7 +41,7 @@
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.TestInfo;
-import static dev.openfeature.contrib.providers.gofeatureflag.GoFeatureFlagProvider.requestMapper;
+import static dev.openfeature.contrib.providers.gofeatureflag.controller.GoFeatureFlagController.requestMapper;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -49,6 +50,8 @@
@Slf4j
class GoFeatureFlagProviderTest {
private int publishEventsRequestsReceived = 0;
+ private int flagChangeCallCounter = 0;
+ private boolean flagChanged404 = false;
// Dispatcher is the configuration of the mock server to test the provider.
final Dispatcher dispatcher = new Dispatcher() {
@@ -66,18 +69,34 @@ public MockResponse dispatch(RecordedRequest request) {
if (request.getPath().startsWith("/v1/feature/")) {
String flagName = request.getPath().replace("/v1/feature/", "").replace("/eval", "");
return new MockResponse()
- .setResponseCode(200)
- .setBody(readMockResponse(flagName + ".json"));
+ .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();
- if(requestBody.contains("fail_500") && publishEventsRequestsReceived == 1){
+ publishEventsRequestsReceived = ((List) map.get("events")).size();
+ 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);
}
};
@@ -92,8 +111,11 @@ public MockResponse dispatch(RecordedRequest request) {
.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);
@@ -161,7 +183,7 @@ void constructor_options_valid_endpoint() {
@SneakyThrows
@Test
void should_return_not_ready_if_not_initialized() {
- GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()){
+ GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()) {
@Override
public void initialize(EvaluationContext evaluationContext) throws Exception {
@@ -174,7 +196,7 @@ public void initialize(EvaluationContext evaluationContext) throws Exception {
ErrorCode.PROVIDER_NOT_READY and default value should be returned when evaluated via the client,
see next step in this test.
*/
- assertThrows(ProviderNotReadyError.class, ()-> g.getBooleanEvaluation("bool_targeting_match", false, this.evaluationContext));
+ assertThrows(ProviderNotReadyError.class, () -> g.getBooleanEvaluation("bool_targeting_match", false, this.evaluationContext));
String providerName = "shouldReturnNotReadyIfNotInitialized";
OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g);
@@ -192,10 +214,10 @@ void client_test() {
String providerName = "clientTest";
OpenFeatureAPI.getInstance().setProviderAndWait(providerName, g);
Client client = OpenFeatureAPI.getInstance().getClient(providerName);
- Boolean value = client.getBooleanValue("bool_targeting_match",false);
+ 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());
+ 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"));
@@ -210,7 +232,7 @@ void should_throw_an_error_if_endpoint_not_available() {
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 got = client.getBooleanDetails("fail_500", false, this.evaluationContext);
FlagEvaluationDetails want = FlagEvaluationDetails.builder()
.value(false)
.reason(Reason.ERROR.name())
@@ -232,7 +254,7 @@ void should_throw_an_error_if_invalid_api_key() {
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 got = client.getBooleanDetails("fail_401", false, this.evaluationContext);
FlagEvaluationDetails want = FlagEvaluationDetails.builder()
.value(false)
.reason(Reason.ERROR.name())
@@ -249,7 +271,7 @@ void should_throw_an_error_if_flag_does_not_exists() {
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 got = client.getBooleanDetails("flag_not_found", false, this.evaluationContext);
FlagEvaluationDetails want = FlagEvaluationDetails.builder()
.value(false)
.reason(Reason.ERROR.name())
@@ -266,7 +288,7 @@ void should_throw_an_error_if_we_expect_a_boolean_and_got_another_type() {
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 got = client.getBooleanDetails("string_key", false, this.evaluationContext);
FlagEvaluationDetails want = FlagEvaluationDetails.builder()
.value(false)
.reason(Reason.ERROR.name())
@@ -283,7 +305,7 @@ void should_resolve_a_valid_boolean_flag_with_TARGETING_MATCH_reason() {
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 got = client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext);
FlagEvaluationDetails want = FlagEvaluationDetails.builder()
.value(true)
.variant("True")
@@ -298,14 +320,14 @@ void should_resolve_a_valid_boolean_flag_with_TARGETING_MATCH_reason() {
@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());
+ .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 got = client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext);
FlagEvaluationDetails want = FlagEvaluationDetails.builder()
.value(true)
.variant("True")
@@ -314,7 +336,7 @@ void should_resolve_a_valid_boolean_flag_with_TARGETING_MATCH_reason_cache_disab
.flagMetadata(defaultMetadata)
.build();
assertEquals(want, got);
- got = client.getBooleanDetails("bool_targeting_match",false, this.evaluationContext);
+ got = client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext);
assertEquals(want, got);
}
@@ -325,7 +347,7 @@ void should_resolve_from_cache() {
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 got = client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext);
FlagEvaluationDetails want = FlagEvaluationDetails.builder()
.value(true)
.variant("True")
@@ -334,7 +356,7 @@ void should_resolve_from_cache() {
.flagMetadata(defaultMetadata)
.build();
assertEquals(want, got);
- got = client.getBooleanDetails("bool_targeting_match",false, this.evaluationContext);
+ got = client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext);
FlagEvaluationDetails want2 = FlagEvaluationDetails.builder()
.value(true)
.variant("True")
@@ -354,7 +376,7 @@ void should_resolve_from_cache_max_size() {
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 got = client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext);
FlagEvaluationDetails want = FlagEvaluationDetails.builder()
.value(true)
.variant("True")
@@ -364,7 +386,7 @@ void should_resolve_from_cache_max_size() {
.build();
assertEquals(want, got);
- got = client.getBooleanDetails("bool_targeting_match",false, this.evaluationContext);
+ got = client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext);
FlagEvaluationDetails want2 = FlagEvaluationDetails.builder()
.value(true)
.variant("True")
@@ -395,7 +417,7 @@ void should_resolve_from_cache_max_size() {
assertEquals(wantStr2, gotStr);
// verify that value previously fetch from cache now not fetched from cache since cache max size is 1, and cache is full.
- got = client.getBooleanDetails("bool_targeting_match",false, this.evaluationContext);
+ got = client.getBooleanDetails("bool_targeting_match", false, this.evaluationContext);
assertEquals(want, got);
}
@@ -690,7 +712,7 @@ void should_not_fail_if_receive_an_unknown_field_in_response() {
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 got = client.getBooleanDetails("unknown_field", false, this.evaluationContext);
FlagEvaluationDetails want = FlagEvaluationDetails.builder()
.value(true)
.variant("True")
@@ -708,7 +730,7 @@ void should_not_fail_if_no_metadata_in_response() {
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 got = client.getBooleanDetails("no_metadata", false, this.evaluationContext);
FlagEvaluationDetails want = FlagEvaluationDetails.builder()
.value(true)
.variant("True")
@@ -773,6 +795,50 @@ void should_publish_events_context_without_anonymous() {
assertEquals(3, publishEventsRequestsReceived, "We pass the flush interval, we should have 3 events");
}
+ @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());
+ }
+
+ @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);
+ }
+
private String readMockResponse(String filename) throws Exception {
URL url = getClass().getClassLoader().getResource("mock_responses/" + filename);
assert url != null;