diff --git a/.github/component_owners.yml b/.github/component_owners.yml index 4372c0a2a..f8af4ddf6 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -19,6 +19,9 @@ components: - thomaspoignant providers/jsonlogic-eval-provider: - justinabrahms + providers/unleash: + - liran2000 + - sighphyre ignored-authors: - renovate-bot \ No newline at end of file diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 58707aee5..ea06aa0cd 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -4,5 +4,6 @@ "providers/go-feature-flag": "0.2.13", "providers/flagsmith": "0.0.8", "providers/env-var": "0.0.4", - "providers/jsonlogic-eval-provider": "1.0.0" + "providers/jsonlogic-eval-provider": "1.0.0", + "providers/unleash": "0.0.1-alpha" } \ No newline at end of file diff --git a/pom.xml b/pom.xml index d96b4609d..b2b9253f9 100644 --- a/pom.xml +++ b/pom.xml @@ -33,6 +33,7 @@ providers/go-feature-flag providers/jsonlogic-eval-provider providers/env-var + providers/unleash diff --git a/providers/unleash/CHANGELOG.md b/providers/unleash/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/providers/unleash/README.md b/providers/unleash/README.md new file mode 100644 index 000000000..5b0e53b3e --- /dev/null +++ b/providers/unleash/README.md @@ -0,0 +1,62 @@ +# Unofficial Unleash OpenFeature Provider for Java + +[Unleash](https://getunleash.io) OpenFeature Provider can provide usage for Unleash via OpenFeature Java SDK. + +## Installation + + + +```xml + + + dev.openfeature.contrib.providers + unleash + 0.0.1-alpha + +``` + + + +## Concepts +* Boolean evaluation gets feature enabled status. +* String evaluation gets feature variant value. + +## Usage +Unleash OpenFeature Provider is using Unleash Java SDK. + +### Usage Example + +``` +FeatureProvider unleashProvider = new UnleashProvider(unleashProviderConfig); +OpenFeatureAPI.getInstance().setProviderAndWait(unleashProvider); +boolean featureEnabled = client.getBooleanValue(FLAG_NAME, false); + +// Context parameters are optional, not mandatory to fill all parameters +MutableContext evaluationContext = new MutableContext(); +evaluationContext.add("userId", userIdValue); +evaluationContext.add("currentTime", String.valueOf(currentTimeValue)); +evaluationContext.add("sessionId", sessionIdValue); +evaluationContext.add("remoteAddress", remoteAddressValue); +evaluationContext.add("environment", environmentValue); +evaluationContext.add("appName", appNameValue); +evaluationContext.add(customPropertyKey, customPropertyValue); +featureEnabled = client.getBooleanValue(FLAG_NAME, false, evaluationContext); + +String variantValue = client.getStringValue(FLAG_NAME, ""); +``` + +See [UnleashProviderTest.java](./src/test/java/dev/openfeature/contrib/providers/unleash/UnleashProviderTest.java) for more information. + +### Additional Usage Details + +* When default value is used and returned, default variant is not used and variant name is not set. +* json/csv payloads are evaluated via object evaluation as what returned from Unleash - string, wrapped with Value. +* Additional evaluation data can be received via flag metadata, such as: + * *enabled* - boolean + * *variant-stickiness* - string + * *payload-type* - string, optional + +## Unleash Provider Tests Strategies + +Unit test based on Unleash instance with Unleash features schema file, with WireMock for API mocking. +See [UnleashProviderTest.java](./src/test/java/dev/openfeature/contrib/providers/unleash/UnleashProviderTest.java) for more information. diff --git a/providers/unleash/lombok.config b/providers/unleash/lombok.config new file mode 100644 index 000000000..bcd1afdae --- /dev/null +++ b/providers/unleash/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/unleash/pom.xml b/providers/unleash/pom.xml new file mode 100644 index 000000000..6085aeeed --- /dev/null +++ b/providers/unleash/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + dev.openfeature.contrib + parent + 0.1.0 + ../../pom.xml + + dev.openfeature.contrib.providers + unleash + 0.0.1-alpha + + unleash + unleash provider for Java + https://www.getunleash.io/ + + + + io.getunleash + unleash-client-java + 8.3.1 + + + + org.slf4j + slf4j-api + 2.0.9 + + + + com.github.tomakehurst + wiremock-jre8 + 2.35.1 + test + + + + org.apache.logging.log4j + log4j-slf4j2-impl + 2.20.0 + test + + + + diff --git a/providers/unleash/src/main/java/dev/openfeature/contrib/providers/unleash/ContextTransformer.java b/providers/unleash/src/main/java/dev/openfeature/contrib/providers/unleash/ContextTransformer.java new file mode 100644 index 000000000..2e8f68806 --- /dev/null +++ b/providers/unleash/src/main/java/dev/openfeature/contrib/providers/unleash/ContextTransformer.java @@ -0,0 +1,50 @@ +package dev.openfeature.contrib.providers.unleash; + +import dev.openfeature.sdk.EvaluationContext; +import io.getunleash.UnleashContext; + +import java.time.ZonedDateTime; + +/** + * Transformer from Unleash context to OpenFeature context and vice versa. + */ +public class ContextTransformer { + + public static final String CONTEXT_APP_NAME = "appName"; + public static final String CONTEXT_USER_ID = "userId"; + public static final String CONTEXT_ENVIRONMENT = "environment"; + public static final String CONTEXT_REMOTE_ADDRESS = "remoteAddress"; + public static final String CONTEXT_SESSION_ID = "sessionId"; + public static final String CONTEXT_CURRENT_TIME = "currentTime"; + + protected static UnleashContext transform(EvaluationContext ctx) { + UnleashContext.Builder unleashContextBuilder = new UnleashContext.Builder(); + ctx.asObjectMap().forEach((k, v) -> { + switch (k) { + case CONTEXT_APP_NAME: + unleashContextBuilder.appName(String.valueOf(v)); + break; + case CONTEXT_USER_ID: + unleashContextBuilder.userId(String.valueOf(v)); + break; + case CONTEXT_ENVIRONMENT: + unleashContextBuilder.environment(String.valueOf(v)); + break; + case CONTEXT_REMOTE_ADDRESS: + unleashContextBuilder.remoteAddress(String.valueOf(v)); + break; + case CONTEXT_SESSION_ID: + unleashContextBuilder.sessionId(String.valueOf(v)); + break; + case CONTEXT_CURRENT_TIME: + unleashContextBuilder.currentTime(ZonedDateTime.parse(String.valueOf(v))); + break; + default: + unleashContextBuilder.addProperty(k, String.valueOf(v)); + break; + } + }); + return unleashContextBuilder.build(); + } + +} diff --git a/providers/unleash/src/main/java/dev/openfeature/contrib/providers/unleash/UnleashProvider.java b/providers/unleash/src/main/java/dev/openfeature/contrib/providers/unleash/UnleashProvider.java new file mode 100644 index 000000000..380a3979c --- /dev/null +++ b/providers/unleash/src/main/java/dev/openfeature/contrib/providers/unleash/UnleashProvider.java @@ -0,0 +1,214 @@ +package dev.openfeature.contrib.providers.unleash; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.EventProvider; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.Metadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEventDetails; +import dev.openfeature.sdk.ProviderState; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.GeneralError; +import dev.openfeature.sdk.exceptions.ProviderNotReadyError; +import io.getunleash.DefaultUnleash; +import io.getunleash.Unleash; +import io.getunleash.UnleashContext; +import io.getunleash.Variant; +import io.getunleash.util.UnleashConfig; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static io.getunleash.Variant.DISABLED_VARIANT; + +/** + * Provider implementation for Unleash. + */ +@Slf4j +public class UnleashProvider extends EventProvider { + + @Getter + private static final String NAME = "Unleash"; + public static final String NOT_IMPLEMENTED = + "Not implemented - provider does not support this type. Only boolean is supported."; + + public static final String PROVIDER_NOT_YET_INITIALIZED = "provider not yet initialized"; + public static final String UNKNOWN_ERROR = "unknown error"; + + @Getter(AccessLevel.PROTECTED) + private UnleashProviderConfig unleashProviderConfig; + + @Setter(AccessLevel.PROTECTED) + @Getter + private Unleash unleash; + + @Setter(AccessLevel.PROTECTED) + @Getter + private ProviderState state = ProviderState.NOT_READY; + + private AtomicBoolean isInitialized = new AtomicBoolean(false); + + /** + * Constructor. + * @param unleashProviderConfig UnleashProviderConfig + */ + public UnleashProvider(UnleashProviderConfig unleashProviderConfig) { + this.unleashProviderConfig = unleashProviderConfig; + } + + /** + * Initialize the provider. + * @param evaluationContext evaluation context + * @throws Exception on error + */ + @Override + public void initialize(EvaluationContext evaluationContext) throws Exception { + boolean initialized = isInitialized.getAndSet(true); + if (initialized) { + throw new GeneralError("already initialized"); + } + super.initialize(evaluationContext); + UnleashSubscriberWrapper unleashSubscriberWrapper = new UnleashSubscriberWrapper( + unleashProviderConfig.getUnleashConfigBuilder().build().getSubscriber(), this); + unleashProviderConfig.getUnleashConfigBuilder().subscriber(unleashSubscriberWrapper); + UnleashConfig unleashConfig = unleashProviderConfig.getUnleashConfigBuilder().build(); + unleash = new DefaultUnleash(unleashConfig); + + // Unleash is per definition ready after it is initialized. + state = ProviderState.READY; + log.info("finished initializing provider, state: {}", state); + } + + @Override + public Metadata getMetadata() { + return () -> NAME; + } + + @Override + public void emitProviderReady(ProviderEventDetails details) { + super.emitProviderReady(details); + state = ProviderState.READY; + } + + @Override + public void emitProviderError(ProviderEventDetails details) { + super.emitProviderError(details); + state = ProviderState.ERROR; + } + + @Override + public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { + if (!ProviderState.READY.equals(state)) { + if (ProviderState.NOT_READY.equals(state)) { + throw new ProviderNotReadyError(PROVIDER_NOT_YET_INITIALIZED); + } + throw new GeneralError(UNKNOWN_ERROR); + } + UnleashContext context = ctx == null ? UnleashContext.builder().build() : ContextTransformer.transform(ctx); + boolean featureBooleanValue = unleash.isEnabled(key, context, defaultValue); + return ProviderEvaluation.builder() + .value(featureBooleanValue) + .build(); + } + + @Override + public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { + ProviderEvaluation valueProviderEvaluation = getObjectEvaluation(key, new Value(defaultValue), ctx); + return ProviderEvaluation.builder() + .value(valueProviderEvaluation.getValue().asString()) + .variant(valueProviderEvaluation.getVariant()) + .errorCode(valueProviderEvaluation.getErrorCode()) + .reason(valueProviderEvaluation.getReason()) + .flagMetadata(valueProviderEvaluation.getFlagMetadata()) + .build(); + } + + @Override + public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { + ProviderEvaluation valueProviderEvaluation = getObjectEvaluation(key, new Value(defaultValue), ctx); + Integer value = getIntegerValue(valueProviderEvaluation, defaultValue); + return ProviderEvaluation.builder() + .value(value) + .variant(valueProviderEvaluation.getVariant()) + .errorCode(valueProviderEvaluation.getErrorCode()) + .reason(valueProviderEvaluation.getReason()) + .flagMetadata(valueProviderEvaluation.getFlagMetadata()) + .build(); + } + + private static Integer getIntegerValue(ProviderEvaluation valueProviderEvaluation, Integer defaultValue) { + String valueStr = valueProviderEvaluation.getValue().asObject().toString(); + try { + return Integer.parseInt(valueStr); + } catch (NumberFormatException ex) { + return defaultValue; + } + } + + @Override + public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { + ProviderEvaluation valueProviderEvaluation = getObjectEvaluation(key, new Value(defaultValue), ctx); + Double value = getDoubleValue(valueProviderEvaluation, defaultValue); + return ProviderEvaluation.builder() + .value(value) + .variant(valueProviderEvaluation.getVariant()) + .errorCode(valueProviderEvaluation.getErrorCode()) + .reason(valueProviderEvaluation.getReason()) + .flagMetadata(valueProviderEvaluation.getFlagMetadata()) + .build(); + } + + private static Double getDoubleValue(ProviderEvaluation valueProviderEvaluation, Double defaultValue) { + String valueStr = valueProviderEvaluation.getValue().asObject().toString(); + try { + return Double.parseDouble(valueStr); + } catch (NumberFormatException ex) { + return defaultValue; + } + } + + @Override + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { + if (!ProviderState.READY.equals(state)) { + if (ProviderState.NOT_READY.equals(state)) { + throw new ProviderNotReadyError(PROVIDER_NOT_YET_INITIALIZED); + } + throw new GeneralError(UNKNOWN_ERROR); + } + UnleashContext context = ctx == null ? UnleashContext.builder().build() : ContextTransformer.transform(ctx); + Variant evaluatedVariant = unleash.getVariant(key, context); + String variantName; + Value value; + if (DISABLED_VARIANT.equals(evaluatedVariant)) { + variantName = null; + value = defaultValue; + } else { + variantName = evaluatedVariant.getName(); + value = evaluatedVariant.getPayload().map(p -> new Value(p.getValue())).orElse(null); + } + ImmutableMetadata.ImmutableMetadataBuilder flagMetadataBuilder = ImmutableMetadata.builder() + .addString("variant-stickiness", evaluatedVariant.getStickiness()); + flagMetadataBuilder.addBoolean("enabled", evaluatedVariant.isEnabled()); + if (evaluatedVariant.getPayload().isPresent()) { + flagMetadataBuilder.addString("payload-type", evaluatedVariant.getPayload().get().getType()); + } + return ProviderEvaluation.builder() + .value(value) + .variant(variantName) + .flagMetadata(flagMetadataBuilder.build()) + .build(); + } + + @Override + public void shutdown() { + super.shutdown(); + log.info("shutdown"); + if (unleash != null) { + unleash.shutdown(); + } + state = ProviderState.NOT_READY; + } +} diff --git a/providers/unleash/src/main/java/dev/openfeature/contrib/providers/unleash/UnleashProviderConfig.java b/providers/unleash/src/main/java/dev/openfeature/contrib/providers/unleash/UnleashProviderConfig.java new file mode 100644 index 000000000..39cb9b87e --- /dev/null +++ b/providers/unleash/src/main/java/dev/openfeature/contrib/providers/unleash/UnleashProviderConfig.java @@ -0,0 +1,15 @@ +package dev.openfeature.contrib.providers.unleash; + +import io.getunleash.util.UnleashConfig; +import lombok.Builder; +import lombok.Getter; + + +/** + * Options for initializing Unleash provider. + */ +@Getter +@Builder +public class UnleashProviderConfig { + private UnleashConfig.Builder unleashConfigBuilder; +} diff --git a/providers/unleash/src/main/java/dev/openfeature/contrib/providers/unleash/UnleashSubscriberWrapper.java b/providers/unleash/src/main/java/dev/openfeature/contrib/providers/unleash/UnleashSubscriberWrapper.java new file mode 100644 index 000000000..93e9e945a --- /dev/null +++ b/providers/unleash/src/main/java/dev/openfeature/contrib/providers/unleash/UnleashSubscriberWrapper.java @@ -0,0 +1,122 @@ +package dev.openfeature.contrib.providers.unleash; + +import dev.openfeature.sdk.EventProvider; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.ProviderEventDetails; +import io.getunleash.UnleashException; +import io.getunleash.event.ImpressionEvent; +import io.getunleash.event.ToggleEvaluated; +import io.getunleash.event.UnleashEvent; +import io.getunleash.event.UnleashReady; +import io.getunleash.event.UnleashSubscriber; +import io.getunleash.metric.ClientMetrics; +import io.getunleash.metric.ClientRegistration; +import io.getunleash.repository.FeatureCollection; +import io.getunleash.repository.FeatureToggleResponse; +import io.getunleash.repository.ToggleCollection; +import lombok.Generated; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; + +/** + * UnleashSubscriber wrapper for emitting event provider events. + */ +@Slf4j +@Generated +public class UnleashSubscriberWrapper implements UnleashSubscriber { + + private UnleashSubscriber unleashSubscriber; + private EventProvider eventProvider; + + /** + * Constructor. + * @param unleashSubscriber subscriber + * @param eventProvider events provider for emitting events. + */ + public UnleashSubscriberWrapper(@Nullable UnleashSubscriber unleashSubscriber, EventProvider eventProvider) { + this.unleashSubscriber = unleashSubscriber; + this.eventProvider = eventProvider; + } + + @Override + public void onError(UnleashException unleashException) { + unleashSubscriber.onError(unleashException); + log.info("unleashException: ", unleashException); + + // Not emitting provider error, since some unleashException not expects to change provider state to error + } + + @Override + public void on(UnleashEvent unleashEvent) { + unleashSubscriber.on(unleashEvent); + } + + @Override + public void onReady(UnleashReady unleashReady) { + unleashSubscriber.onReady(unleashReady); + eventProvider.emitProviderReady(ProviderEventDetails.builder() + .eventMetadata(ImmutableMetadata.builder() + .build()).build()); + } + + @Override + public void toggleEvaluated(ToggleEvaluated toggleEvaluated) { + unleashSubscriber.toggleEvaluated(toggleEvaluated); + } + + @Override + public void togglesFetched(FeatureToggleResponse toggleResponse) { + unleashSubscriber.togglesFetched(toggleResponse); + if (FeatureToggleResponse.Status.CHANGED.equals(toggleResponse.getStatus())) { + eventProvider.emitProviderConfigurationChanged(ProviderEventDetails.builder() + .eventMetadata(ImmutableMetadata.builder() + .build()).build()); + } + } + + @Override + public void clientMetrics(ClientMetrics clientMetrics) { + unleashSubscriber.clientMetrics(clientMetrics); + } + + @Override + public void clientRegistered(ClientRegistration clientRegistration) { + unleashSubscriber.clientRegistered(clientRegistration); + } + + @Override + public void togglesBackedUp(ToggleCollection toggleCollection) { + unleashSubscriber.togglesBackedUp(toggleCollection); + } + + @Override + public void toggleBackupRestored(ToggleCollection toggleCollection) { + unleashSubscriber.toggleBackupRestored(toggleCollection); + } + + @Override + public void togglesBootstrapped(ToggleCollection toggleCollection) { + unleashSubscriber.togglesBootstrapped(toggleCollection); + } + + @Override + public void featuresBootstrapped(FeatureCollection featureCollection) { + unleashSubscriber.featuresBootstrapped(featureCollection); + } + + @Override + public void featuresBackedUp(FeatureCollection featureCollection) { + unleashSubscriber.featuresBackedUp(featureCollection); + } + + @Override + public void featuresBackupRestored(FeatureCollection featureCollection) { + unleashSubscriber.featuresBackupRestored(featureCollection); + } + + @Override + public void impression(ImpressionEvent impressionEvent) { + unleashSubscriber.impression(impressionEvent); + } +} diff --git a/providers/unleash/src/test/java/dev/openfeature/contrib/providers/unleash/UnleashProviderTest.java b/providers/unleash/src/test/java/dev/openfeature/contrib/providers/unleash/UnleashProviderTest.java new file mode 100644 index 000000000..915b38e56 --- /dev/null +++ b/providers/unleash/src/test/java/dev/openfeature/contrib/providers/unleash/UnleashProviderTest.java @@ -0,0 +1,323 @@ +package dev.openfeature.contrib.providers.unleash; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEventDetails; +import dev.openfeature.sdk.ProviderState; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.GeneralError; +import dev.openfeature.sdk.exceptions.ProviderNotReadyError; +import io.getunleash.UnleashContext; +import io.getunleash.UnleashException; +import io.getunleash.event.ToggleEvaluated; +import io.getunleash.event.UnleashEvent; +import io.getunleash.event.UnleashSubscriber; +import io.getunleash.repository.FeatureToggleResponse; +import io.getunleash.util.UnleashConfig; +import lombok.SneakyThrows; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import java.net.URI; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.any; +import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * UnleashProvider test, based on APIs mocking. + * Inspired by Unleash tests. + */ +@WireMockTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class UnleashProviderTest { + + public static final String FLAG_NAME = "variant-flag"; + public static final String VARIANT_FLAG_NAME = "variant-flag"; + public static final String VARIANT_FLAG_VALUE = "v1"; + public static final String INT_FLAG_NAME = "int-flag"; + public static final Integer INT_FLAG_VALUE = 123; + public static final String DOUBLE_FLAG_NAME = "double-flag"; + public static final Double DOUBLE_FLAG_VALUE = 1.23; + public static final String USERS_FLAG_NAME = "users-flag"; + public static final String JSON_VARIANT_FLAG_NAME = "json-flag"; + public static final String JSON_VARIANT_FLAG_VALUE = "{ a: 1 }"; + public static final String CSV_VARIANT_FLAG_NAME = "csv-flag"; + public static final String CSV_VARIANT_FLAG_VALUE = "a,b,c"; + private static UnleashProvider unleashProvider; + private static Client client; + + @BeforeAll + void setUp(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(any(anyUrl()).willReturn(aResponse() + .withStatus(200) + .withBody("{}"))); + String unleashAPI = "http://localhost:" + wmRuntimeInfo.getHttpPort() + "/api/"; + String backupFileContent = readBackupFile(); + mockUnleashAPI(backupFileContent); + unleashProvider = buildUnleashProvider(true, unleashAPI, new TestSubscriber()); + OpenFeatureAPI.getInstance().setProviderAndWait("sync", unleashProvider); + client = OpenFeatureAPI.getInstance().getClient("sync"); + } + + @AfterAll + public void shutdown() { + unleashProvider.shutdown(); + } + + private void mockUnleashAPI(String backupFileContent) { + stubFor( + get(urlEqualTo("/api/client/features")) + .withHeader("Accept", equalTo("application/json")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(backupFileContent))); + stubFor(post(urlEqualTo("/api/client/register")).willReturn(aResponse().withStatus(200))); + } + + @SneakyThrows + private UnleashProvider buildUnleashProvider(boolean synchronousFetchOnInitialisation, String unleashAPI, TestSubscriber testSubscriber) { + UnleashConfig.Builder unleashConfigBuilder = + UnleashConfig.builder().unleashAPI(new URI(unleashAPI)) + .appName("fakeApp") + .subscriber(testSubscriber) + .synchronousFetchOnInitialisation(synchronousFetchOnInitialisation); + + UnleashProviderConfig unleashProviderConfig = UnleashProviderConfig.builder() + .unleashConfigBuilder(unleashConfigBuilder) + .build(); + return new UnleashProvider(unleashProviderConfig); + } + + @SneakyThrows + private String readBackupFile() { + URL url = getClass().getResource("/features.json"); + return new String(Files.readAllBytes(Paths.get(url.toURI()))); + } + + @Test + void getBooleanEvaluation() { + assertEquals(true, unleashProvider.getBooleanEvaluation(FLAG_NAME, false, new ImmutableContext()).getValue()); + assertEquals(true, client.getBooleanValue(FLAG_NAME, false)); + assertEquals(false, unleashProvider.getBooleanEvaluation("non-existing", false, new ImmutableContext()).getValue()); + assertEquals(false, client.getBooleanValue("non-existing", false)); + } + + @Test + void getStringVariantEvaluation() { + assertEquals(VARIANT_FLAG_VALUE, unleashProvider.getStringEvaluation(VARIANT_FLAG_NAME, "", + new ImmutableContext()).getValue()); + assertEquals(VARIANT_FLAG_VALUE, client.getStringValue(VARIANT_FLAG_NAME, "")); + assertEquals("fallback_str", unleashProvider.getStringEvaluation("non-existing", + "fallback_str", new ImmutableContext()).getValue()); + assertEquals("fallback_str", client.getStringValue("non-existing", "fallback_str")); + } + + @Test + void getIntegerEvaluation() { + MutableContext evaluationContext = new MutableContext(); + evaluationContext.add("userId", "int"); + assertEquals(INT_FLAG_VALUE, unleashProvider.getIntegerEvaluation(INT_FLAG_NAME, 1, + evaluationContext).getValue()); + assertEquals(INT_FLAG_VALUE, client.getIntegerValue(INT_FLAG_NAME, 1)); + assertEquals(1, client.getIntegerValue("non-existing", 1)); + + // non-number flag value + assertEquals(1, client.getIntegerValue(VARIANT_FLAG_NAME, 1)); + } + + @Test + void getDoubleEvaluation() { + MutableContext evaluationContext = new MutableContext(); + evaluationContext.add("userId", "double"); + assertEquals(DOUBLE_FLAG_VALUE, unleashProvider.getDoubleEvaluation(DOUBLE_FLAG_NAME, 1.1, + evaluationContext).getValue()); + assertEquals(DOUBLE_FLAG_VALUE, client.getDoubleValue(DOUBLE_FLAG_NAME, 1.1)); + assertEquals(1.1, client.getDoubleValue("non-existing", 1.1)); + + // non-number flag value + assertEquals(1.1, client.getDoubleValue(VARIANT_FLAG_NAME, 1.1)); + } + + @Test + void getJsonVariantEvaluation() { + assertEquals(JSON_VARIANT_FLAG_VALUE, unleashProvider.getObjectEvaluation(JSON_VARIANT_FLAG_NAME, new Value(""), + new ImmutableContext()).getValue().asString()); + assertEquals(new Value(JSON_VARIANT_FLAG_VALUE), client.getObjectValue(JSON_VARIANT_FLAG_NAME, new Value(""))); + assertEquals("fallback_str", unleashProvider.getObjectEvaluation("non-existing", + new Value("fallback_str"), new ImmutableContext()).getValue().asString()); + assertEquals(new Value("fallback_str"), client.getObjectValue("non-existing", new Value("fallback_str"))); + } + + @Test + void getCSVVariantEvaluation() { + assertEquals(CSV_VARIANT_FLAG_VALUE, unleashProvider.getObjectEvaluation(CSV_VARIANT_FLAG_NAME, new Value(""), + new ImmutableContext()).getValue().asString()); + assertEquals(new Value(CSV_VARIANT_FLAG_VALUE), client.getObjectValue(CSV_VARIANT_FLAG_NAME, new Value(""))); + assertEquals("fallback_str", unleashProvider.getObjectEvaluation("non-existing", + new Value("fallback_str"), new ImmutableContext()).getValue().asString()); + assertEquals(new Value("fallback_str"), client.getObjectValue("non-existing", new Value("fallback_str"))); + } + + @Test + void getBooleanEvaluationByUser() { + MutableContext evaluationContext = new MutableContext(); + evaluationContext.add("userId", "111"); + assertEquals(true, unleashProvider.getBooleanEvaluation(USERS_FLAG_NAME, false, evaluationContext).getValue()); + assertEquals(true, client.getBooleanValue(USERS_FLAG_NAME, false, evaluationContext)); + evaluationContext.add("userId", "2"); + assertEquals(false, unleashProvider.getBooleanEvaluation(USERS_FLAG_NAME, false, evaluationContext).getValue()); + assertEquals(false, client.getBooleanValue(USERS_FLAG_NAME, false, evaluationContext)); + } + + @Test + void getEvaluationMetadataTest() { + ProviderEvaluation stringEvaluation = unleashProvider.getStringEvaluation(VARIANT_FLAG_NAME, "", + new ImmutableContext()); + ImmutableMetadata flagMetadata = stringEvaluation.getFlagMetadata(); + assertEquals("default", flagMetadata.getString("variant-stickiness")); + assertEquals("string", flagMetadata.getString("payload-type")); + assertEquals(true, flagMetadata.getBoolean("enabled")); + ProviderEvaluation nonExistingFlagEvaluation = unleashProvider.getStringEvaluation("non-existing", + "", new ImmutableContext()); + assertEquals(false, nonExistingFlagEvaluation.getFlagMetadata().getBoolean("enabled")); + } + + @SneakyThrows + @Test + void shouldThrowIfNotInitialized() { + UnleashProvider asyncInitUnleashProvider = buildUnleashProvider(false, "http://fakeAPI", new TestSubscriber()); + assertEquals(ProviderState.NOT_READY, asyncInitUnleashProvider.getState()); + + // ErrorCode.PROVIDER_NOT_READY should be returned when evaluated via the client + assertThrows(ProviderNotReadyError.class, ()-> asyncInitUnleashProvider.getBooleanEvaluation("fail_not_initialized", false, new ImmutableContext())); + assertThrows(ProviderNotReadyError.class, ()-> asyncInitUnleashProvider.getStringEvaluation("fail_not_initialized", "", new ImmutableContext())); + + asyncInitUnleashProvider.initialize(null); + assertThrows(GeneralError.class, ()-> asyncInitUnleashProvider.initialize(null)); + + asyncInitUnleashProvider.shutdown(); + } + + @SneakyThrows + @Test + void shouldThrowIfErrorEvent() { + UnleashProvider asyncInitUnleashProvider = buildUnleashProvider(false, "http://fakeAPI", null); + asyncInitUnleashProvider.initialize(new ImmutableContext()); + + asyncInitUnleashProvider.emitProviderError(ProviderEventDetails.builder().build()); + + // ErrorCode.PROVIDER_NOT_READY should be returned when evaluated via the client + assertThrows(GeneralError.class, ()-> asyncInitUnleashProvider.getBooleanEvaluation("fail", false, new ImmutableContext())); + assertThrows(GeneralError.class, ()-> asyncInitUnleashProvider.getStringEvaluation("fail", "", new ImmutableContext())); + + asyncInitUnleashProvider.shutdown(); + } + + @SneakyThrows + @Test + void contextTransformTest() { + String appNameValue = "appName_value"; + String userIdValue = "userId_value"; + String environmentValue = "environment_value"; + String remoteAddressValue = "remoteAddress_value"; + String sessionIdValue = "sessionId_value"; + ZonedDateTime currentTimeValue = ZonedDateTime.now(); + String customPropertyValue = "customProperty_value"; + String customPropertyKey = "customProperty"; + + MutableContext evaluationContext = new MutableContext(); + evaluationContext.add("userId", userIdValue); + evaluationContext.add("currentTime", String.valueOf(currentTimeValue)); + evaluationContext.add("sessionId", sessionIdValue); + evaluationContext.add("remoteAddress", remoteAddressValue); + evaluationContext.add("environment", environmentValue); + evaluationContext.add("appName", appNameValue); + evaluationContext.add(customPropertyKey, customPropertyValue); + + UnleashContext transformedUnleashContext = ContextTransformer.transform(evaluationContext); + assertEquals(appNameValue, transformedUnleashContext.getAppName().get()); + assertEquals(userIdValue, transformedUnleashContext.getUserId().get()); + assertEquals(environmentValue, transformedUnleashContext.getEnvironment().get()); + assertEquals(remoteAddressValue, transformedUnleashContext.getRemoteAddress().get()); + assertEquals(sessionIdValue, transformedUnleashContext.getSessionId().get()); + assertEquals(currentTimeValue, transformedUnleashContext.getCurrentTime().get()); + assertEquals(customPropertyValue, transformedUnleashContext.getProperties().get(customPropertyKey)); + } + + @SneakyThrows + @Test + void subscriberWrapperTest() { + UnleashProvider asyncInitUnleashProvider = buildUnleashProvider(false, + "http://fakeAPI", null); + UnleashSubscriberWrapper unleashSubscriberWrapper = new UnleashSubscriberWrapper( + new TestSubscriber(), asyncInitUnleashProvider); + unleashSubscriberWrapper.clientMetrics(null); + unleashSubscriberWrapper.clientRegistered(null); + unleashSubscriberWrapper.featuresBackedUp(null); + unleashSubscriberWrapper.featuresBackupRestored(null); + unleashSubscriberWrapper.featuresBootstrapped(null); + unleashSubscriberWrapper.impression(null); + unleashSubscriberWrapper.toggleEvaluated(new ToggleEvaluated("dummy", false)); + unleashSubscriberWrapper.togglesFetched(new FeatureToggleResponse(FeatureToggleResponse.Status.NOT_CHANGED, +200)); + unleashSubscriberWrapper.toggleBackupRestored(null); + unleashSubscriberWrapper.togglesBackedUp(null); + unleashSubscriberWrapper.togglesBootstrapped(null); + } + + private class TestSubscriber implements UnleashSubscriber { + + private FeatureToggleResponse.Status status; + + private String toggleName; + private boolean toggleEnabled; + + private List events = new ArrayList<>(); + private List errors = new ArrayList<>(); + + @Override + public void on(UnleashEvent unleashEvent) { + this.events.add(unleashEvent); + } + + @Override + public void onError(UnleashException unleashException) { + this.errors.add(unleashException); + } + + @Override + public void toggleEvaluated(ToggleEvaluated toggleEvaluated) { + this.toggleName = toggleEvaluated.getToggleName(); + this.toggleEnabled = toggleEvaluated.isEnabled(); + } + + @Override + public void togglesFetched(FeatureToggleResponse toggleResponse) { + this.status = toggleResponse.getStatus(); + } + } +} \ No newline at end of file diff --git a/providers/unleash/src/test/resources/features.json b/providers/unleash/src/test/resources/features.json new file mode 100644 index 000000000..5b78c82b3 --- /dev/null +++ b/providers/unleash/src/test/resources/features.json @@ -0,0 +1,110 @@ +{ + "version": 1, + "features": [ + { + "name": "variant-flag", + "type": "experiment", + "enabled": true, + "stale": false, + "strategies": [ + { "name": "default", "parameters": {}, "constraints": [] } + ], + "variants": [ + { + "name": "v1", + "weight": 1000, + "weightType": "fix", + "payload": { "type": "string", "value": "v1" }, + "overrides": [], + "stickiness": "default" + }, + { + "name": "v2", + "weight": 0, + "weightType": "variable", + "payload": { "type": "string", "value": "v2" }, + "overrides": [{ "contextName": "userId", "values": ["me"] }], + "stickiness": "default" + } + ] + }, + { + "name": "users-flag", + "type": "release", + "enabled": true, + "stale": false, + "strategies": [ + { "name": "userWithId", "parameters": { "userIds": "111,234" } } + ], + "variants": [] + }, + { + "name": "json-flag", + "type": "experiment", + "enabled": true, + "stale": false, + "strategies": [{ "name": "default", "parameters": {} }], + "variants": [ + { + "name": "aaaa", + "weight": 1000, + "payload": { "type": "json", "value": "{ a: 1 }" }, + "overrides": [], + "weightType": "variable", + "stickiness": "default" + } + ] + }, + { + "name": "csv-flag", + "type": "experiment", + "enabled": true, + "stale": false, + "strategies": [{ "name": "default", "parameters": {} }], + "variants": [ + { + "name": "aaaa", + "weight": 1000, + "payload": { "type": "csv", "value": "a,b,c" }, + "overrides": [], + "weightType": "variable", + "stickiness": "default" + } + ] + }, + { + "name": "int-flag", + "type": "experiment", + "enabled": true, + "stale": false, + "strategies": [{ "name": "default", "parameters": {} }], + "variants": [ + { + "name": "aaaa", + "weight": 1000, + "payload": { "type": "number", "value": "123" }, + "overrides": [], + "weightType": "variable", + "stickiness": "default" + } + ] + }, + { + "name": "double-flag", + "type": "experiment", + "enabled": true, + "stale": false, + "strategies": [{ "name": "default", "parameters": {} }], + "variants": [ + { + "name": "aaaa", + "weight": 1000, + "payload": { "type": "number", "value": "1.23" }, + "overrides": [], + "weightType": "variable", + "stickiness": "default" + } + ] + } + ] +} diff --git a/providers/unleash/src/test/resources/log4j2-test.xml b/providers/unleash/src/test/resources/log4j2-test.xml new file mode 100644 index 000000000..aced30f8a --- /dev/null +++ b/providers/unleash/src/test/resources/log4j2-test.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/providers/unleash/version.txt b/providers/unleash/version.txt new file mode 100644 index 000000000..68e4b0375 --- /dev/null +++ b/providers/unleash/version.txt @@ -0,0 +1 @@ +0.0.1-alpha diff --git a/release-please-config.json b/release-please-config.json index f4c12abfe..b1bf18a8b 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -56,6 +56,17 @@ "README.md" ] }, + "providers/unleash": { + "package-name": "dev.openfeature.contrib.providers.unleash", + "release-type": "simple", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "pom.xml", + "README.md" + ] + }, "hooks/open-telemetry": { "package-name": "dev.openfeature.contrib.hooks.otel", "release-type": "simple",