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",