diff --git a/providers/optimizely/README.md b/providers/optimizely/README.md index f83faa122..9e16bacf7 100644 --- a/providers/optimizely/README.md +++ b/providers/optimizely/README.md @@ -19,9 +19,13 @@ ## 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. +### Evaluation Context + +The `targetingKey` is required and maps to the Optimizely user ID. Additional attributes are passed to Optimizely for audience targeting. + +### Variable Key Selection + +Optimizely flags can have multiple variables. By default, the provider looks for a variable named `"value"`. Specify a different variable using the `variableKey` attribute: ## Usage Optimizely OpenFeature Provider is based on [Optimizely Java SDK documentation](https://docs.developers.optimizely.com/feature-experimentation/docs/java-sdk). @@ -57,3 +61,9 @@ provider.getOptimizely()... 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. + +## Release Notes + +### 0.1.0 + +Concepts updated, evaluation acts accordingly. diff --git a/providers/optimizely/pom.xml b/providers/optimizely/pom.xml index 6b4594ebe..a7312fe54 100644 --- a/providers/optimizely/pom.xml +++ b/providers/optimizely/pom.xml @@ -10,7 +10,7 @@ dev.openfeature.contrib.providers optimizely - 0.0.1 + 0.1.0 optimizely optimizely provider for Java 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 index dae35cbd3..d238dea69 100644 --- 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 @@ -2,15 +2,14 @@ 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.Reason; import dev.openfeature.sdk.Structure; import dev.openfeature.sdk.Value; -import java.util.List; import java.util.Map; import lombok.Getter; import lombok.SneakyThrows; @@ -69,68 +68,135 @@ public Metadata getMetadata() { @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); + + String variableKey = getVariableKey(ctx); + Boolean enabled = optimizely.getFeatureVariableBoolean( + key, variableKey, userContext.getUserId(), userContext.getAttributes()); + + String variant = variableKey; + String reason = Reason.TARGETING_MATCH.name(); + if (enabled == null) { + enabled = false; + variant = null; + reason = Reason.DEFAULT.name(); } - boolean enabled = decision.getEnabled(); return ProviderEvaluation.builder() .value(enabled) - .reason(reasonsString) + .reason(reason) + .variant(variant) .build(); } + private static String getVariableKey(EvaluationContext ctx) { + String variableKey = "value"; + Value varKey = ctx.getValue("variableKey"); + if (varKey != null && varKey.isString() && !varKey.asString().isBlank()) { + variableKey = varKey.asString(); + } + return variableKey; + } + @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."); + OptimizelyUserContext userContext = contextTransformer.transform(ctx); + + String variableKey = getVariableKey(ctx); + String value = optimizely.getFeatureVariableString( + key, variableKey, userContext.getUserId(), userContext.getAttributes()); + + String variant = variableKey; + String reason = Reason.TARGETING_MATCH.name(); + if (value == null) { + value = defaultValue; + variant = null; + reason = Reason.DEFAULT.name(); + } + + return ProviderEvaluation.builder() + .value(value) + .reason(reason) + .variant(variant) + .build(); } @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."); + OptimizelyUserContext userContext = contextTransformer.transform(ctx); + + String variableKey = getVariableKey(ctx); + Integer value = optimizely.getFeatureVariableInteger( + key, variableKey, userContext.getUserId(), userContext.getAttributes()); + + String variant = variableKey; + String reason = Reason.TARGETING_MATCH.name(); + if (value == null) { + value = defaultValue; + variant = null; + reason = Reason.DEFAULT.name(); + } + + return ProviderEvaluation.builder() + .value(value) + .reason(reason) + .variant(variant) + .build(); } @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."); + OptimizelyUserContext userContext = contextTransformer.transform(ctx); + + String variableKey = getVariableKey(ctx); + Double value = optimizely.getFeatureVariableDouble( + key, variableKey, userContext.getUserId(), userContext.getAttributes()); + + String variant = variableKey; + String reason = Reason.TARGETING_MATCH.name(); + if (value == null) { + value = defaultValue; + variant = null; + reason = Reason.DEFAULT.name(); + } + + return ProviderEvaluation.builder() + .value(value) + .reason(reason) + .variant(variant) + .build(); } @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); + String variableKey = getVariableKey(ctx); + OptimizelyJSON value = optimizely.getFeatureVariableJSON( + key, variableKey, userContext.getUserId(), userContext.getAttributes()); + Value evaluatedValue = toValue(value); + + String variant = variableKey; + String reason = Reason.TARGETING_MATCH.name(); + if (value == null) { + evaluatedValue = defaultValue; + variant = null; + reason = Reason.DEFAULT.name(); } return ProviderEvaluation.builder() .value(evaluatedValue) - .reason(reasonsString) - .variant(variationKey) + .reason(reason) + .variant(variant) .build(); } @SneakyThrows private Value toValue(OptimizelyJSON optimizelyJson) { + if (optimizelyJson == null) { + return new Value(); + } Map map = optimizelyJson.toMap(); Structure structure = Structure.mapToStructure(map); return new Value(structure); 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 index d92fba154..50ec83206 100644 --- 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 @@ -4,7 +4,6 @@ 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; @@ -14,6 +13,7 @@ import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Value; import dev.openfeature.sdk.exceptions.TargetingKeyMissingError; import java.io.File; @@ -78,24 +78,69 @@ public void testInitializeHandlesNullConfigurationParameters() { @Test public void testGetObjectEvaluation() { EvaluationContext ctx = new MutableContext("targetingKey"); - ProviderEvaluation result = provider.getObjectEvaluation("string-feature", new Value(), ctx); + ProviderEvaluation evaluation = provider.getObjectEvaluation("json_feature_flag", new Value(), ctx); - assertNotNull(result.getValue()); - assertEquals("string_feature_variation", result.getVariant()); assertEquals( - "str1", - result.getValue().asStructure().getValue("string_variable_1").asString()); + "{key1=value1, key2=2.0}", + evaluation.getValue().asStructure().asObjectMap().toString()); - result = provider.getObjectEvaluation("non-existing-object-feature", new Value(), ctx); - assertNotNull(result.getReason()); + MutableContext contextWithVarKey = new MutableContext("targetingKey"); + contextWithVarKey.add("variableKey", "var_json"); + + evaluation = provider.getObjectEvaluation("json_feature_flag", new Value(), contextWithVarKey); + + assertEquals( + "{key1=value1a, key2=3.0}", + evaluation.getValue().asStructure().asObjectMap().toString()); + assertEquals("var_json", evaluation.getVariant()); + assertEquals(Reason.TARGETING_MATCH.name(), evaluation.getReason()); + + MutableContext contextWithNonExistingVarKey = new MutableContext("targetingKey"); + contextWithNonExistingVarKey.add("variableKey", "non-existing-var"); + + evaluation = provider.getObjectEvaluation("json_feature_flag", new Value(), contextWithNonExistingVarKey); + + assertTrue(evaluation.getValue().isNull()); + assertEquals(null, evaluation.getVariant()); + assertEquals(Reason.DEFAULT.name(), evaluation.getReason()); + + EvaluationContext emptyEvaluationContext = new MutableContext(); + assertThrows(TargetingKeyMissingError.class, () -> { + provider.getObjectEvaluation("string-feature", new Value(), emptyEvaluationContext); + }); + + evaluation = provider.getObjectEvaluation("non-existing-feature", new Value(), ctx); + assertTrue(evaluation.getValue().isNull()); + assertEquals(null, evaluation.getVariant()); + assertEquals(Reason.DEFAULT.name(), evaluation.getReason()); } @Test public void testGetBooleanEvaluation() { EvaluationContext ctx = new MutableContext("targetingKey"); - ProviderEvaluation evaluation = provider.getBooleanEvaluation("string-feature", false, ctx); + ProviderEvaluation evaluation = provider.getBooleanEvaluation("boolean_feature_flag", false, ctx); + + assertTrue(evaluation.getValue()); + assertEquals("value", evaluation.getVariant()); + assertEquals(Reason.TARGETING_MATCH.name(), evaluation.getReason()); + + MutableContext contextWithVarKey = new MutableContext("targetingKey"); + contextWithVarKey.add("variableKey", "var_bool"); + + evaluation = provider.getBooleanEvaluation("boolean_feature_flag", false, contextWithVarKey); assertTrue(evaluation.getValue()); + assertEquals("var_bool", evaluation.getVariant()); + assertEquals(Reason.TARGETING_MATCH.name(), evaluation.getReason()); + + MutableContext contextWithNonExistingVarKey = new MutableContext("targetingKey"); + contextWithNonExistingVarKey.add("variableKey", "non-existing-var"); + + evaluation = provider.getBooleanEvaluation("boolean_feature_flag", false, contextWithNonExistingVarKey); + + assertFalse(evaluation.getValue()); + assertEquals(null, evaluation.getVariant()); + assertEquals(Reason.DEFAULT.name(), evaluation.getReason()); EvaluationContext emptyEvaluationContext = new MutableContext(); assertThrows(TargetingKeyMissingError.class, () -> { @@ -104,24 +149,116 @@ public void testGetBooleanEvaluation() { evaluation = provider.getBooleanEvaluation("non-existing-feature", false, ctx); assertFalse(evaluation.getValue()); - assertNotNull(evaluation.getReason()); + assertEquals(null, evaluation.getVariant()); + assertEquals(Reason.DEFAULT.name(), evaluation.getReason()); } @Test - public void testUnsupportedEvaluations() { + public void testGetStringEvaluation() { EvaluationContext ctx = new MutableContext("targetingKey"); + ProviderEvaluation evaluation = provider.getStringEvaluation("string_feature_flag", "", ctx); + + assertEquals("str1", evaluation.getValue()); + + MutableContext contextWithVarKey = new MutableContext("targetingKey"); + contextWithVarKey.add("variableKey", "var_str"); + + evaluation = provider.getStringEvaluation("string_feature_flag", "", contextWithVarKey); - assertThrows(UnsupportedOperationException.class, () -> { - provider.getDoubleEvaluation("string-feature", 0.0, ctx); + assertEquals("str2", evaluation.getValue()); + assertEquals("var_str", evaluation.getVariant()); + assertEquals(Reason.TARGETING_MATCH.name(), evaluation.getReason()); + + MutableContext contextWithNonExistingVarKey = new MutableContext("targetingKey"); + contextWithNonExistingVarKey.add("variableKey", "non-existing-var"); + + evaluation = provider.getStringEvaluation("string_feature_flag", "", contextWithNonExistingVarKey); + + assertEquals("", evaluation.getValue()); + assertEquals(null, evaluation.getVariant()); + assertEquals(Reason.DEFAULT.name(), evaluation.getReason()); + + EvaluationContext emptyEvaluationContext = new MutableContext(); + assertThrows(TargetingKeyMissingError.class, () -> { + provider.getStringEvaluation("string-feature", "", emptyEvaluationContext); }); - assertThrows(UnsupportedOperationException.class, () -> { - provider.getIntegerEvaluation("string-feature", 0, ctx); + evaluation = provider.getStringEvaluation("non-existing-feature", "", ctx); + assertEquals("", evaluation.getValue()); + assertEquals(null, evaluation.getVariant()); + assertEquals(Reason.DEFAULT.name(), evaluation.getReason()); + } + + @Test + public void testGetIntegerEvaluation() { + EvaluationContext ctx = new MutableContext("targetingKey"); + ProviderEvaluation evaluation = provider.getIntegerEvaluation("int_feature_flag", 0, ctx); + + assertEquals(1, evaluation.getValue()); + + MutableContext contextWithVarKey = new MutableContext("targetingKey"); + contextWithVarKey.add("variableKey", "var_int"); + + evaluation = provider.getIntegerEvaluation("int_feature_flag", 0, contextWithVarKey); + + assertEquals(2, evaluation.getValue()); + assertEquals("var_int", evaluation.getVariant()); + assertEquals(Reason.TARGETING_MATCH.name(), evaluation.getReason()); + + MutableContext contextWithNonExistingVarKey = new MutableContext("targetingKey"); + contextWithNonExistingVarKey.add("variableKey", "non-existing-var"); + + evaluation = provider.getIntegerEvaluation("int_feature_flag", 0, contextWithNonExistingVarKey); + + assertEquals(0, evaluation.getValue()); + assertEquals(null, evaluation.getVariant()); + assertEquals(Reason.DEFAULT.name(), evaluation.getReason()); + + EvaluationContext emptyEvaluationContext = new MutableContext(); + assertThrows(TargetingKeyMissingError.class, () -> { + provider.getIntegerEvaluation("string-feature", 0, emptyEvaluationContext); }); - assertThrows(UnsupportedOperationException.class, () -> { - provider.getStringEvaluation("string-feature", "default", ctx); + evaluation = provider.getIntegerEvaluation("non-existing-feature", 0, ctx); + assertEquals(0, evaluation.getValue()); + assertEquals(null, evaluation.getVariant()); + assertEquals(Reason.DEFAULT.name(), evaluation.getReason()); + } + + @Test + public void testGetDoubleEvaluation() { + EvaluationContext ctx = new MutableContext("targetingKey"); + ProviderEvaluation evaluation = provider.getDoubleEvaluation("double_feature_flag", 0.0, ctx); + + assertEquals(1.5, evaluation.getValue()); + + MutableContext contextWithVarKey = new MutableContext("targetingKey"); + contextWithVarKey.add("variableKey", "var_double"); + + evaluation = provider.getDoubleEvaluation("double_feature_flag", 0.0, contextWithVarKey); + + assertEquals(2.5, evaluation.getValue()); + assertEquals("var_double", evaluation.getVariant()); + assertEquals(Reason.TARGETING_MATCH.name(), evaluation.getReason()); + + MutableContext contextWithNonExistingVarKey = new MutableContext("targetingKey"); + contextWithNonExistingVarKey.add("variableKey", "non-existing-var"); + + evaluation = provider.getDoubleEvaluation("double_feature_flag", 0.0, contextWithNonExistingVarKey); + + assertEquals(0.0, evaluation.getValue()); + assertEquals(null, evaluation.getVariant()); + assertEquals(Reason.DEFAULT.name(), evaluation.getReason()); + + EvaluationContext emptyEvaluationContext = new MutableContext(); + assertThrows(TargetingKeyMissingError.class, () -> { + provider.getDoubleEvaluation("string-feature", 0.0, emptyEvaluationContext); }); + + evaluation = provider.getDoubleEvaluation("non-existing-feature", 0.0, ctx); + assertEquals(0.0, evaluation.getValue()); + assertEquals(null, evaluation.getVariant()); + assertEquals(Reason.DEFAULT.name(), evaluation.getReason()); } @SneakyThrows diff --git a/providers/optimizely/src/test/resources/data.json b/providers/optimizely/src/test/resources/data.json index ddc49f8d3..713a12cdc 100644 --- a/providers/optimizely/src/test/resources/data.json +++ b/providers/optimizely/src/test/resources/data.json @@ -16,7 +16,12 @@ "audienceIds": [], "variations": [ { - "variables": [], + "variables": [ + { + "id": "boolean_variable_1", + "value": "true" + } + ], "id": "boolean_feature_variation", "key": "boolean_feature_variation", "featureEnabled": true @@ -81,6 +86,8 @@ "key": "boolean-feature" }, { + "id": "boolean_feature_flag", + "key": "boolean_feature_flag", "experimentIds": [], "rolloutId": "string_feature_rollout", "variables": [ @@ -89,10 +96,100 @@ "type": "string", "id": "string_variable_1", "key": "string_variable_1" + }, + { + "id": "value", + "key": "value", + "defaultValue": "true", + "type": "boolean" + }, + { + "id": "var_bool", + "key": "var_bool", + "defaultValue": "true", + "type": "boolean" } - ], + ] + }, + { "id": "string_feature_flag", - "key": "string-feature" + "key": "string_feature_flag", + "experimentIds": [], + "rolloutId": "string_feature_rollout", + "variables": [ + { + "id": "value", + "key": "value", + "defaultValue": "str1", + "type": "string" + }, + { + "id": "var_str", + "key": "var_str", + "defaultValue": "str2", + "type": "string" + } + ] + }, + { + "id": "int_feature_flag", + "key": "int_feature_flag", + "experimentIds": [], + "rolloutId": "int_feature_rollout", + "variables": [ + { + "id": "value", + "key": "value", + "defaultValue": "1", + "type": "integer" + }, + { + "id": "var_int", + "key": "var_int", + "defaultValue": "2", + "type": "integer" + } + ] + }, + { + "id": "double_feature_flag", + "key": "double_feature_flag", + "experimentIds": [], + "rolloutId": "double_feature_rollout", + "variables": [ + { + "id": "value", + "key": "value", + "defaultValue": "1.5", + "type": "double" + }, + { + "id": "var_double", + "key": "var_double", + "defaultValue": "2.5", + "type": "double" + } + ] + }, + { + "id": "json_feature_flag", + "key": "json_feature_flag", + "experimentIds": [], + "rolloutId": "json_feature_rollout", + "variables": [ + { + "id": "value", + "key": "value", + "defaultValue": "{\"key1\":\"value1\",\"key2\":2}", + "type": "json" + }, + { + "id": "var_json", + "key": "var_json", + "defaultValue": "{\"key1\":\"value1a\",\"key2\":3}", + "type": "json" + } + ] } ], "experiments": [],