diff --git a/pom.xml b/pom.xml index 1ec6e791e..e147779e7 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,7 @@ hooks/open-telemetry providers/flagd + providers/go-feature-flag @@ -45,7 +46,7 @@ - + dev.openfeature javasdk diff --git a/providers/go-feature-flag/README.md b/providers/go-feature-flag/README.md new file mode 100644 index 000000000..3b75b01c0 --- /dev/null +++ b/providers/go-feature-flag/README.md @@ -0,0 +1,30 @@ +# GO Feature Flag Java Provider + +GO Feature Flag provider allows you to connect to your [GO Feature Flag relay proxy](https://gofeatureflag.org) instance. + +## How to use this provider? + +To initialize your instance please follow this example: + +```java +import dev.openfeature.contrib.providers.gofeatureflag; + +// ... +new GoFeatureFlagProvider( + GoFeatureFlagProviderOptions + .builder() + .endpoint("https://my-gofeatureflag-instance.org") + .timeout(1000) + .build()); +``` + +You will have a new instance ready to be used with your `open-feature` java SDK. + +### Options + +| name | mandatory | Description | +|--------------------------|-----------|----------------------------------------------------------------------------------------------------------------| +| **`endpoint`** | `true` | endpoint contains the DNS of your GO Feature Flag relay proxy _(ex: https://mydomain.com/gofeatureflagproxy/)_ | +| **`timeout`** | `false` | timeout in millisecond we are waiting when calling the go-feature-flag relay proxy API. _(default: 10000)_ | +| **`maxIdleConnections`** | `false` | maxIdleConnections is the maximum number of connexions in the connexion pool. _(default: 1000)_ | +| **`keepAliveDuration`** | `false` | keepAliveDuration is the time in millisecond we keep the connexion open. _(default: 7200000 (2 hours))_ | diff --git a/providers/go-feature-flag/lombok.config b/providers/go-feature-flag/lombok.config new file mode 100644 index 000000000..bcd1afdae --- /dev/null +++ b/providers/go-feature-flag/lombok.config @@ -0,0 +1,5 @@ +# This file is needed to avoid errors throw by findbugs when working with lombok. +lombok.addSuppressWarnings = true +lombok.addLombokGeneratedAnnotation = true +config.stopBubbling = true +lombok.extern.findbugs.addSuppressFBWarnings = true diff --git a/providers/go-feature-flag/pom.xml b/providers/go-feature-flag/pom.xml new file mode 100644 index 000000000..80ab079c3 --- /dev/null +++ b/providers/go-feature-flag/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + dev.openfeature.contrib + parent + 0.0.2 + ../../pom.xml + + dev.openfeature.contrib.providers + go-feature-flag + 0.1.0 + + go-feature-flag + GO Feature Flag provider for Java + https://gofeatureflag.org + + + + thomaspoignant + Thomas Poignant + go-feature-flag + https://gofeatureflag.org + + + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + 2.13.4 + + + + com.fasterxml.jackson.core + jackson-databind + 2.13.4 + + + + com.squareup.okhttp3 + okhttp + 4.10.0 + + + + com.squareup.okhttp3 + mockwebserver + 4.10.0 + test + + + diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java new file mode 100644 index 000000000..33d7d1635 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProvider.java @@ -0,0 +1,312 @@ +package dev.openfeature.contrib.providers.gofeatureflag; + +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.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.InvalidEndpoint; +import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; +import dev.openfeature.javasdk.ErrorCode; +import dev.openfeature.javasdk.EvaluationContext; +import dev.openfeature.javasdk.FeatureProvider; +import dev.openfeature.javasdk.Hook; +import dev.openfeature.javasdk.Metadata; +import dev.openfeature.javasdk.ProviderEvaluation; +import dev.openfeature.javasdk.Reason; +import dev.openfeature.javasdk.Structure; +import dev.openfeature.javasdk.Value; +import dev.openfeature.javasdk.exceptions.FlagNotFoundError; +import dev.openfeature.javasdk.exceptions.GeneralError; +import dev.openfeature.javasdk.exceptions.OpenFeatureError; +import dev.openfeature.javasdk.exceptions.TypeMismatchError; +import okhttp3.ConnectionPool; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; + +/** + * GoFeatureFlagProvider is the JAVA provider implementation for the feature flag solution GO Feature Flag. + */ +public class GoFeatureFlagProvider implements FeatureProvider { + private static final String NAME = "GO Feature Flag Provider"; + private static final ObjectMapper requestMapper = new ObjectMapper(); + private static final ObjectMapper responseMapper = new ObjectMapper(); + private HttpUrl parsedEndpoint; + // httpClient is the instance of the OkHttpClient used by the provider + private OkHttpClient httpClient; + + /** + * Constructor of the provider. + * + * @param options - options to configure the provider + * @throws InvalidOptions - if options are invalid + */ + public GoFeatureFlagProvider(GoFeatureFlagProviderOptions options) throws InvalidOptions { + this.validateInputOptions(options); + this.initializeProvider(options); + } + + + /** + * validateInputOptions is validating the different options provided when creating the provider. + * + * @param options - Options used while creating the provider + * @throws InvalidOptions - if no options are provided + * @throws InvalidEndpoint - if the endpoint provided is not valid + */ + private void validateInputOptions(GoFeatureFlagProviderOptions options) throws InvalidEndpoint, InvalidOptions { + if (options == null) { + throw new InvalidOptions("No options provided"); + } + + if (options.getEndpoint() == null || "".equals(options.getEndpoint())) { + throw new InvalidEndpoint("endpoint is a mandatory field when initializing the provider"); + } + } + + /** + * initializeProvider is initializing the different class element used by the provider. + * + * @param options - Options used while creating the provider + */ + private void initializeProvider(GoFeatureFlagProviderOptions options) throws 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()); + + // init httpClient to call the GO Feature Flag API + int timeout = options.getTimeout() == 0 ? 10000 : options.getTimeout(); + long keepAliveDuration = options.getKeepAliveDuration() == null ? 7200000 : options.getKeepAliveDuration(); + int maxIdleConnections = options.getMaxIdleConnections() == 0 ? 1000 : options.getMaxIdleConnections(); + this.httpClient = new OkHttpClient.Builder() + .connectTimeout(timeout, TimeUnit.MILLISECONDS) + .readTimeout(timeout, TimeUnit.MILLISECONDS) + .callTimeout(timeout, TimeUnit.MILLISECONDS) + .readTimeout(timeout, TimeUnit.MILLISECONDS) + .writeTimeout(timeout, TimeUnit.MILLISECONDS) + .connectionPool(new ConnectionPool(maxIdleConnections, keepAliveDuration, TimeUnit.MILLISECONDS)) + .build(); + + this.parsedEndpoint = HttpUrl.parse(options.getEndpoint()); + if (this.parsedEndpoint == null) { + throw new InvalidEndpoint(); + } + } + + @Override + public Metadata getMetadata() { + return () -> NAME; + } + + @Override + public List getProviderHooks() { + return FeatureProvider.super.getProviderHooks(); + } + + + @Override + public ProviderEvaluation getBooleanEvaluation( + String key, Boolean defaultValue, EvaluationContext evaluationContext + ) { + return resolveEvaluationGoFeatureFlagProxy(key, defaultValue, evaluationContext, Boolean.class); + } + + @Override + public ProviderEvaluation getStringEvaluation( + String key, String defaultValue, EvaluationContext evaluationContext + ) { + return resolveEvaluationGoFeatureFlagProxy(key, defaultValue, evaluationContext, String.class); + } + + @Override + public ProviderEvaluation getIntegerEvaluation( + String key, Integer defaultValue, EvaluationContext evaluationContext + ) { + return resolveEvaluationGoFeatureFlagProxy(key, defaultValue, evaluationContext, Integer.class); + } + + @Override + public ProviderEvaluation getDoubleEvaluation( + String key, Double defaultValue, EvaluationContext evaluationContext + ) { + return resolveEvaluationGoFeatureFlagProxy(key, defaultValue, evaluationContext, Double.class); + } + + @Override + public ProviderEvaluation getObjectEvaluation( + String key, Value defaultValue, EvaluationContext evaluationContext + ) { + return resolveEvaluationGoFeatureFlagProxy(key, defaultValue, evaluationContext, Value.class); + } + + /** + * resolveEvaluationGoFeatureFlagProxy is calling the GO Feature Flag API to retrieve the flag value. + * + * @param key - name of the feature flag + * @param defaultValue - value used if something is not working as expected + * @param ctx - EvaluationContext used for the request + * @param expectedType - type expected for the value + * @return a ProviderEvaluation that contains the open-feature response + * @throws OpenFeatureError - if an error happen + */ + private ProviderEvaluation resolveEvaluationGoFeatureFlagProxy( + String key, T defaultValue, EvaluationContext ctx, Class expectedType + ) throws OpenFeatureError { + try { + GoFeatureFlagUser user = GoFeatureFlagUser.fromEvaluationContext(ctx); + GoFeatureFlagRequest goffRequest = new GoFeatureFlagRequest(user, defaultValue); + + HttpUrl url = this.parsedEndpoint.newBuilder() + .addEncodedPathSegment("v1") + .addEncodedPathSegment("feature") + .addEncodedPathSegment(key) + .addEncodedPathSegment("eval") + .build(); + + Request request = new Request.Builder() + .url(url) + .addHeader("Content-Type", "application/json") + .post(RequestBody.create( + requestMapper.writeValueAsBytes(goffRequest), + MediaType.get("application/json; charset=utf-8"))) + .build(); + + try (Response response = this.httpClient.newCall(request).execute()) { + if (response.code() >= HTTP_BAD_REQUEST) { + throw new GeneralError("impossible to contact GO Feature Flag relay proxy instance"); + } + + ResponseBody responseBody = response.body(); + String body = responseBody != null ? responseBody.string() : ""; + GoFeatureFlagResponse goffResp = + responseMapper.readValue(body, GoFeatureFlagResponse.class); + + if (Reason.DISABLED.name().equalsIgnoreCase(goffResp.getReason())) { + // we don't set a variant since we are using the default value, and we are not able to know + // which variant it is. + return ProviderEvaluation.builder().value(defaultValue).reason(Reason.DISABLED).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 + "."); + } + + return ProviderEvaluation.builder() + .errorCode(goffResp.getErrorCode()) + .reason(mapReason(goffResp.getReason())) + .value(flagValue) + .variant(goffResp.getVariationType()) + .build(); + + } + } catch (IOException e) { + throw new GeneralError("unknown error while retrieving flag " + key); + } + } + + + /** + * mapReason is mapping the reason in string received by the API to our internal SDK reason enum. + * + * @param reason - string of the reason received from the API + * @return an item from the enum + */ + private Reason mapReason(String reason) { + try { + return Reason.valueOf(reason); + } catch (IllegalArgumentException e) { + return Reason.UNKNOWN; + } + } + + + /** + * 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) { + return (T) value; + } + return (T) objectToValue(value); + } + + /** + * objectToValue is wrapping an object into a Value. + * + * @param object the object you want to wrap + * @return the wrapped object + */ + private Value objectToValue(Object object) { + if (object instanceof Value) { + return (Value) object; + } else if (object == null) { + return null; + } else if (object instanceof String) { + return new Value((String) object); + } else if (object instanceof Boolean) { + return new Value((Boolean) object); + } else if (object instanceof Integer) { + return new Value((Integer) object); + } else if (object instanceof Double) { + return new Value((Double) object); + } else if (object instanceof Structure) { + return new Value((Structure) object); + } else if (object instanceof List) { + // need to translate each elem in list to a value + return new Value(((List) object).stream().map(this::objectToValue).collect(Collectors.toList())); + } else if (object instanceof Instant) { + return new Value((Instant) object); + } else if (object instanceof Map) { + return new Value(mapToStructure((Map) object)); + } else { + throw new ClassCastException("Could not cast Object to Value"); + } + } + + /** + * mapToStructure transform a map coming from a JSON Object to a Structure type. + * + * @param map - JSON object return by the API + * @return a Structure object in the SDK format + */ + private Structure mapToStructure(Map map) { + return new Structure( + map.entrySet().stream() + .filter(e -> e.getValue() != null) + .collect(Collectors.toMap(Map.Entry::getKey, e -> objectToValue(e.getValue())))); + } +} 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 new file mode 100644 index 000000000..f35d8b8c7 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java @@ -0,0 +1,41 @@ +package dev.openfeature.contrib.providers.gofeatureflag; + +import lombok.Builder; +import lombok.Getter; + +/** + * GoFeatureFlagProviderOptions contains the options to initialise the provider. + */ +@Builder +public class GoFeatureFlagProviderOptions { + + /** + * (mandatory) endpoint contains the DNS of your GO Feature Flag relay proxy + * example: https://mydomain.com/gofeatureflagproxy/ + */ + @Getter + private String endpoint; + + /** + * (optional) timeout in millisecond we are waiting when calling the + * go-feature-flag relay proxy API. + * Default: 10000 ms + */ + @Getter + private int timeout; + + + /** + * (optional) maxIdleConnections is the maximum number of connexions in the connexion pool. + * Default: 1000 + */ + @Getter + private int maxIdleConnections; + + /** + * (optional) keepAliveDuration is the time in millisecond we keep the connexion open. + * Default: 7200000 (2 hours) + */ + @Getter + private Long keepAliveDuration; +} 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 new file mode 100644 index 000000000..a0a98321f --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/GoFeatureFlagRequest.java @@ -0,0 +1,16 @@ +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 new file mode 100644 index 000000000..75b229755 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/GoFeatureFlagResponse.java @@ -0,0 +1,23 @@ +package dev.openfeature.contrib.providers.gofeatureflag.bean; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +/** + * GoFeatureFlagResponse is the response returned by the relay proxy. + */ +@Getter +@Setter +@ToString +public class GoFeatureFlagResponse { + private boolean trackEvents; + private String variationType; + private boolean failed; + private String version; + private String reason; + private String errorCode; + private Object value; +} + + 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 new file mode 100644 index 000000000..874c07496 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/bean/GoFeatureFlagUser.java @@ -0,0 +1,43 @@ +package dev.openfeature.contrib.providers.gofeatureflag.bean; + +import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidTargetingKey; +import dev.openfeature.javasdk.EvaluationContext; +import dev.openfeature.javasdk.Value; +import lombok.Builder; +import lombok.Getter; + +import java.util.HashMap; +import java.util.Map; + +/** + * 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 || "".equals(key)) { + throw new InvalidTargetingKey(); + } + + Value anonymousValue = ctx.getValue(anonymousFieldName); + boolean anonymous = anonymousValue.asBoolean(); + Map custom = new HashMap<>(ctx.asObjectMap()); + if (ctx.getValue(anonymousFieldName) != null) { + custom.remove(anonymousFieldName); + } + return GoFeatureFlagUser.builder().anonymous(anonymous).key(key).custom(custom).build(); + } +} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/GoFeatureFlagException.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/GoFeatureFlagException.java new file mode 100644 index 000000000..272914a7b --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/GoFeatureFlagException.java @@ -0,0 +1,10 @@ +package dev.openfeature.contrib.providers.gofeatureflag.exception; + +import lombok.experimental.StandardException; + +/** + * GoFeatureFlagException is the main exception for the provider. + */ +@StandardException +public class GoFeatureFlagException extends Exception { +} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/InvalidEndpoint.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/InvalidEndpoint.java new file mode 100644 index 000000000..e1762929d --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/InvalidEndpoint.java @@ -0,0 +1,10 @@ +package dev.openfeature.contrib.providers.gofeatureflag.exception; + +import lombok.experimental.StandardException; + +/** + * InvalidEndpoint is thrown when we don't have any endpoint in the configuration. + */ +@StandardException +public class InvalidEndpoint extends InvalidOptions { +} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/InvalidOptions.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/InvalidOptions.java new file mode 100644 index 000000000..432d5a8cc --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/InvalidOptions.java @@ -0,0 +1,10 @@ +package dev.openfeature.contrib.providers.gofeatureflag.exception; + +import lombok.experimental.StandardException; + +/** + * InvalidOptions is the super Exception used when we have a configuration exception. + */ +@StandardException +public class InvalidOptions extends GoFeatureFlagException { +} diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/InvalidTargetingKey.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/InvalidTargetingKey.java new file mode 100644 index 000000000..506e5d689 --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/exception/InvalidTargetingKey.java @@ -0,0 +1,16 @@ +package dev.openfeature.contrib.providers.gofeatureflag.exception; + +import dev.openfeature.javasdk.ErrorCode; +import dev.openfeature.javasdk.exceptions.OpenFeatureError; +import lombok.experimental.StandardException; + +/** + * InvalidTargetingKey is the error send when we don't have a targeting key. + */ +@StandardException +public class InvalidTargetingKey extends OpenFeatureError { + public ErrorCode getErrorCode() { + // Should change as soon as we have a better error type. + return ErrorCode.GENERAL; + } +} 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 new file mode 100644 index 000000000..7220466c5 --- /dev/null +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -0,0 +1,291 @@ +package dev.openfeature.contrib.providers.gofeatureflag; + +import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidEndpoint; +import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions; +import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidTargetingKey; +import dev.openfeature.javasdk.EvaluationContext; +import dev.openfeature.javasdk.ProviderEvaluation; +import dev.openfeature.javasdk.Reason; +import dev.openfeature.javasdk.Structure; +import dev.openfeature.javasdk.Value; +import dev.openfeature.javasdk.exceptions.FlagNotFoundError; +import dev.openfeature.javasdk.exceptions.GeneralError; +import dev.openfeature.javasdk.exceptions.TypeMismatchError; +import lombok.SneakyThrows; +import okhttp3.HttpUrl; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class GoFeatureFlagProviderTest { + // Dispatcher is the configuration of the mock server to test the provider. + final Dispatcher dispatcher = new Dispatcher() { + @SneakyThrows + @Override + public MockResponse dispatch(RecordedRequest request) { + if (request.getPath().contains("fail_500")) { + return new MockResponse().setResponseCode(500); + } + if (request.getPath().startsWith("/v1/feature/")) { + String flagName = request.getPath().replace("/v1/feature/", "").replace("/eval", ""); + return new MockResponse() + .setResponseCode(200) + .setBody(readMockResponse(flagName + ".json")); + } + return new MockResponse().setResponseCode(404); + } + }; + private MockWebServer server; + private HttpUrl baseUrl; + private EvaluationContext evaluationContext; + + @BeforeEach + void beforeEach() throws IOException { + this.server = new MockWebServer(); + this.server.setDispatcher(dispatcher); + this.server.start(); + this.baseUrl = server.url(""); + + this.evaluationContext = new EvaluationContext(); + 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 Structure().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); + } + + @AfterEach + void afterEach() throws IOException { + this.server.close(); + this.server = null; + this.baseUrl = null; + } + + @Test + void getMetadata_validate_name() throws InvalidOptions { + assertEquals("GO Feature Flag Provider", new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()).getMetadata().getName()); + } + + @Test + void constructor_options_null() { + assertThrows(InvalidOptions.class, () -> new GoFeatureFlagProvider(null)); + } + + @Test + void constructor_options_empty() { + assertThrows(InvalidOptions.class, () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().build())); + } + + @Test + void constructor_options_empty_endpoint() { + assertThrows(InvalidEndpoint.class, () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint("").build())); + } + + @Test + void constructor_options_only_timeout() { + assertThrows(InvalidEndpoint.class, () -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().timeout(10000).build())); + } + + @Test + void constructor_options_valid_endpoint() { + assertDoesNotThrow(() -> new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint("http://localhost:1031").build())); + } + + @Test + void should_throw_an_error_if_endpoint_not_available() throws InvalidOptions { + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()); + assertThrows(GeneralError.class, () -> g.getBooleanEvaluation("fail_500", false, this.evaluationContext)); + } + + @Test + void should_throw_an_error_if_flag_does_not_exists() throws InvalidOptions { + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()); + assertThrows(FlagNotFoundError.class, () -> g.getBooleanEvaluation("flag_not_found", false, this.evaluationContext)); + } + + @Test + void should_throw_an_error_if_we_expect_a_boolean_and_got_another_type() throws InvalidOptions { + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()); + assertThrows(TypeMismatchError.class, () -> g.getBooleanEvaluation("string_key", false, this.evaluationContext)); + } + + @Test + void should_resolve_a_valid_boolean_flag_with_TARGETING_MATCH_reason() throws InvalidOptions { + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()); + ProviderEvaluation res = g.getBooleanEvaluation("bool_targeting_match", false, this.evaluationContext); + assertEquals(true, res.getValue()); + assertEquals("", res.getErrorCode()); + assertEquals(Reason.TARGETING_MATCH, res.getReason()); + assertEquals("True", res.getVariant()); + } + + @Test + void should_return_unknown_reason_if_not_exists_in_SDK() throws InvalidOptions { + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()); + ProviderEvaluation res = g.getBooleanEvaluation("unknown_reason", false, this.evaluationContext); + assertEquals(true, res.getValue()); + assertEquals("", res.getErrorCode()); + assertEquals(Reason.UNKNOWN, res.getReason()); + assertEquals("True", res.getVariant()); + } + + @Test + void should_use_boolean_default_value_if_the_flag_is_disabled() throws InvalidOptions { + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()); + ProviderEvaluation res = g.getBooleanEvaluation("disabled", false, this.evaluationContext); + assertEquals(false, res.getValue()); + assertEquals(Reason.DISABLED, res.getReason()); + } + + @Test + void should_throw_an_error_if_we_expect_a_string_and_got_another_type() throws InvalidOptions { + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()); + assertThrows(TypeMismatchError.class, () -> g.getStringEvaluation("bool_targeting_match", "defaultValue", this.evaluationContext)); + } + + @Test + void should_resolve_a_valid_string_flag_with_TARGETING_MATCH_reason() throws InvalidOptions { + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()); + ProviderEvaluation res = g.getStringEvaluation("string_key", "defaultValue", this.evaluationContext); + assertEquals("CC0000", res.getValue()); + assertEquals("", res.getErrorCode()); + assertEquals(Reason.TARGETING_MATCH, res.getReason()); + assertEquals("True", res.getVariant()); + } + + @Test + void should_use_string_default_value_if_the_flag_is_disabled() throws InvalidOptions { + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()); + ProviderEvaluation res = g.getStringEvaluation("disabled", "defaultValue", this.evaluationContext); + assertEquals("defaultValue", res.getValue()); + assertEquals(Reason.DISABLED, res.getReason()); + } + + @Test + void should_throw_an_error_if_we_expect_a_integer_and_got_another_type() throws InvalidOptions { + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()); + assertThrows(TypeMismatchError.class, () -> g.getIntegerEvaluation("string_key", 200, this.evaluationContext)); + } + + @Test + void should_resolve_a_valid_integer_flag_with_TARGETING_MATCH_reason() throws InvalidOptions { + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()); + ProviderEvaluation res = g.getIntegerEvaluation("integer_key", 1200, this.evaluationContext); + assertEquals(100, res.getValue()); + assertEquals("", res.getErrorCode()); + assertEquals(Reason.TARGETING_MATCH, res.getReason()); + assertEquals("True", res.getVariant()); + } + + @Test + void should_use_integer_default_value_if_the_flag_is_disabled() throws InvalidOptions { + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()); + ProviderEvaluation res = g.getIntegerEvaluation("disabled", 1225, this.evaluationContext); + assertEquals(1225, res.getValue()); + assertEquals(Reason.DISABLED, res.getReason()); + } + + @Test + void should_throw_an_error_if_we_expect_a_integer_and_double_type() throws InvalidOptions { + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()); + assertThrows(TypeMismatchError.class, () -> g.getIntegerEvaluation("double_key", 200, this.evaluationContext)); + } + + @Test + void should_resolve_a_valid_double_flag_with_TARGETING_MATCH_reason() throws InvalidOptions { + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()); + ProviderEvaluation res = g.getDoubleEvaluation("double_key", 1200.25, this.evaluationContext); + assertEquals(100.25, res.getValue()); + assertEquals("", res.getErrorCode()); + assertEquals(Reason.TARGETING_MATCH, res.getReason()); + assertEquals("True", res.getVariant()); + } + + @Test + void should_use_double_default_value_if_the_flag_is_disabled() throws InvalidOptions { + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()); + ProviderEvaluation res = g.getDoubleEvaluation("disabled", 1225.34, this.evaluationContext); + assertEquals(1225.34, res.getValue()); + assertEquals(Reason.DISABLED, res.getReason()); + } + + @Test + void should_resolve_a_valid_value_flag_with_TARGETING_MATCH_reason() throws InvalidOptions { + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()); + ProviderEvaluation res = g.getObjectEvaluation("object_key", null, this.evaluationContext); + Value want = new Value(new Structure().add("test", "test1").add("test2", false).add("test3", 123.3).add("test4", 1)); + assertEquals(want, res.getValue()); + assertEquals("", res.getErrorCode()); + assertEquals(Reason.TARGETING_MATCH, res.getReason()); + assertEquals("True", res.getVariant()); + } + + @Test + void should_wrap_into_value_if_wrong_type() throws InvalidOptions { + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()); + ProviderEvaluation res = g.getObjectEvaluation("string_key", null, this.evaluationContext); + Value want = new Value("CC0000"); + assertEquals(want, res.getValue()); + assertEquals("", res.getErrorCode()); + assertEquals(Reason.TARGETING_MATCH, res.getReason()); + assertEquals("True", res.getVariant()); + } + + @Test + void should_use_object_default_value_if_the_flag_is_disabled() throws InvalidOptions { + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()); + ProviderEvaluation res = g.getObjectEvaluation("disabled", new Value("default"), this.evaluationContext); + assertEquals(new Value("default"), res.getValue()); + assertEquals(Reason.DISABLED, res.getReason()); + } + + + @Test + void should_resolve_a_valid_value_flag_with_a_list() throws InvalidOptions { + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()); + assertThrows(InvalidTargetingKey.class, () -> g.getObjectEvaluation("list_key", null, new EvaluationContext())); + } + + @Test + void should_throw_an_error_if_no_targeting_key() throws InvalidOptions { + GoFeatureFlagProvider g = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder().endpoint(this.baseUrl.toString()).timeout(1000).build()); + ProviderEvaluation res = g.getObjectEvaluation("list_key", null, this.evaluationContext); + Value want = new Value(new ArrayList<>( + Arrays.asList(new Value("test"), + new Value("test1"), + new Value("test2"), + new Value("false"), + new Value("test3")))); + assertEquals(want, res.getValue()); + assertEquals("", res.getErrorCode()); + assertEquals(Reason.TARGETING_MATCH, res.getReason()); + assertEquals("True", res.getVariant()); + } + + private String readMockResponse(String filename) throws IOException { + String file = getClass().getClassLoader().getResource("mock_responses/" + filename).getFile(); + byte[] bytes = Files.readAllBytes(Paths.get(file)); + return new String(bytes); + } +} diff --git a/providers/go-feature-flag/src/test/resources/mock_responses/bool_targeting_match.json b/providers/go-feature-flag/src/test/resources/mock_responses/bool_targeting_match.json new file mode 100644 index 000000000..dd20b510c --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/mock_responses/bool_targeting_match.json @@ -0,0 +1,9 @@ +{ + "trackEvents": true, + "variationType": "True", + "failed": false, + "version": 0, + "reason": "TARGETING_MATCH", + "errorCode": "", + "value": true +} diff --git a/providers/go-feature-flag/src/test/resources/mock_responses/disabled.json b/providers/go-feature-flag/src/test/resources/mock_responses/disabled.json new file mode 100644 index 000000000..85a9949d0 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/mock_responses/disabled.json @@ -0,0 +1,9 @@ +{ + "trackEvents": true, + "variationType": "defaultSdk", + "failed": false, + "version": 0, + "reason": "DISABLED", + "errorCode": "", + "value": true +} diff --git a/providers/go-feature-flag/src/test/resources/mock_responses/double_key.json b/providers/go-feature-flag/src/test/resources/mock_responses/double_key.json new file mode 100644 index 000000000..d6bb81828 --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/mock_responses/double_key.json @@ -0,0 +1,9 @@ +{ + "trackEvents": true, + "variationType": "True", + "failed": false, + "version": 0, + "reason": "TARGETING_MATCH", + "errorCode": "", + "value": 100.25 +} diff --git a/providers/go-feature-flag/src/test/resources/mock_responses/flag_not_found.json b/providers/go-feature-flag/src/test/resources/mock_responses/flag_not_found.json new file mode 100644 index 000000000..209436b4f --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/mock_responses/flag_not_found.json @@ -0,0 +1,9 @@ +{ + "trackEvents": true, + "variationType": "SdkDefault", + "failed": true, + "version": 0, + "reason": "ERROR", + "errorCode": "FLAG_NOT_FOUND", + "value": "false" +} diff --git a/providers/go-feature-flag/src/test/resources/mock_responses/integer_key.json b/providers/go-feature-flag/src/test/resources/mock_responses/integer_key.json new file mode 100644 index 000000000..d6ddc655b --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/mock_responses/integer_key.json @@ -0,0 +1,9 @@ +{ + "trackEvents": true, + "variationType": "True", + "failed": false, + "version": 0, + "reason": "TARGETING_MATCH", + "errorCode": "", + "value": 100 +} diff --git a/providers/go-feature-flag/src/test/resources/mock_responses/list_key.json b/providers/go-feature-flag/src/test/resources/mock_responses/list_key.json new file mode 100644 index 000000000..0e0e3965b --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/mock_responses/list_key.json @@ -0,0 +1,15 @@ +{ + "trackEvents": true, + "variationType": "True", + "failed": false, + "version": 0, + "reason": "TARGETING_MATCH", + "errorCode": "", + "value": [ + "test", + "test1", + "test2", + "false", + "test3" + ] +} diff --git a/providers/go-feature-flag/src/test/resources/mock_responses/object_key.json b/providers/go-feature-flag/src/test/resources/mock_responses/object_key.json new file mode 100644 index 000000000..27db6711b --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/mock_responses/object_key.json @@ -0,0 +1,15 @@ +{ + "trackEvents": true, + "variationType": "True", + "failed": false, + "version": 0, + "reason": "TARGETING_MATCH", + "errorCode": "", + "value": { + "test": "test1", + "test2": false, + "test3": 123.3, + "test4": 1, + "test5": null + } +} diff --git a/providers/go-feature-flag/src/test/resources/mock_responses/string_key.json b/providers/go-feature-flag/src/test/resources/mock_responses/string_key.json new file mode 100644 index 000000000..53683df0a --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/mock_responses/string_key.json @@ -0,0 +1,9 @@ +{ + "trackEvents": true, + "variationType": "True", + "failed": false, + "version": 0, + "reason": "TARGETING_MATCH", + "errorCode": "", + "value": "CC0000" +} diff --git a/providers/go-feature-flag/src/test/resources/mock_responses/unknown_reason.json b/providers/go-feature-flag/src/test/resources/mock_responses/unknown_reason.json new file mode 100644 index 000000000..ff025b40a --- /dev/null +++ b/providers/go-feature-flag/src/test/resources/mock_responses/unknown_reason.json @@ -0,0 +1,9 @@ +{ + "trackEvents": true, + "variationType": "True", + "failed": false, + "version": 0, + "reason": "CUSTOM_REASON", + "errorCode": "", + "value": true +}