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