diff --git a/.github/component_owners.yml b/.github/component_owners.yml index c5641fde7..8c31c9c0c 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -35,6 +35,8 @@ components: - novalisdenahi providers/statsig: - liran2000 + providers/optimizely: + - liran2000 providers/multiprovider: - liran2000 tools/flagd-http-connector: diff --git a/pom.xml b/pom.xml index 10108b21e..aac1a040e 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,7 @@ providers/flipt providers/configcat providers/statsig + providers/optimizely providers/multiprovider tools/flagd-http-connector diff --git a/providers/optimizely/CHANGELOG.md b/providers/optimizely/CHANGELOG.md new file mode 100644 index 000000000..825c32f0d --- /dev/null +++ b/providers/optimizely/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/providers/optimizely/README.md b/providers/optimizely/README.md new file mode 100644 index 000000000..f83faa122 --- /dev/null +++ b/providers/optimizely/README.md @@ -0,0 +1,59 @@ +# Unofficial Optimizely OpenFeature Provider for Java + +[optimizely](https://www.optimizely.com/optimization-glossary/feature-flags/) OpenFeature Provider can provide usage for optimizely via OpenFeature Java SDK. + +## Installation + + + +```xml + + + dev.openfeature.contrib.providers + optimizely + 0.0.1 + +``` + + + +## Concepts + +* Boolean evaluation gets feature [enabled](https://docs.developers.optimizely.com/feature-experimentation/docs/create-feature-flags) value. +* Object evaluation gets a structure representing the evaluated variant variables. +* String/Integer/Double evaluations evaluation are not directly supported by Optimizely provider, use getObjectEvaluation instead. + +## Usage +Optimizely OpenFeature Provider is based on [Optimizely Java SDK documentation](https://docs.developers.optimizely.com/feature-experimentation/docs/java-sdk). + +### Usage Example + +```java +OptimizelyProviderConfig config = OptimizelyProviderConfig.builder() + .build(); + +provider = new OptimizelyProvider(config); +provider.initialize(new MutableContext("test-targeting-key")); + +ProviderEvaluation evaluation = provider.getBooleanEvaluation("string-feature", false, ctx); +System.out.println("Feature enabled: " + evaluation.getValue()); + +ProviderEvaluation result = provider.getObjectEvaluation("string-feature", new Value(), ctx); +System.out.println("Feature variable: " + result.getValue().asStructure().getValue("string_variable_1").asString()); +``` + +See [OptimizelyProviderTest](./src/test/java/dev/openfeature/contrib/providers/optimizely/OptimizelyProviderTest.java) +for more information. + +## Notes +Some Optimizely custom operations are supported from the optimizely client via: + +```java +provider.getOptimizely()... +``` + +## Optimizely Provider Tests Strategies + +Unit test based on optimizely [Local Data File](https://docs.developers.optimizely.com/feature-experimentation/docs/initialize-sdk-java). +See [OptimizelyProviderTest](./src/test/java/dev/openfeature/contrib/providers/optimizely/OptimizelyProviderTest.java) +for more information. diff --git a/providers/optimizely/lombok.config b/providers/optimizely/lombok.config new file mode 100644 index 000000000..bcd1afdae --- /dev/null +++ b/providers/optimizely/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/optimizely/pom.xml b/providers/optimizely/pom.xml new file mode 100644 index 000000000..69e2edd35 --- /dev/null +++ b/providers/optimizely/pom.xml @@ -0,0 +1,45 @@ + + + 4.0.0 + + dev.openfeature.contrib + parent + [1.0,2.0) + ../../pom.xml + + dev.openfeature.contrib.providers + optimizely + 0.0.1 + + optimizely + optimizely provider for Java + https://optimizely.com/ + + + + com.optimizely.ab + core-api + 4.2.2 + + + com.optimizely.ab + core-httpclient-impl + 4.2.2 + + + + org.slf4j + slf4j-api + 2.0.17 + + + + org.apache.logging.log4j + log4j-slf4j2-impl + 2.25.0 + test + + + + diff --git a/providers/optimizely/src/main/java/dev/openfeature/contrib/providers/optimizely/ContextTransformer.java b/providers/optimizely/src/main/java/dev/openfeature/contrib/providers/optimizely/ContextTransformer.java new file mode 100644 index 000000000..ee8e7d9af --- /dev/null +++ b/providers/optimizely/src/main/java/dev/openfeature/contrib/providers/optimizely/ContextTransformer.java @@ -0,0 +1,32 @@ +package dev.openfeature.contrib.providers.optimizely; + +import com.optimizely.ab.Optimizely; +import com.optimizely.ab.OptimizelyUserContext; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.exceptions.TargetingKeyMissingError; +import java.util.HashMap; +import java.util.Map; +import lombok.Builder; + +/** Transformer from OpenFeature context to OptimizelyUserContext. */ +@Builder +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"; + + private Optimizely optimizely; + + public OptimizelyUserContext transform(EvaluationContext ctx) { + if (ctx.getTargetingKey() == null) { + throw new TargetingKeyMissingError("targeting key is required."); + } + Map attributes = new HashMap<>(); + attributes.putAll(ctx.asObjectMap()); + return optimizely.createUserContext(ctx.getTargetingKey(), attributes); + } +} diff --git a/providers/optimizely/src/main/java/dev/openfeature/contrib/providers/optimizely/OptimizelyProvider.java b/providers/optimizely/src/main/java/dev/openfeature/contrib/providers/optimizely/OptimizelyProvider.java new file mode 100644 index 000000000..dae35cbd3 --- /dev/null +++ b/providers/optimizely/src/main/java/dev/openfeature/contrib/providers/optimizely/OptimizelyProvider.java @@ -0,0 +1,147 @@ +package dev.openfeature.contrib.providers.optimizely; + +import com.optimizely.ab.Optimizely; +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; +import com.optimizely.ab.optimizelyjson.OptimizelyJSON; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.EventProvider; +import dev.openfeature.sdk.Metadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.Value; +import java.util.List; +import java.util.Map; +import lombok.Getter; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +/** Provider implementation for Optimizely. */ +@Slf4j +public class OptimizelyProvider extends EventProvider { + + @Getter + private static final String NAME = "Optimizely"; + + private OptimizelyProviderConfig optimizelyProviderConfig; + + @Getter + private Optimizely optimizely; + + private ContextTransformer contextTransformer; + + /** + * Constructor. + * + * @param optimizelyProviderConfig configuration for the provider + */ + public OptimizelyProvider(OptimizelyProviderConfig optimizelyProviderConfig) { + this.optimizelyProviderConfig = optimizelyProviderConfig; + } + + /** + * Initialize the provider. + * + * @param evaluationContext evaluation context + * @throws Exception on error + */ + @Override + public void initialize(EvaluationContext evaluationContext) throws Exception { + optimizely = Optimizely.builder() + .withConfigManager(optimizelyProviderConfig.getProjectConfigManager()) + .withEventProcessor(optimizelyProviderConfig.getEventProcessor()) + .withDatafile(optimizelyProviderConfig.getDatafile()) + .withDefaultDecideOptions(optimizelyProviderConfig.getDefaultDecideOptions()) + .withErrorHandler(optimizelyProviderConfig.getErrorHandler()) + .withODPManager(optimizelyProviderConfig.getOdpManager()) + .withUserProfileService(optimizelyProviderConfig.getUserProfileService()) + .build(); + contextTransformer = ContextTransformer.builder().optimizely(optimizely).build(); + log.info("finished initializing provider"); + } + + @Override + public Metadata getMetadata() { + return () -> NAME; + } + + @SneakyThrows + @Override + public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { + OptimizelyUserContext userContext = contextTransformer.transform(ctx); + OptimizelyDecision decision = userContext.decide(key); + String variationKey = decision.getVariationKey(); + String reasonsString = null; + if (variationKey == null) { + List reasons = decision.getReasons(); + reasonsString = String.join(", ", reasons); + } + + boolean enabled = decision.getEnabled(); + return ProviderEvaluation.builder() + .value(enabled) + .reason(reasonsString) + .build(); + } + + @SneakyThrows + @Override + public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { + throw new UnsupportedOperationException("String evaluation is not directly supported by Optimizely provider," + + "use getObjectEvaluation instead."); + } + + @Override + public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { + throw new UnsupportedOperationException("Integer evaluation is not directly supported by Optimizely provider," + + "use getObjectEvaluation instead."); + } + + @Override + public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { + throw new UnsupportedOperationException("Double evaluation is not directly supported by Optimizely provider," + + "use getObjectEvaluation instead."); + } + + @SneakyThrows + @Override + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { + OptimizelyUserContext userContext = contextTransformer.transform(ctx); + OptimizelyDecision decision = userContext.decide(key); + String variationKey = decision.getVariationKey(); + String reasonsString = null; + if (variationKey == null) { + List reasons = decision.getReasons(); + reasonsString = String.join(", ", reasons); + } + + Value evaluatedValue = defaultValue; + boolean enabled = decision.getEnabled(); + if (enabled) { + OptimizelyJSON variables = decision.getVariables(); + evaluatedValue = toValue(variables); + } + + return ProviderEvaluation.builder() + .value(evaluatedValue) + .reason(reasonsString) + .variant(variationKey) + .build(); + } + + @SneakyThrows + private Value toValue(OptimizelyJSON optimizelyJson) { + Map map = optimizelyJson.toMap(); + Structure structure = Structure.mapToStructure(map); + return new Value(structure); + } + + @SneakyThrows + @Override + public void shutdown() { + log.info("shutdown"); + if (optimizely != null) { + optimizely.close(); + } + } +} diff --git a/providers/optimizely/src/main/java/dev/openfeature/contrib/providers/optimizely/OptimizelyProviderConfig.java b/providers/optimizely/src/main/java/dev/openfeature/contrib/providers/optimizely/OptimizelyProviderConfig.java new file mode 100644 index 000000000..08148f3ad --- /dev/null +++ b/providers/optimizely/src/main/java/dev/openfeature/contrib/providers/optimizely/OptimizelyProviderConfig.java @@ -0,0 +1,29 @@ +package dev.openfeature.contrib.providers.optimizely; + +import com.optimizely.ab.bucketing.UserProfileService; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.ProjectConfigManager; +import com.optimizely.ab.error.ErrorHandler; +import com.optimizely.ab.event.EventHandler; +import com.optimizely.ab.event.EventProcessor; +import com.optimizely.ab.odp.ODPManager; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +/** Configuration for initializing statsig provider. */ +@Getter +@Builder +public class OptimizelyProviderConfig { + + private ProjectConfigManager projectConfigManager; + private EventHandler eventHandler; + private EventProcessor eventProcessor; + private String datafile; + private ErrorHandler errorHandler; + private ProjectConfig projectConfig; + private UserProfileService userProfileService; + private List defaultDecideOptions; + private ODPManager odpManager; +} diff --git a/providers/optimizely/src/test/java/dev/openfeature/contrib/providers/optimizely/OptimizelyProviderTest.java b/providers/optimizely/src/test/java/dev/openfeature/contrib/providers/optimizely/OptimizelyProviderTest.java new file mode 100644 index 000000000..d92fba154 --- /dev/null +++ b/providers/optimizely/src/test/java/dev/openfeature/contrib/providers/optimizely/OptimizelyProviderTest.java @@ -0,0 +1,134 @@ +package dev.openfeature.contrib.providers.optimizely; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import com.optimizely.ab.config.ProjectConfigManager; +import com.optimizely.ab.event.EventProcessor; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.TargetingKeyMissingError; +import java.io.File; +import lombok.SneakyThrows; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +public class OptimizelyProviderTest { + + private static OptimizelyProvider provider; + + @SneakyThrows + @BeforeAll + static void setUp() { + File dataFile = new File(OptimizelyProviderTest.class + .getClassLoader() + .getResource("data.json") + .getFile()); + String dataFileContent = new String(java.nio.file.Files.readAllBytes(dataFile.toPath())); + + OptimizelyProviderConfig config = OptimizelyProviderConfig.builder() + .eventProcessor(mock(EventProcessor.class)) + .datafile(dataFileContent) + .build(); + + provider = new OptimizelyProvider(config); + provider.initialize(new MutableContext("test-targeting-key")); + } + + @Test + public void testConstructorInitializesProviderWithValidConfig() { + OptimizelyProviderConfig config = OptimizelyProviderConfig.builder() + .projectConfigManager(mock(ProjectConfigManager.class)) + .eventProcessor(mock(EventProcessor.class)) + .datafile("test-datafile") + .build(); + + OptimizelyProvider localProvider = new OptimizelyProvider(config); + + assertThat(localProvider).isNotNull(); + assertEquals("Optimizely", localProvider.getMetadata().getName()); + } + + @Test + public void testInitializeHandlesNullConfigurationParameters() { + OptimizelyProviderConfig config = OptimizelyProviderConfig.builder() + .projectConfigManager(null) + .eventProcessor(null) + .datafile(null) + .build(); + + OptimizelyProvider localProvider = new OptimizelyProvider(config); + EvaluationContext evaluationContext = mock(EvaluationContext.class); + + assertDoesNotThrow(() -> { + localProvider.initialize(evaluationContext); + }); + } + + @SneakyThrows + @Test + public void testGetObjectEvaluation() { + EvaluationContext ctx = new MutableContext("targetingKey"); + ProviderEvaluation result = provider.getObjectEvaluation("string-feature", new Value(), ctx); + + assertNotNull(result.getValue()); + assertEquals("string_feature_variation", result.getVariant()); + assertEquals( + "str1", + result.getValue().asStructure().getValue("string_variable_1").asString()); + + result = provider.getObjectEvaluation("non-existing-object-feature", new Value(), ctx); + assertNotNull(result.getReason()); + } + + @Test + public void testGetBooleanEvaluation() { + EvaluationContext ctx = new MutableContext("targetingKey"); + ProviderEvaluation evaluation = provider.getBooleanEvaluation("string-feature", false, ctx); + + assertTrue(evaluation.getValue()); + + EvaluationContext emptyEvaluationContext = new MutableContext(); + assertThrows(TargetingKeyMissingError.class, () -> { + provider.getBooleanEvaluation("string-feature", false, emptyEvaluationContext); + }); + + evaluation = provider.getBooleanEvaluation("non-existing-feature", false, ctx); + assertFalse(evaluation.getValue()); + assertNotNull(evaluation.getReason()); + } + + @Test + public void testUnsupportedEvaluations() { + EvaluationContext ctx = new MutableContext("targetingKey"); + + assertThrows(UnsupportedOperationException.class, () -> { + provider.getDoubleEvaluation("string-feature", 0.0, ctx); + }); + + assertThrows(UnsupportedOperationException.class, () -> { + provider.getIntegerEvaluation("string-feature", 0, ctx); + }); + + assertThrows(UnsupportedOperationException.class, () -> { + provider.getStringEvaluation("string-feature", "default", ctx); + }); + } + + @SneakyThrows + @AfterAll + static void tearDown() { + if (provider != null) { + provider.shutdown(); + } + } +} diff --git a/providers/optimizely/src/test/resources/data.json b/providers/optimizely/src/test/resources/data.json new file mode 100644 index 000000000..ddc49f8d3 --- /dev/null +++ b/providers/optimizely/src/test/resources/data.json @@ -0,0 +1,105 @@ +{ + "version": "4", + "rollouts": [ + { + "experiments": [ + { + "status": "Running", + "key": "boolean-feature-rollout", + "layerId": "boolean_feature_layer", + "trafficAllocation": [ + { + "entityId": "boolean_feature_variation", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "variations": [ + { + "variables": [], + "id": "boolean_feature_variation", + "key": "boolean_feature_variation", + "featureEnabled": true + } + ], + "forcedVariations": {}, + "id": "boolean_feature_rollout_experiment" + } + ], + "id": "boolean_feature_rollout" + }, + { + "experiments": [ + { + "status": "Running", + "key": "string-feature-rollout", + "layerId": "string_feature_layer", + "trafficAllocation": [ + { + "entityId": "string_feature_variation", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "variations": [ + { + "variables": [ + { + "id": "string_variable_1", + "value": "str1" + } + ], + "id": "string_feature_variation", + "key": "string_feature_variation", + "featureEnabled": true + } + ], + "forcedVariations": {}, + "id": "string_feature_rollout_experiment" + } + ], + "id": "string_feature_rollout" + } + ], + "typedAudiences": [], + "anonymizeIP": false, + "projectId": "12345678901", + "variables": [ + { + "defaultValue": "str1", + "type": "string", + "id": "string_variable_1", + "key": "string_variable_1" + } + ], + "featureFlags": [ + { + "experimentIds": [], + "rolloutId": "boolean_feature_rollout", + "variables": [], + "id": "boolean_feature_flag", + "key": "boolean-feature" + }, + { + "experimentIds": [], + "rolloutId": "string_feature_rollout", + "variables": [ + { + "defaultValue": "str1", + "type": "string", + "id": "string_variable_1", + "key": "string_variable_1" + } + ], + "id": "string_feature_flag", + "key": "string-feature" + } + ], + "experiments": [], + "audiences": [], + "groups": [], + "attributes": [], + "accountId": "12345678901", + "events": [], + "revision": "1" +} diff --git a/providers/optimizely/src/test/resources/log4j2-test.xml b/providers/optimizely/src/test/resources/log4j2-test.xml new file mode 100644 index 000000000..a13aeb0ea --- /dev/null +++ b/providers/optimizely/src/test/resources/log4j2-test.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/providers/optimizely/version.txt b/providers/optimizely/version.txt new file mode 100644 index 000000000..8acdd82b7 --- /dev/null +++ b/providers/optimizely/version.txt @@ -0,0 +1 @@ +0.0.1 diff --git a/release-please-config.json b/release-please-config.json index 11e8fffb0..7450f6c89 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -103,6 +103,17 @@ "README.md" ] }, + "providers/optimizely": { + "package-name": "dev.openfeature.contrib.providers.optimizely", + "release-type": "simple", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "pom.xml", + "README.md" + ] + }, "providers/multiprovider": { "package-name": "dev.openfeature.contrib.providers.multiprovider", "release-type": "simple",