diff --git a/.github/component_owners.yml b/.github/component_owners.yml index b3ba04b13..cca686378 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -31,6 +31,8 @@ components: - z4kn4fein - laliconfigcat - novalisdenahi + providers/statsig: + - liran2000 ignored-authors: - renovate-bot diff --git a/pom.xml b/pom.xml index 52333289e..246856afa 100644 --- a/pom.xml +++ b/pom.xml @@ -36,6 +36,7 @@ providers/unleash providers/flipt providers/configcat + providers/statsig diff --git a/providers/statsig/CHANGELOG.md b/providers/statsig/CHANGELOG.md new file mode 100644 index 000000000..825c32f0d --- /dev/null +++ b/providers/statsig/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/providers/statsig/README.md b/providers/statsig/README.md new file mode 100644 index 000000000..861706574 --- /dev/null +++ b/providers/statsig/README.md @@ -0,0 +1,73 @@ +# Unofficial Statsig OpenFeature Provider for Java + +[Statsig](https://statsig.com/) OpenFeature Provider can provide usage for Statsig via OpenFeature Java SDK. + +## Installation + + + +```xml + + + dev.openfeature.contrib.providers + statsig + 0.0.1 + +``` + + + +## Concepts +* Boolean evaluation gets [gate](https://docs.statsig.com/server/javaSdk#checking-a-gate) status. +* String/Integer/Double evaluations evaluation gets [Dynamic config](https://docs.statsig.com/server/javaSdk#reading-a-dynamic-config) or [Layer](https://docs.statsig.com/server/javaSdk#getting-an-layerexperiment) evaluation. + As the key represents an inner attribute, feature config is required as a parameter with data needed for evaluation. + For an example of dynamic config of product alias, need to differentiate between dynamic config or layer, and the dynamic config name. +* Object evaluation gets a structure representing the dynamic config or layer. +* [Private Attributes](https://docs.statsig.com/server/javaSdk#private-attributes) are supported as 'privateAttributes' context key. + +## Usage +Statsig OpenFeature Provider is based on [Statsig Java SDK documentation](https://docs.statsig.com/server/javaSdk). + +### Usage Example + +``` +StatsigOptions statsigOptions = new StatsigOptions(); +StatsigProviderConfig statsigProviderConfig = StatsigProviderConfig.builder().sdkKey(sdkKey) + .options(statsigOptions).build(); +statsigProvider = new StatsigProvider(statsigProviderConfig); +OpenFeatureAPI.getInstance().setProviderAndWait(statsigProvider); + +boolean featureEnabled = client.getBooleanValue(FLAG_NAME, false); + +MutableContext evaluationContext = new MutableContext(); +MutableContext featureConfig = new MutableContext(); +featureConfig.add("type", "CONFIG"); +featureConfig.add("name", "product"); +evaluationContext.add("feature_config", featureConfig); +String value = statsigProvider.getStringEvaluation("alias", "fallback", evaluationContext).getValue()); + +MutableContext evaluationContext = new MutableContext(); +evaluationContext.setTargetingKey("test-id"); +evaluationContext.add("Email", "a@b.com"); +MutableContext privateAttributes = new MutableContext(); +privateAttributes.add("locale", locale); +evaluationContext.add("privateAttributes", privateAttributes); +featureEnabled = client.getBooleanValue(USERS_FLAG_NAME, false, evaluationContext); +``` + +See [StatsigProviderTest](./src/test/java/dev/openfeature/contrib/providers/statsig/StatsigProviderTest.java) +for more information. + +## Notes +Some Statsig custom operations are supported from the Statsig client via: + +```java +Statsig... +``` + +## Statsig Provider Tests Strategies + +Unit test based on Statsig [Local Overrides](https://docs.statsig.com/server/javaSdk#local-overrides) and mocking. +As it is limited, evaluation context based tests are limited. +See [statsigProviderTest](./src/test/java/dev/openfeature/contrib/providers/statsig/StatsigProviderTest.java) +for more information. diff --git a/providers/statsig/lombok.config b/providers/statsig/lombok.config new file mode 100644 index 000000000..bcd1afdae --- /dev/null +++ b/providers/statsig/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/statsig/pom.xml b/providers/statsig/pom.xml new file mode 100644 index 000000000..6455763ab --- /dev/null +++ b/providers/statsig/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + dev.openfeature.contrib + parent + 0.1.0 + ../../pom.xml + + dev.openfeature.contrib.providers + statsig + 0.0.1 + + statsig + Statsig provider for Java + https://statsig.com/ + + + + com.statsig + serversdk + 1.10.0 + + + + org.slf4j + slf4j-api + 2.0.11 + + + + org.apache.logging.log4j + log4j-slf4j2-impl + 2.22.1 + test + + + + diff --git a/providers/statsig/src/main/java/dev/openfeature/contrib/providers/statsig/ContextTransformer.java b/providers/statsig/src/main/java/dev/openfeature/contrib/providers/statsig/ContextTransformer.java new file mode 100644 index 000000000..1ae16bbb8 --- /dev/null +++ b/providers/statsig/src/main/java/dev/openfeature/contrib/providers/statsig/ContextTransformer.java @@ -0,0 +1,69 @@ +package dev.openfeature.contrib.providers.statsig; + +import com.statsig.sdk.StatsigUser; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.TargetingKeyMissingError; + +import java.util.HashMap; +import java.util.Map; + +/** + * Transformer from OpenFeature context to statsig User. + */ +class ContextTransformer { + public static final String CONTEXT_APP_VERSION = "appVersion"; + public static final String CONTEXT_COUNTRY = "country"; + public static final String CONTEXT_EMAIL = "email"; + public static final String CONTEXT_IP = "ip"; + public static final String CONTEXT_LOCALE = "locale"; + public static final String CONTEXT_USER_AGENT = "userAgent"; + public static final String CONTEXT_PRIVATE_ATTRIBUTES = "privateAttributes"; + + static StatsigUser transform(EvaluationContext ctx) { + if (ctx.getTargetingKey() == null) { + throw new TargetingKeyMissingError("targeting key is required."); + } + StatsigUser user = new StatsigUser(ctx.getTargetingKey()); + Map customMap = new HashMap<>(); + ctx.asObjectMap().forEach((k, v) -> { + switch (k) { + case CONTEXT_APP_VERSION: + user.setAppVersion(String.valueOf(v)); + break; + case CONTEXT_COUNTRY: + user.setCountry(String.valueOf(v)); + break; + case CONTEXT_EMAIL: + user.setEmail(String.valueOf(v)); + break; + case CONTEXT_IP: + user.setIp(String.valueOf(v)); + break; + case CONTEXT_USER_AGENT: + user.setUserAgent(String.valueOf(v)); + break; + case CONTEXT_LOCALE: + user.setLocale(String.valueOf(v)); + break; + default: + if (!CONTEXT_PRIVATE_ATTRIBUTES.equals(k)) { + customMap.put(k, String.valueOf(v)); + } + break; + } + }); + user.setCustomIDs(customMap); + + Map privateMap = new HashMap<>(); + Value privateAttributes = ctx.getValue(CONTEXT_PRIVATE_ATTRIBUTES); + if (privateAttributes != null && privateAttributes.isStructure()) { + Structure privateAttributesStructure = privateAttributes.asStructure(); + privateAttributesStructure.asObjectMap().forEach((k, v) -> privateMap.put(k, String.valueOf(v))); + user.setPrivateAttributes(privateMap); + } + return user; + } + +} diff --git a/providers/statsig/src/main/java/dev/openfeature/contrib/providers/statsig/StatsigProvider.java b/providers/statsig/src/main/java/dev/openfeature/contrib/providers/statsig/StatsigProvider.java new file mode 100644 index 000000000..bff88088e --- /dev/null +++ b/providers/statsig/src/main/java/dev/openfeature/contrib/providers/statsig/StatsigProvider.java @@ -0,0 +1,296 @@ +package dev.openfeature.contrib.providers.statsig; + +import com.statsig.sdk.DynamicConfig; +import com.statsig.sdk.Layer; +import com.statsig.sdk.Statsig; +import com.statsig.sdk.StatsigUser; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.EventProvider; +import dev.openfeature.sdk.Metadata; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderState; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.GeneralError; +import dev.openfeature.sdk.exceptions.ProviderNotReadyError; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Provider implementation for Statsig. + */ +@Slf4j +public class StatsigProvider extends EventProvider { + + @Getter + private static final String NAME = "Statsig"; + + private static final String PROVIDER_NOT_YET_INITIALIZED = "provider not yet initialized"; + private static final String UNKNOWN_ERROR = "unknown error"; + private static final String FEATURE_CONFIG_KEY = "feature_config"; + + private final StatsigProviderConfig statsigProviderConfig; + + @Getter + private ProviderState state = ProviderState.NOT_READY; + + private final AtomicBoolean isInitialized = new AtomicBoolean(false); + + /** + * Constructor. + * @param statsigProviderConfig StatsigProvider Config + */ + public StatsigProvider(StatsigProviderConfig statsigProviderConfig) { + this.statsigProviderConfig = statsigProviderConfig; + } + + /** + * 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 && ProviderState.READY.equals(state)) { + log.debug("already initialized"); + return; + } + + Future initFuture = Statsig.initializeAsync(statsigProviderConfig.getSdkKey(), + statsigProviderConfig.getOptions()); + initFuture.get(); + + statsigProviderConfig.postInit(); + state = ProviderState.READY; + log.info("finished initializing provider, state: {}", state); + } + + @Override + public Metadata getMetadata() { + return () -> NAME; + } + + @SneakyThrows + @Override + public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { + verifyEvaluation(); + StatsigUser user = ContextTransformer.transform(ctx); + Future featureOn = Statsig.checkGateAsync(user, key); + Boolean evaluatedValue = featureOn.get(); + return ProviderEvaluation.builder() + .value(evaluatedValue) + .build(); + } + + @Override + public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { + verifyEvaluation(); + StatsigUser user = ContextTransformer.transform(ctx); + FeatureConfig featureConfig = parseFeatureConfig(ctx); + String evaluatedValue = defaultValue; + switch (featureConfig.getType()) { + case CONFIG: + DynamicConfig dynamicConfig = fetchDynamicConfig(user, featureConfig); + evaluatedValue = dynamicConfig.getString(key, defaultValue); + break; + case LAYER: + Layer layer = fetchLayer(user, featureConfig); + evaluatedValue = layer.getString(key, defaultValue); + break; + default: + break; + } + return ProviderEvaluation.builder() + .value(evaluatedValue) + .build(); + } + + @Override + public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { + verifyEvaluation(); + StatsigUser user = ContextTransformer.transform(ctx); + FeatureConfig featureConfig = parseFeatureConfig(ctx); + Integer evaluatedValue = defaultValue; + switch (featureConfig.getType()) { + case CONFIG: + DynamicConfig dynamicConfig = fetchDynamicConfig(user, featureConfig); + evaluatedValue = dynamicConfig.getInt(key, defaultValue); + break; + case LAYER: + Layer layer = fetchLayer(user, featureConfig); + evaluatedValue = layer.getInt(key, defaultValue); + break; + default: + break; + } + return ProviderEvaluation.builder() + .value(evaluatedValue) + .build(); + } + + @Override + public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { + verifyEvaluation(); + StatsigUser user = ContextTransformer.transform(ctx); + FeatureConfig featureConfig = parseFeatureConfig(ctx); + Double evaluatedValue = defaultValue; + switch (featureConfig.getType()) { + case CONFIG: + DynamicConfig dynamicConfig = fetchDynamicConfig(user, featureConfig); + evaluatedValue = dynamicConfig.getDouble(key, defaultValue); + break; + case LAYER: + Layer layer = fetchLayer(user, featureConfig); + evaluatedValue = layer.getDouble(key, defaultValue); + break; + default: + break; + } + return ProviderEvaluation.builder() + .value(evaluatedValue) + .build(); + } + + @SneakyThrows + @Override + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { + verifyEvaluation(); + StatsigUser user = ContextTransformer.transform(ctx); + FeatureConfig featureConfig = parseFeatureConfig(ctx); + Value evaluatedValue = defaultValue; + switch (featureConfig.getType()) { + case CONFIG: + DynamicConfig dynamicConfig = fetchDynamicConfig(user, featureConfig); + evaluatedValue = toValue(dynamicConfig); + break; + case LAYER: + Layer layer = fetchLayer(user, featureConfig); + evaluatedValue = toValue(layer); + break; + default: + break; + } + return ProviderEvaluation.builder() + .value(evaluatedValue) + .build(); + } + + @SneakyThrows + protected DynamicConfig fetchDynamicConfig(StatsigUser user, FeatureConfig featureConfig) { + return Statsig.getConfigAsync(user, featureConfig.getName()).get(); + } + + @SneakyThrows + protected Layer fetchLayer(StatsigUser user, FeatureConfig featureConfig) { + return Statsig.getLayerAsync(user, featureConfig.getName()).get(); + } + + private Value toValue(DynamicConfig dynamicConfig) { + MutableContext mutableContext = new MutableContext(); + mutableContext.add("name", dynamicConfig.getName()); + mutableContext.add("value", Structure.mapToStructure(dynamicConfig.getValue())); + mutableContext.add("ruleID", dynamicConfig.getRuleID()); + mutableContext.add("groupName", dynamicConfig.getGroupName()); + List secondaryExposures = new ArrayList<>(); + dynamicConfig.getSecondaryExposures().forEach(secondaryExposure -> { + Value value = Value.objectToValue(secondaryExposure); + secondaryExposures.add(value); + } + ); + mutableContext.add("secondaryExposures", secondaryExposures); + return new Value(mutableContext); + } + + private Value toValue(Layer layer) { + MutableContext mutableContext = new MutableContext(); + mutableContext.add("name", layer.getName()); + mutableContext.add("value", Structure.mapToStructure(layer.getValue())); + mutableContext.add("ruleID", layer.getRuleID()); + mutableContext.add("groupName", layer.getGroupName()); + List secondaryExposures = new ArrayList<>(); + layer.getSecondaryExposures().forEach(secondaryExposure -> { + Value value = Value.objectToValue(secondaryExposure); + secondaryExposures.add(value); + } + ); + mutableContext.add("secondaryExposures", secondaryExposures); + mutableContext.add("allocatedExperiment", layer.getAllocatedExperiment()); + return new Value(mutableContext); + } + + @NotNull + private static FeatureConfig parseFeatureConfig(EvaluationContext ctx) { + Value featureConfigValue = ctx.getValue(FEATURE_CONFIG_KEY); + if (featureConfigValue == null) { + throw new IllegalArgumentException("feature config not found at evaluation context."); + } + if (!featureConfigValue.isStructure()) { + throw new IllegalArgumentException("feature config is not a structure."); + } + Structure featureConfigStructure = featureConfigValue.asStructure(); + Value typeValue = featureConfigStructure.getValue("type"); + if (typeValue == null) { + throw new IllegalArgumentException("feature config type is missing"); + } + FeatureConfig.Type type = FeatureConfig.Type.valueOf(typeValue.asString()); + Value nameValue = featureConfigStructure.getValue("name"); + if (nameValue == null) { + throw new IllegalArgumentException("feature config name is missing"); + } + String name = nameValue.asString(); + return new FeatureConfig(type, name); + } + + private void verifyEvaluation() throws ProviderNotReadyError, GeneralError { + if (!ProviderState.READY.equals(state)) { + + /* + According to spec Requirement 2.4.5: + "The provider SHOULD indicate an error if flag resolution is attempted before the provider is ready." + https://github.com/open-feature/spec/blob/main/specification/sections/02-providers.md#requirement-245 + */ + if (ProviderState.NOT_READY.equals(state)) { + throw new ProviderNotReadyError(PROVIDER_NOT_YET_INITIALIZED); + } + throw new GeneralError(UNKNOWN_ERROR); + } + } + + @SneakyThrows + @Override + public void shutdown() { + log.info("shutdown"); + Statsig.shutdown(); + state = ProviderState.NOT_READY; + } + + /** + * Feature config, as required for evaluation. + */ + @AllArgsConstructor + @Getter + public static class FeatureConfig { + + /** + * Type. + * CONFIG: Dynamic Config + * LAYER: Layer + */ + public enum Type { + CONFIG, LAYER + } + + private Type type; + private String name; + } +} diff --git a/providers/statsig/src/main/java/dev/openfeature/contrib/providers/statsig/StatsigProviderConfig.java b/providers/statsig/src/main/java/dev/openfeature/contrib/providers/statsig/StatsigProviderConfig.java new file mode 100644 index 000000000..58d5b9fdc --- /dev/null +++ b/providers/statsig/src/main/java/dev/openfeature/contrib/providers/statsig/StatsigProviderConfig.java @@ -0,0 +1,23 @@ +package dev.openfeature.contrib.providers.statsig; + +import com.statsig.sdk.StatsigOptions; +import lombok.Builder; +import lombok.Getter; + + +/** + * Configuration for initializing statsig provider. + */ +@Getter +@Builder +public class StatsigProviderConfig { + + private StatsigOptions options; + + // Only holding temporary for initialization + private String sdkKey; + + public void postInit() { + sdkKey = null; // for security, not holding key in memory for long-term + } +} diff --git a/providers/statsig/src/test/java/dev/openfeature/contrib/providers/statsig/StatsigProviderTest.java b/providers/statsig/src/test/java/dev/openfeature/contrib/providers/statsig/StatsigProviderTest.java new file mode 100644 index 000000000..6fa5a3a17 --- /dev/null +++ b/providers/statsig/src/test/java/dev/openfeature/contrib/providers/statsig/StatsigProviderTest.java @@ -0,0 +1,338 @@ +package dev.openfeature.contrib.providers.statsig; + +import com.statsig.sdk.DynamicConfig; +import com.statsig.sdk.Layer; +import com.statsig.sdk.Statsig; +import com.statsig.sdk.StatsigOptions; +import com.statsig.sdk.StatsigUser; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.ProviderNotReadyError; +import lombok.SneakyThrows; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static dev.openfeature.contrib.providers.statsig.ContextTransformer.CONTEXT_APP_VERSION; +import static dev.openfeature.contrib.providers.statsig.ContextTransformer.CONTEXT_COUNTRY; +import static dev.openfeature.contrib.providers.statsig.ContextTransformer.CONTEXT_EMAIL; +import static dev.openfeature.contrib.providers.statsig.ContextTransformer.CONTEXT_IP; +import static dev.openfeature.contrib.providers.statsig.ContextTransformer.CONTEXT_LOCALE; +import static dev.openfeature.contrib.providers.statsig.ContextTransformer.CONTEXT_PRIVATE_ATTRIBUTES; +import static dev.openfeature.contrib.providers.statsig.ContextTransformer.CONTEXT_USER_AGENT; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +/** + * StatsigProvider test, based on local config file evaluation. + * Configuration file test by statsig tests. + */ +class StatsigProviderTest { + + public static final String FLAG_NAME = "enabledFeature"; + public static final String CONFIG_FLAG_NAME = "alias"; + public static final String LAYER_FLAG_NAME = "alias"; + public static final String CONFIG_FLAG_VALUE = "test"; + public static final String INT_FLAG_NAME = "revision"; + public static final String LAYER_INT_FLAG_NAME = "revision"; + public static final Integer INT_FLAG_VALUE = 5; + public static final String DOUBLE_FLAG_NAME = "price"; + public static final String LAYER_DOUBLE_FLAG_NAME = "price"; + public static final Double DOUBLE_FLAG_VALUE = 3.14; + public static final String USERS_FLAG_NAME = "userIdMatching"; + public static final String PROPERTIES_FLAG_NAME = "emailMatching"; + private static StatsigProvider statsigProvider; + private static Client client; + + @SneakyThrows + @BeforeAll + static void setUp() { + String sdkKey = "test"; + StatsigOptions statsigOptions = new StatsigOptions(); + statsigOptions.setLocalMode(true); + StatsigProviderConfig statsigProviderConfig = StatsigProviderConfig.builder().sdkKey(sdkKey) + .options(statsigOptions).build(); + statsigProvider = spy(new StatsigProvider(statsigProviderConfig)); + OpenFeatureAPI.getInstance().setProviderAndWait(statsigProvider); + client = OpenFeatureAPI.getInstance().getClient(); + buildFlags(); + } + + @SneakyThrows + private static void buildFlags() { + Statsig.overrideGate(FLAG_NAME, true); + Map configMap = new HashMap<>(); + configMap.put("alias", "test"); + configMap.put("revision", INT_FLAG_VALUE); + configMap.put("price", DOUBLE_FLAG_VALUE); + Statsig.overrideConfig("product", configMap); + Statsig.overrideLayer("product", configMap); + + ArrayList> secondaryExposures = new ArrayList<>(); + secondaryExposures.add(Collections.singletonMap("test-exposure", "test-exposure-value")); + DynamicConfig dynamicConfig = new DynamicConfig("object-config-name", + Collections.singletonMap("value-key", "test-value"), "test-rule-id", "test-group-name", + secondaryExposures); + + doAnswer(invocation -> { + if ("object-config-name".equals(invocation.getArgument(1, + StatsigProvider.FeatureConfig.class).getName())) { + return dynamicConfig; + } + return invocation.callRealMethod(); + }).when(statsigProvider).fetchDynamicConfig(any(), any()); + + Layer layer = new Layer("layer-name", "test-rule-id", "test-group-name", + Collections.singletonMap("value-key", "test-value"), secondaryExposures, "allocated", + null); + doAnswer(invocation -> { + if ("layer-name".equals(invocation.getArgument(1, StatsigProvider.FeatureConfig.class).getName())) { + return layer; + } + return invocation.callRealMethod(); + }).when(statsigProvider).fetchLayer(any(), any()); + + } + + @AfterAll + static void shutdown() { + statsigProvider.shutdown(); + } + + @Test + void getBooleanEvaluation() { + assertEquals(true, statsigProvider.getBooleanEvaluation(FLAG_NAME, false, new ImmutableContext()).getValue()); + assertEquals(true, client.getBooleanValue(FLAG_NAME, false)); + assertEquals(false, statsigProvider.getBooleanEvaluation("non-existing", false, new ImmutableContext()).getValue()); + assertEquals(false, client.getBooleanValue("non-existing", false)); + } + + @Test + void getStringEvaluation() { + MutableContext evaluationContext = new MutableContext(); + MutableContext featureConfig = new MutableContext(); + featureConfig.add("type", "CONFIG"); + featureConfig.add("name", "product"); + evaluationContext.add("feature_config", featureConfig); + assertEquals(CONFIG_FLAG_VALUE, statsigProvider.getStringEvaluation(CONFIG_FLAG_NAME, "", + evaluationContext).getValue()); + assertEquals(CONFIG_FLAG_VALUE, statsigProvider.getStringEvaluation(LAYER_FLAG_NAME, "", + evaluationContext).getValue()); + assertEquals("fallback_str", client.getStringValue("non-existing", "fallback_str")); + } + + @Test + void getObjectConfigEvaluation() { + MutableContext evaluationContext = new MutableContext(); + MutableContext featureConfig = new MutableContext(); + featureConfig.add("type", "CONFIG"); + featureConfig.add("name", "object-config-name"); + evaluationContext.add("feature_config", featureConfig); + Value objectEvaluation = statsigProvider.getObjectEvaluation("dummy", new Value("fallback"), + evaluationContext).getValue(); + + String expectedObjectEvaluation = "{groupName=test-group-name, name=object-config-name, secondaryExposures=" + + "[{test-exposure=test-exposure-value}], ruleID=test-rule-id, value={value-key=test-value}}"; + assertEquals(expectedObjectEvaluation, objectEvaluation.asStructure().asObjectMap().toString()); + } + + @Test + void getObjectLayerEvaluation() { + MutableContext evaluationContext = new MutableContext(); + MutableContext featureConfig = new MutableContext(); + featureConfig.add("type", "LAYER"); + featureConfig.add("name", "layer-name"); + evaluationContext.add("feature_config", featureConfig); + Value objectEvaluation = statsigProvider.getObjectEvaluation("dummy", new Value("fallback"), + evaluationContext).getValue(); + + String expectedObjectEvaluation = "{groupName=test-group-name, name=layer-name, secondaryExposures=" + + "[{test-exposure=test-exposure-value}], allocatedExperiment=allocated, ruleID=test-rule-id, " + + "value={value-key=test-value}}"; + assertEquals(expectedObjectEvaluation, objectEvaluation.asStructure().asObjectMap().toString()); + } + + @Test + void getIntegerEvaluation() { + MutableContext evaluationContext = new MutableContext(); + MutableContext featureConfig = new MutableContext(); + featureConfig.add("type", "CONFIG"); + featureConfig.add("name", "product"); + evaluationContext.add("feature_config", featureConfig); + assertEquals(INT_FLAG_VALUE, statsigProvider.getIntegerEvaluation(INT_FLAG_NAME, 1, + evaluationContext).getValue()); + assertEquals(INT_FLAG_VALUE, statsigProvider.getIntegerEvaluation(LAYER_INT_FLAG_NAME, 1, + evaluationContext).getValue()); + assertEquals(1, client.getIntegerValue("non-existing", 1)); + + // non-number flag value + assertEquals(1, client.getIntegerValue(CONFIG_FLAG_NAME, 1)); + } + + @Test + void getDoubleEvaluation() { + MutableContext evaluationContext = new MutableContext(); + MutableContext featureConfig = new MutableContext(); + featureConfig.add("type", "CONFIG"); + featureConfig.add("name", "product"); + evaluationContext.add("feature_config", featureConfig); + assertEquals(DOUBLE_FLAG_VALUE, statsigProvider.getDoubleEvaluation(DOUBLE_FLAG_NAME, 1.1, + evaluationContext).getValue()); + assertEquals(DOUBLE_FLAG_VALUE, statsigProvider.getDoubleEvaluation(LAYER_DOUBLE_FLAG_NAME, 1.1, + evaluationContext).getValue()); + assertEquals(1.1, client.getDoubleValue("non-existing", 1.1)); + + // non-number flag value + assertEquals(1.1, client.getDoubleValue(CONFIG_FLAG_NAME, 1.1)); + } + + @Test + void getBooleanEvaluationByUser() { + MutableContext evaluationContext = new MutableContext(); + MutableContext featureConfig = new MutableContext(); + featureConfig.add("type", "CONFIG"); + featureConfig.add("name", "product"); + evaluationContext.add("feature_config", featureConfig); + final String expectedTargetingKey = "test-id"; + evaluationContext.setTargetingKey(expectedTargetingKey); + + when(statsigProvider.getBooleanEvaluation(USERS_FLAG_NAME, false, evaluationContext)) + .thenAnswer(invocation -> { + if (!USERS_FLAG_NAME.equals(invocation.getArgument(0, String.class))) { + invocation.callRealMethod(); + } + boolean evaluatedValue = invocation.getArgument(2, MutableContext.class).getTargetingKey() + .equals(expectedTargetingKey); + return ProviderEvaluation.builder() + .value(evaluatedValue) + .build(); + } + ); + + assertEquals(true, statsigProvider.getBooleanEvaluation(USERS_FLAG_NAME, false, + evaluationContext).getValue()); + evaluationContext.setTargetingKey("other-id"); + assertEquals(false, statsigProvider.getBooleanEvaluation(USERS_FLAG_NAME, false, + evaluationContext).getValue()); + } + + @Test + void getBooleanEvaluationByProperties() { + MutableContext evaluationContext = new MutableContext(); + MutableContext featureConfig = new MutableContext(); + featureConfig.add("type", "CONFIG"); + featureConfig.add("name", "product"); + evaluationContext.add("feature_config", featureConfig); + final String expectedTargetingKey = "test-id"; + final String expectedEmail = "match@test.com"; + final String expectedIp = "1.2.3.4"; + evaluationContext.setTargetingKey(expectedTargetingKey); + evaluationContext.add(CONTEXT_EMAIL, expectedEmail); + evaluationContext.add(CONTEXT_LOCALE, "test-locale"); + MutableContext privateAttributes = new MutableContext(); + privateAttributes.add(CONTEXT_IP, "1.2.3.5"); + privateAttributes.add("custom-private", "test-custom"); + evaluationContext.add(CONTEXT_PRIVATE_ATTRIBUTES, privateAttributes); + + when(statsigProvider.getBooleanEvaluation(PROPERTIES_FLAG_NAME, false, evaluationContext)) + .thenAnswer(invocation -> { + if (!PROPERTIES_FLAG_NAME.equals(invocation.getArgument(0, String.class))) { + invocation.callRealMethod(); + } + boolean evaluatedValue = invocation.getArgument(2, MutableContext.class) + .getValue(CONTEXT_EMAIL).asString().equals(expectedEmail); + if (invocation.getArgument(2, MutableContext.class).getValue(CONTEXT_PRIVATE_ATTRIBUTES) + .asStructure().getValue(CONTEXT_IP).asString().equals(expectedIp)) { + evaluatedValue = true; + } + return ProviderEvaluation.builder() + .value(evaluatedValue) + .build(); + } + ); + + assertEquals(true, statsigProvider.getBooleanEvaluation(PROPERTIES_FLAG_NAME, false, + evaluationContext).getValue()); + evaluationContext.add(CONTEXT_EMAIL, "non-match@test.com"); + assertEquals(false, statsigProvider.getBooleanEvaluation(PROPERTIES_FLAG_NAME, false, + evaluationContext).getValue()); + + privateAttributes.add(CONTEXT_IP, expectedIp); + assertEquals(true, statsigProvider.getBooleanEvaluation(PROPERTIES_FLAG_NAME, false, + evaluationContext).getValue()); + privateAttributes.add(CONTEXT_IP, "1.2.3.5"); + assertEquals(false, statsigProvider.getBooleanEvaluation(PROPERTIES_FLAG_NAME, false, + evaluationContext).getValue()); + } + + @SneakyThrows + @Test + void shouldThrowIfNotInitialized() { + StatsigProviderConfig statsigProviderConfig = StatsigProviderConfig.builder().sdkKey("test").build(); + StatsigProvider tempstatsigProvider = new StatsigProvider(statsigProviderConfig); + + assertThrows(ProviderNotReadyError.class, ()-> tempstatsigProvider.getBooleanEvaluation( + "fail_not_initialized", false, new ImmutableContext())); + + OpenFeatureAPI.getInstance().setProviderAndWait("tempstatsigProvider", tempstatsigProvider); + } + + @SneakyThrows + @Test + void contextTransformTest() { + String userId = "a"; + String email = "a@a.com"; + String country = "someCountry"; + String userAgent = "userAgent1"; + String ip = "1.2.3.4"; + String appVersion = "appVersion1"; + String locale = "locale1"; + String customPropertyValue = "customProperty_value"; + String customPropertyKey = "customProperty"; + + MutableContext evaluationContext = new MutableContext(); + evaluationContext.setTargetingKey(userId); + evaluationContext.add(CONTEXT_COUNTRY, country); + evaluationContext.add(CONTEXT_EMAIL, email); + evaluationContext.add(CONTEXT_USER_AGENT, userAgent); + evaluationContext.add(CONTEXT_IP, ip); + evaluationContext.add(CONTEXT_APP_VERSION, appVersion); + + MutableContext privateAttributes = new MutableContext(); + privateAttributes.add(CONTEXT_LOCALE, locale); + evaluationContext.add(CONTEXT_PRIVATE_ATTRIBUTES, privateAttributes); + + evaluationContext.add(customPropertyKey, customPropertyValue); + + HashMap customMap = new HashMap<>(); + customMap.put(customPropertyKey, customPropertyValue); + StatsigUser expectedUser = new StatsigUser(evaluationContext.getTargetingKey()); + expectedUser.setEmail(email); + expectedUser.setCountry(country); + expectedUser.setUserAgent(userAgent); + expectedUser.setIp(ip); + expectedUser.setAppVersion(appVersion); + Map privateAttributesMap = new HashMap<>(); + privateAttributesMap.put(CONTEXT_LOCALE, locale); + expectedUser.setPrivateAttributes(privateAttributesMap); + expectedUser.setCustomIDs(customMap); + StatsigUser transformedUser = ContextTransformer.transform(evaluationContext); + + // equals not implemented for User, using toString + assertEquals(expectedUser.toString(), transformedUser.toString()); + } + +} \ No newline at end of file diff --git a/providers/statsig/src/test/resources/log4j2-test.xml b/providers/statsig/src/test/resources/log4j2-test.xml new file mode 100644 index 000000000..aced30f8a --- /dev/null +++ b/providers/statsig/src/test/resources/log4j2-test.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/providers/statsig/version.txt b/providers/statsig/version.txt new file mode 100644 index 000000000..8acdd82b7 --- /dev/null +++ b/providers/statsig/version.txt @@ -0,0 +1 @@ +0.0.1