From db48d3fbcb0dbda6e1bec17a1af493e3e89a288a Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Tue, 28 Apr 2020 17:50:04 -0700 Subject: [PATCH 001/147] prepare for release 3.4.3 (#374) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b3a98575..59b1c4398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Optimizely Java X SDK Changelog +## [3.4.3] +April 28th, 2020 + +### Bug Fixes: +- Change FeatureVariable type from enum to string for forward compatibility. ([#370](https://github.com/optimizely/java-sdk/pull/370)) + ## [3.4.2] March 30th, 2020 From b08b7a5d870da5cf1043c4ecc1b0b4624679c383 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Fri, 1 May 2020 14:30:56 -0700 Subject: [PATCH 002/147] feat: add "json" type to FeatureVariable (#372) --- .../optimizely/ab/config/FeatureVariable.java | 12 +++- .../ab/config/parser/JsonConfigParser.java | 6 +- .../config/parser/JsonSimpleConfigParser.java | 3 +- .../ab/config/ValidProjectConfigV4.java | 66 ++++++++++++++++--- .../config/parser/GsonConfigParserTest.java | 42 ++++++++++++ .../parser/JacksonConfigParserTest.java | 41 ++++++++++++ .../config/parser/JsonConfigParserTest.java | 42 ++++++++++++ .../parser/JsonSimpleConfigParserTest.java | 44 +++++++++++++ .../OptimizelyConfigServiceTest.java | 6 +- .../config/valid-project-config-v4.json | 31 ++++++++- 10 files changed, 277 insertions(+), 16 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/config/FeatureVariable.java b/core-api/src/main/java/com/optimizely/ab/config/FeatureVariable.java index 7d8657970..92d082a08 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/FeatureVariable.java +++ b/core-api/src/main/java/com/optimizely/ab/config/FeatureVariable.java @@ -65,12 +65,15 @@ public static VariableStatus fromString(String variableStatusString) { public static final String INTEGER_TYPE = "integer"; public static final String DOUBLE_TYPE = "double"; public static final String BOOLEAN_TYPE = "boolean"; + public static final String JSON_TYPE = "json"; private final String id; private final String key; private final String defaultValue; private final String type; @Nullable + private final String subType; // this is for backward-compatibility (json type) + @Nullable private final VariableStatus status; @JsonCreator @@ -78,12 +81,14 @@ public FeatureVariable(@JsonProperty("id") String id, @JsonProperty("key") String key, @JsonProperty("defaultValue") String defaultValue, @JsonProperty("status") VariableStatus status, - @JsonProperty("type") String type) { + @JsonProperty("type") String type, + @JsonProperty("subType") String subType) { this.id = id; this.key = key; this.defaultValue = defaultValue; this.status = status; this.type = type; + this.subType = subType; } @Nullable @@ -104,6 +109,7 @@ public String getDefaultValue() { } public String getType() { + if (type.equals(STRING_TYPE) && subType != null && subType.equals(JSON_TYPE)) return JSON_TYPE; return type; } @@ -114,6 +120,7 @@ public String toString() { ", key='" + key + '\'' + ", defaultValue='" + defaultValue + '\'' + ", type=" + type + + ", subType=" + subType + ", status=" + status + '}'; } @@ -138,7 +145,8 @@ public int hashCode() { result = 31 * result + key.hashCode(); result = 31 * result + defaultValue.hashCode(); result = 31 * result + type.hashCode(); - result = 31 * result + status.hashCode(); + result = 31 * result + (subType != null ? subType.hashCode() : 0); + result = 31 * result + (status != null ? status.hashCode() : 0); return result; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java index 21198da06..0e33ad4b2 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java @@ -345,12 +345,16 @@ private List parseFeatureVariables(JSONArray featureVariablesJs String key = FeatureVariableObject.getString("key"); String defaultValue = FeatureVariableObject.getString("defaultValue"); String type = FeatureVariableObject.getString("type"); + String subType = null; + if (FeatureVariableObject.has("subType")) { + subType = FeatureVariableObject.getString("subType"); + } FeatureVariable.VariableStatus status = null; if (FeatureVariableObject.has("status")) { status = FeatureVariable.VariableStatus.fromString(FeatureVariableObject.getString("status")); } - featureVariables.add(new FeatureVariable(id, key, defaultValue, status, type)); + featureVariables.add(new FeatureVariable(id, key, defaultValue, status, type, subType)); } return featureVariables; diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java index f800595e3..24180c61a 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java @@ -335,9 +335,10 @@ private List parseFeatureVariables(JSONArray featureVariablesJs String key = (String) featureVariableObject.get("key"); String defaultValue = (String) featureVariableObject.get("defaultValue"); String type = (String) featureVariableObject.get("type"); + String subType = (String) featureVariableObject.get("subType"); VariableStatus status = VariableStatus.fromString((String) featureVariableObject.get("status")); - featureVariables.add(new FeatureVariable(id, key, defaultValue, status, type)); + featureVariables.add(new FeatureVariable(id, key, defaultValue, status, type, subType)); } return featureVariables; diff --git a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java index dd79294b8..1c71667f8 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java +++ b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java @@ -247,7 +247,8 @@ public class ValidProjectConfigV4 { VARIABLE_DOUBLE_VARIABLE_KEY, VARIABLE_DOUBLE_DEFAULT_VALUE, null, - FeatureVariable.DOUBLE_TYPE + FeatureVariable.DOUBLE_TYPE, + null ); private static final String FEATURE_SINGLE_VARIABLE_INTEGER_ID = "3281420120"; public static final String FEATURE_SINGLE_VARIABLE_INTEGER_KEY = "integer_single_variable_feature"; @@ -259,7 +260,8 @@ public class ValidProjectConfigV4 { VARIABLE_INTEGER_VARIABLE_KEY, VARIABLE_INTEGER_DEFAULT_VALUE, null, - FeatureVariable.INTEGER_TYPE + FeatureVariable.INTEGER_TYPE, + null ); private static final String FEATURE_SINGLE_VARIABLE_BOOLEAN_ID = "2591051011"; public static final String FEATURE_SINGLE_VARIABLE_BOOLEAN_KEY = "boolean_single_variable_feature"; @@ -271,7 +273,8 @@ public class ValidProjectConfigV4 { VARIABLE_BOOLEAN_VARIABLE_KEY, VARIABLE_BOOLEAN_VARIABLE_DEFAULT_VALUE, null, - FeatureVariable.BOOLEAN_TYPE + FeatureVariable.BOOLEAN_TYPE, + null ); private static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_BOOLEAN = new FeatureFlag( FEATURE_SINGLE_VARIABLE_BOOLEAN_ID, @@ -292,7 +295,8 @@ public class ValidProjectConfigV4 { VARIABLE_STRING_VARIABLE_KEY, VARIABLE_STRING_VARIABLE_DEFAULT_VALUE, null, - FeatureVariable.STRING_TYPE + FeatureVariable.STRING_TYPE, + null ); private static final String ROLLOUT_1_ID = "1058508303"; private static final String ROLLOUT_1_EVERYONE_ELSE_EXPERIMENT_ID = "1785077004"; @@ -388,7 +392,8 @@ public class ValidProjectConfigV4 { VARIABLE_FIRST_LETTER_KEY, VARIABLE_FIRST_LETTER_DEFAULT_VALUE, null, - FeatureVariable.STRING_TYPE + FeatureVariable.STRING_TYPE, + null ); private static final String VARIABLE_REST_OF_NAME_ID = "4052219963"; private static final String VARIABLE_REST_OF_NAME_KEY = "rest_of_name"; @@ -398,9 +403,32 @@ public class ValidProjectConfigV4 { VARIABLE_REST_OF_NAME_KEY, VARIABLE_REST_OF_NAME_DEFAULT_VALUE, null, - FeatureVariable.STRING_TYPE + FeatureVariable.STRING_TYPE, + null + ); + private static final String VARIABLE_JSON_PATCHED_TYPE_ID = "4111661000"; + private static final String VARIABLE_JSON_PATCHED_TYPE_KEY = "json_patched"; + private static final String VARIABLE_JSON_PATCHED_TYPE_DEFAULT_VALUE = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}"; + private static final FeatureVariable VARIABLE_JSON_PATCHED_TYPE_VARIABLE = new FeatureVariable( + VARIABLE_JSON_PATCHED_TYPE_ID, + VARIABLE_JSON_PATCHED_TYPE_KEY, + VARIABLE_JSON_PATCHED_TYPE_DEFAULT_VALUE, + null, + FeatureVariable.STRING_TYPE, + FeatureVariable.JSON_TYPE + ); + private static final String VARIABLE_JSON_NATIVE_TYPE_ID = "4111661001"; + private static final String VARIABLE_JSON_NATIVE_TYPE_KEY = "json_native"; + private static final String VARIABLE_JSON_NATIVE_TYPE_DEFAULT_VALUE = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}"; + private static final FeatureVariable VARIABLE_JSON_NATIVE_TYPE_VARIABLE = new FeatureVariable( + VARIABLE_JSON_NATIVE_TYPE_ID, + VARIABLE_JSON_NATIVE_TYPE_KEY, + VARIABLE_JSON_NATIVE_TYPE_DEFAULT_VALUE, + null, + FeatureVariable.JSON_TYPE, + null ); - private static final String VARIABLE_FUTURE_TYPE_ID = "4111661234"; + private static final String VARIABLE_FUTURE_TYPE_ID = "4111661002"; private static final String VARIABLE_FUTURE_TYPE_KEY = "future_variable"; private static final String VARIABLE_FUTURE_TYPE_DEFAULT_VALUE = "future_value"; private static final FeatureVariable VARIABLE_FUTURE_TYPE_VARIABLE = new FeatureVariable( @@ -408,7 +436,8 @@ public class ValidProjectConfigV4 { VARIABLE_FUTURE_TYPE_KEY, VARIABLE_FUTURE_TYPE_DEFAULT_VALUE, null, - "future_type" + "future_type", + null ); private static final String FEATURE_MUTEX_GROUP_FEATURE_ID = "3263342226"; public static final String FEATURE_MUTEX_GROUP_FEATURE_KEY = "mutex_group_feature"; @@ -420,7 +449,8 @@ public class ValidProjectConfigV4 { VARIABLE_CORRELATING_VARIATION_NAME_KEY, VARIABLE_CORRELATING_VARIATION_NAME_DEFAULT_VALUE, null, - FeatureVariable.STRING_TYPE + FeatureVariable.STRING_TYPE, + null ); // group IDs @@ -733,6 +763,10 @@ public class ValidProjectConfigV4 { new FeatureVariableUsageInstance( VARIABLE_REST_OF_NAME_ID, "red" + ), + new FeatureVariableUsageInstance( + VARIABLE_JSON_PATCHED_TYPE_ID, + "{\"k1\":\"s1\",\"k2\":103.5,\"k3\":false,\"k4\":{\"kk1\":\"ss1\",\"kk2\":true}}" ) ) ); @@ -750,6 +784,10 @@ public class ValidProjectConfigV4 { new FeatureVariableUsageInstance( VARIABLE_REST_OF_NAME_ID, "eorge" + ), + new FeatureVariableUsageInstance( + VARIABLE_JSON_PATCHED_TYPE_ID, + "{\"k1\":\"s2\",\"k2\":203.5,\"k3\":true,\"k4\":{\"kk1\":\"ss2\",\"kk2\":true}}" ) ) ); @@ -768,6 +806,10 @@ public class ValidProjectConfigV4 { new FeatureVariableUsageInstance( VARIABLE_REST_OF_NAME_ID, "red" + ), + new FeatureVariableUsageInstance( + VARIABLE_JSON_PATCHED_TYPE_ID, + "{\"k1\":\"s3\",\"k2\":303.5,\"k3\":true,\"k4\":{\"kk1\":\"ss3\",\"kk2\":false}}" ) ) ); @@ -785,6 +827,10 @@ public class ValidProjectConfigV4 { new FeatureVariableUsageInstance( VARIABLE_REST_OF_NAME_ID, "eorge" + ), + new FeatureVariableUsageInstance( + VARIABLE_JSON_PATCHED_TYPE_ID, + "{\"k1\":\"s4\",\"k2\":403.5,\"k3\":false,\"k4\":{\"kk1\":\"ss4\",\"kk2\":true}}" ) ) ); @@ -1262,6 +1308,8 @@ public class ValidProjectConfigV4 { DatafileProjectConfigTestUtils.createListOfObjects( VARIABLE_FIRST_LETTER_VARIABLE, VARIABLE_REST_OF_NAME_VARIABLE, + VARIABLE_JSON_PATCHED_TYPE_VARIABLE, + VARIABLE_JSON_NATIVE_TYPE_VARIABLE, VARIABLE_FUTURE_TYPE_VARIABLE ) ); diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java index c6d5807bc..de2ac643a 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java @@ -21,6 +21,8 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.reflect.TypeToken; +import com.optimizely.ab.config.FeatureFlag; +import com.optimizely.ab.config.FeatureVariable; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.audience.Audience; import com.optimizely.ab.config.audience.Condition; @@ -42,6 +44,7 @@ import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.verifyProjectConfig; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; /** @@ -92,6 +95,45 @@ public void parseNullFeatureEnabledProjectConfigV4() throws Exception { } + @Test + public void parseFeatureVariablesWithJsonPatched() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // "string" type + "json" subType + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_patched"); + + assertEquals(variable.getType(), "json"); + } + + @Test + public void parseFeatureVariablesWithJsonNative() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // native "json" type + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_native"); + + assertEquals(variable.getType(), "json"); + } + + @Test + public void parseFeatureVariablesWithFutureType() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // unknown type + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("future_variable"); + + assertEquals(variable.getType(), "future_type"); + } + @Test public void parseAudience() throws Exception { JsonObject jsonObject = new JsonObject(); diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java index bca3ebb9a..61c44e730 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java @@ -18,6 +18,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.module.SimpleModule; +import com.optimizely.ab.config.FeatureFlag; +import com.optimizely.ab.config.FeatureVariable; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.audience.Audience; import com.optimizely.ab.config.audience.Condition; @@ -36,6 +38,7 @@ import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.verifyProjectConfig; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; /** @@ -86,6 +89,44 @@ public void parseNullFeatureEnabledProjectConfigV4() throws Exception { } + @Test + public void parseFeatureVariablesWithJsonPatched() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // "string" type + "json" subType + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_patched"); + + assertEquals(variable.getType(), "json"); + } + + @Test + public void parseFeatureVariablesWithJsonNative() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // native "json" type + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_native"); + + assertEquals(variable.getType(), "json"); + } + + @Test + public void parseFeatureVariablesWithFutureType() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // unknown type + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("future_variable"); + + assertEquals(variable.getType(), "future_type"); + } @Test public void parseAudience() throws Exception { diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java index 9e2b52e8e..27995b88f 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java @@ -16,6 +16,8 @@ */ package com.optimizely.ab.config.parser; +import com.optimizely.ab.config.FeatureFlag; +import com.optimizely.ab.config.FeatureVariable; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.audience.AudienceIdCondition; @@ -38,6 +40,7 @@ import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.verifyProjectConfig; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; /** @@ -88,6 +91,45 @@ public void parseNullFeatureEnabledProjectConfigV4() throws Exception { } + @Test + public void parseFeatureVariablesWithJsonPatched() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // "string" type + "json" subType + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_patched"); + + assertEquals(variable.getType(), "json"); + } + + @Test + public void parseFeatureVariablesWithJsonNative() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // native "json" type + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_native"); + + assertEquals(variable.getType(), "json"); + } + + @Test + public void parseFeatureVariablesWithFutureType() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // unknown type + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("future_variable"); + + assertEquals(variable.getType(), "future_type"); + } + @Test public void parseAudience() throws Exception { JSONObject jsonObject = new JSONObject(); diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java index d95b52500..e22192291 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java @@ -16,6 +16,8 @@ */ package com.optimizely.ab.config.parser; +import com.optimizely.ab.config.FeatureFlag; +import com.optimizely.ab.config.FeatureVariable; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.audience.AudienceIdCondition; @@ -30,6 +32,8 @@ import org.junit.Test; import org.junit.rules.ExpectedException; +import java.util.List; + import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.nullFeatureEnabledConfigJsonV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV2; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV4; @@ -38,6 +42,7 @@ import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.verifyProjectConfig; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; /** @@ -88,6 +93,45 @@ public void parseNullFeatureEnabledProjectConfigV4() throws Exception { } + @Test + public void parseFeatureVariablesWithJsonPatched() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // "string" type + "json" subType + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_patched"); + + assertEquals(variable.getType(), "json"); + } + + @Test + public void parseFeatureVariablesWithJsonNative() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // native "json" type + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_native"); + + assertEquals(variable.getType(), "json"); + } + + @Test + public void parseFeatureVariablesWithFutureType() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // unknown type + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("future_variable"); + + assertEquals(variable.getType(), "future_type"); + } + @Test public void parseAudience() throws Exception { JSONObject jsonObject = new JSONObject(); diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java index 44808c93b..94058776b 100644 --- a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java @@ -287,14 +287,16 @@ private ProjectConfig generateOptimizelyConfig() { "first_letter", "H", FeatureVariable.VariableStatus.ACTIVE, - FeatureVariable.STRING_TYPE + FeatureVariable.STRING_TYPE, + null ), new FeatureVariable( "4052219963", "rest_of_name", "arry", FeatureVariable.VariableStatus.ACTIVE, - FeatureVariable.STRING_TYPE + FeatureVariable.STRING_TYPE, + null ) ) ) diff --git a/core-api/src/test/resources/config/valid-project-config-v4.json b/core-api/src/test/resources/config/valid-project-config-v4.json index 293b26cc7..455a5e8bf 100644 --- a/core-api/src/test/resources/config/valid-project-config-v4.json +++ b/core-api/src/test/resources/config/valid-project-config-v4.json @@ -271,6 +271,10 @@ { "id": "4052219963", "value": "red" + }, + { + "id": "4111661000", + "value": "{\"k1\":\"s1\",\"k2\":103.5,\"k3\":false,\"k4\":{\"kk1\":\"ss1\",\"kk2\":true}}" } ] }, @@ -286,6 +290,10 @@ { "id": "4052219963", "value": "eorge" + }, + { + "id": "4111661000", + "value": "{\"k1\":\"s2\",\"k2\":203.5,\"k3\":true,\"k4\":{\"kk1\":\"ss2\",\"kk2\":true}}" } ] }, @@ -301,6 +309,10 @@ { "id": "4052219963", "value": "red" + }, + { + "id": "4111661000", + "value": "{\"k1\":\"s3\",\"k2\":303.5,\"k3\":true,\"k4\":{\"kk1\":\"ss3\",\"kk2\":false}}" } ] }, @@ -316,6 +328,10 @@ { "id": "4052219963", "value": "eorge" + }, + { + "id": "4111661000", + "value": "{\"k1\":\"s4\",\"k2\":403.5,\"k3\":false,\"k4\":{\"kk1\":\"ss4\",\"kk2\":true}}" } ] } @@ -695,7 +711,20 @@ "defaultValue": "arry" }, { - "id": "4111661234", + "id": "4111661000", + "key": "json_patched", + "type": "string", + "subType": "json", + "defaultValue": "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}" + }, + { + "id": "4111661001", + "key": "json_native", + "type": "json", + "defaultValue": "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}" + }, + { + "id": "4111661002", "key": "future_variable", "type": "future_type", "defaultValue": "future_value" From 8609f3d914dfd49a151c4b5d56405ede98453a76 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Tue, 5 May 2020 13:14:48 -0700 Subject: [PATCH 003/147] feature: add OptimizelyJSON (#371) --- .../ab/config/parser/ConfigParser.java | 6 + .../ab/config/parser/GsonConfigParser.java | 41 ++- .../ab/config/parser/JacksonConfigParser.java | 25 +- .../ab/config/parser/JsonConfigParser.java | 29 +- .../ab/config/parser/JsonHelpers.java | 81 +++++ .../ab/config/parser/JsonParseException.java | 27 ++ .../config/parser/JsonSimpleConfigParser.java | 42 ++- .../ab/optimizelyjson/OptimizelyJSON.java | 161 ++++++++++ .../config/parser/GsonConfigParserTest.java | 53 +++- .../parser/JacksonConfigParserTest.java | 59 +++- .../config/parser/JsonConfigParserTest.java | 54 +++- .../ab/config/parser/JsonHelpersTest.java | 89 ++++++ .../parser/JsonSimpleConfigParserTest.java | 51 ++- .../ab/optimizelyjson/OptimizelyJSONTest.java | 299 ++++++++++++++++++ .../OptimizelyJSONWithGsonParserTest.java | 103 ++++++ .../OptimizelyJSONWithJacksonParserTest.java | 100 ++++++ .../OptimizelyJSONWithJsonParserTest.java | 91 ++++++ ...ptimizelyJSONWithJsonSimpleParserTest.java | 93 ++++++ .../ab/optimizelyjson/TestTypes.java | 61 ++++ 19 files changed, 1425 insertions(+), 40 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/config/parser/JsonHelpers.java create mode 100644 core-api/src/main/java/com/optimizely/ab/config/parser/JsonParseException.java create mode 100644 core-api/src/main/java/com/optimizely/ab/optimizelyjson/OptimizelyJSON.java create mode 100644 core-api/src/test/java/com/optimizely/ab/config/parser/JsonHelpersTest.java create mode 100644 core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONTest.java create mode 100644 core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithGsonParserTest.java create mode 100644 core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJacksonParserTest.java create mode 100644 core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJsonParserTest.java create mode 100644 core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJsonSimpleParserTest.java create mode 100644 core-api/src/test/java/com/optimizely/ab/optimizelyjson/TestTypes.java diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/ConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/ConfigParser.java index eb24b68f3..c8fe74b08 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/ConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/ConfigParser.java @@ -38,4 +38,10 @@ public interface ConfigParser { * @throws ConfigParseException when there's an issue parsing the provided project config */ ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParseException; + + /** + * OptimizelyJSON parsing + */ + String toJson(Object src) throws JsonParseException; + T fromJson(String json, Class clazz) throws JsonParseException; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/GsonConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/GsonConfigParser.java index d86f72140..972d76431 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/GsonConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/GsonConfigParser.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, 2019, Optimizely and contributors + * Copyright 2016-2017, 2019-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,7 +27,23 @@ /** * {@link Gson}-based config parser implementation. */ -final class GsonConfigParser implements ConfigParser { +final public class GsonConfigParser implements ConfigParser { + private Gson gson; + + public GsonConfigParser() { + this(new GsonBuilder() + .registerTypeAdapter(Audience.class, new AudienceGsonDeserializer()) + .registerTypeAdapter(TypedAudience.class, new AudienceGsonDeserializer()) + .registerTypeAdapter(Experiment.class, new ExperimentGsonDeserializer()) + .registerTypeAdapter(FeatureFlag.class, new FeatureFlagGsonDeserializer()) + .registerTypeAdapter(Group.class, new GroupGsonDeserializer()) + .registerTypeAdapter(DatafileProjectConfig.class, new DatafileGsonDeserializer()) + .create()); + } + + GsonConfigParser(Gson gson) { + this.gson = gson; + } @Override public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParseException { @@ -37,14 +53,6 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse if (json.length() == 0) { throw new ConfigParseException("Unable to parse empty json."); } - Gson gson = new GsonBuilder() - .registerTypeAdapter(Audience.class, new AudienceGsonDeserializer()) - .registerTypeAdapter(TypedAudience.class, new AudienceGsonDeserializer()) - .registerTypeAdapter(Experiment.class, new ExperimentGsonDeserializer()) - .registerTypeAdapter(FeatureFlag.class, new FeatureFlagGsonDeserializer()) - .registerTypeAdapter(Group.class, new GroupGsonDeserializer()) - .registerTypeAdapter(DatafileProjectConfig.class, new DatafileGsonDeserializer()) - .create(); try { return gson.fromJson(json, DatafileProjectConfig.class); @@ -52,4 +60,17 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse throw new ConfigParseException("Unable to parse datafile: " + json, e); } } + + public String toJson(Object src) { + return gson.toJson(src); + } + + public T fromJson(String json, Class clazz) throws JsonParseException { + try { + return gson.fromJson(json, clazz); + } catch (Exception e) { + throw new JsonParseException("Unable to parse JSON string: " + e.toString()); + } + } + } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JacksonConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JacksonConfigParser.java index e5c5ca5c0..a9b012807 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JacksonConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JacksonConfigParser.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2018, Optimizely and contributors + * Copyright 2016-2018, 2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ */ package com.optimizely.ab.config.parser; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.module.SimpleModule; import com.optimizely.ab.config.DatafileProjectConfig; @@ -25,11 +26,12 @@ import com.optimizely.ab.config.audience.TypedAudience; import javax.annotation.Nonnull; +import java.io.IOException; /** * {@code Jackson}-based config parser implementation. */ -final class JacksonConfigParser implements ConfigParser { +final public class JacksonConfigParser implements ConfigParser { private ObjectMapper objectMapper; public JacksonConfigParser() { @@ -61,4 +63,23 @@ public ProjectConfigModule() { addDeserializer(Condition.class, new ConditionJacksonDeserializer(objectMapper)); } } + + @Override + public String toJson(Object src) throws JsonParseException { + try { + return objectMapper.writeValueAsString(src); + } catch (JsonProcessingException e) { + throw new JsonParseException("Serialization failed: " + e.toString()); + } + } + + @Override + public T fromJson(String json, Class clazz) throws JsonParseException { + try { + return objectMapper.readValue(json, clazz); + } catch (IOException e) { + throw new JsonParseException("Unable to parse JSON string: " + e.toString()); + } + } + } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java index 0e33ad4b2..ad0d971bd 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2019, Optimizely and contributors + * Copyright 2016-2019, 2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,17 +28,12 @@ import org.json.JSONTokener; import javax.annotation.Nonnull; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; /** * {@code org.json}-based config parser implementation. */ -final class JsonConfigParser implements ConfigParser { +final public class JsonConfigParser implements ConfigParser { @Override public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParseException { @@ -389,4 +384,22 @@ private List parseRollouts(JSONArray rolloutsJson) { return rollouts; } + + @Override + public String toJson(Object src) { + JSONObject json = (JSONObject)JsonHelpers.convertToJsonObject(src); + return json.toString(); + } + + @Override + public T fromJson(String json, Class clazz) throws JsonParseException { + if (Map.class.isAssignableFrom(clazz)) { + JSONObject obj = new JSONObject(json); + return (T)JsonHelpers.jsonObjectToMap(obj); + } + + // org.json parser does not support parsing to user objects + throw new JsonParseException("Parsing fails with a unsupported type"); + } + } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonHelpers.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonHelpers.java new file mode 100644 index 000000000..405c863c5 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonHelpers.java @@ -0,0 +1,81 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.parser; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.*; + +final class JsonHelpers { + + static Object convertToJsonObject(Object obj) { + if (obj instanceof Map) { + Map map = (Map)obj; + JSONObject jObj = new JSONObject(); + for (Map.Entry entry : map.entrySet()) { + jObj.put(entry.getKey().toString(), convertToJsonObject(entry.getValue())); + } + return jObj; + } else if (obj instanceof List) { + List list = (List)obj; + JSONArray jArray = new JSONArray(); + for (Object value : list) { + jArray.put(convertToJsonObject(value)); + } + return jArray; + } else { + return obj; + } + } + + static Map jsonObjectToMap(JSONObject jObj) { + Map map = new HashMap<>(); + + Iterator keys = jObj.keys(); + while(keys.hasNext()) { + String key = keys.next(); + Object value = jObj.get(key); + + if (value instanceof JSONArray) { + value = jsonArrayToList((JSONArray)value); + } else if (value instanceof JSONObject) { + value = jsonObjectToMap((JSONObject)value); + } + + map.put(key, value); + } + + return map; + } + + static List jsonArrayToList(JSONArray array) { + List list = new ArrayList<>(); + for(Object value : array) { + if (value instanceof JSONArray) { + value = jsonArrayToList((JSONArray)value); + } else if (value instanceof JSONObject) { + value = jsonObjectToMap((JSONObject)value); + } + + list.add(value); + } + + return list; + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonParseException.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonParseException.java new file mode 100644 index 000000000..0e77b7571 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonParseException.java @@ -0,0 +1,27 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.parser; + +public final class JsonParseException extends Exception { + public JsonParseException(String message) { + super(message); + } + + public JsonParseException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java index 24180c61a..b6236ffa7 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2019, Optimizely and contributors + * Copyright 2016-2019, 2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,22 +26,20 @@ import com.optimizely.ab.internal.ConditionUtils; import org.json.simple.JSONArray; import org.json.simple.JSONObject; +import org.json.simple.JSONValue; +import org.json.simple.parser.ContainerFactory; import org.json.simple.parser.JSONParser; import org.json.simple.parser.ParseException; import javax.annotation.Nonnull; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.logging.Level; import java.util.logging.Logger; /** * {@code json-simple}-based config parser implementation. */ -final class JsonSimpleConfigParser implements ConfigParser { +final public class JsonSimpleConfigParser implements ConfigParser { @Override public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParseException { @@ -372,5 +370,35 @@ private List parseRollouts(JSONArray rolloutsJson) { return rollouts; } + + @Override + public String toJson(Object src) { + return JSONValue.toJSONString(src); + } + + @Override + public T fromJson(String json, Class clazz) throws JsonParseException { + if (Map.class.isAssignableFrom(clazz)) { + try { + return (T)new JSONParser().parse(json, new ContainerFactory() { + @Override + public Map createObjectContainer() { + return new HashMap(); + } + + @Override + public List creatArrayContainer() { + return new ArrayList(); + } + }); + } catch (ParseException e) { + throw new JsonParseException("Unable to parse JSON string: " + e.toString()); + } + } + + // org.json.simple does not support parsing to user objects + throw new JsonParseException("Parsing fails with a unsupported type"); + } + } diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyjson/OptimizelyJSON.java b/core-api/src/main/java/com/optimizely/ab/optimizelyjson/OptimizelyJSON.java new file mode 100644 index 000000000..811999e24 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyjson/OptimizelyJSON.java @@ -0,0 +1,161 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelyjson; + +import com.optimizely.ab.config.parser.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Map; + +/** + * OptimizelyJSON is an object for accessing values of JSON-type feature variables + */ +public class OptimizelyJSON { + @Nullable + private String payload; + @Nullable + private Map map; + + private ConfigParser parser; + + private static final Logger logger = LoggerFactory.getLogger(OptimizelyJSON.class); + + public OptimizelyJSON(@Nonnull String payload) { + this(payload, DefaultConfigParser.getInstance()); + } + + public OptimizelyJSON(@Nonnull String payload, ConfigParser parser) { + this.payload = payload; + this.parser = parser; + } + + public OptimizelyJSON(@Nonnull Map map) { + this(map, DefaultConfigParser.getInstance()); + } + + public OptimizelyJSON(@Nonnull Map map, ConfigParser parser) { + this.map = map; + this.parser = parser; + } + + /** + * Returns the string representation of json data + */ + @Nonnull + public String toString() { + if (payload == null && map != null) { + try { + payload = parser.toJson(map); + } catch (JsonParseException e) { + logger.error("Provided map could not be converted to a string ({})", e.toString()); + } + } + + return payload != null ? payload : ""; + } + + /** + * Returns the {@code Map} representation of json data + */ + @Nullable + public Map toMap() { + if (map == null && payload != null) { + try { + map = parser.fromJson(payload, Map.class); + } catch (Exception e) { + logger.error("Provided string could not be converted to a dictionary ({})", e.toString()); + } + } + + return map; + } + + /** + * Populates the schema passed by the user - it takes primitive types and complex struct type + *

+ * Example: + *

+     *  JSON data is {"k1":true, "k2":{"k22":"v22"}}
+     *
+     *  Set jsonKey to "k2" to access {"k22":"v22"} or set it to to "k2.k22" to access "v22".
+     *  Set it to null to access the entire JSON data.
+     * 
+ * + * @param jsonKey The JSON key paths for the data to access + * @param clazz The user-defined class that the json data will be parsed to + * @return an instance of clazz type with the parsed data filled in (or null if parse fails) + */ + @Nullable + public T getValue(@Nullable String jsonKey, Class clazz) throws JsonParseException { + if (!(parser instanceof GsonConfigParser || parser instanceof JacksonConfigParser)) { + throw new JsonParseException("A proper JSON parser is not available. Use Gson or Jackson parser for this operation."); + } + + Map subMap = toMap(); + T result = null; + + if (jsonKey == null || jsonKey.isEmpty()) { + return getValueInternal(subMap, clazz); + } + + String[] keys = jsonKey.split("\\.", -1); // -1 to keep trailing empty fields + + for(int i=0; i) subMap.get(key); + } else { + logger.error("Value for JSON key ({}) not found.", jsonKey); + break; + } + } + + if (result == null) { + logger.error("Value for path could not be assigned to provided schema."); + } + return result; + } + + private T getValueInternal(@Nullable Object object, Class clazz) { + if (object == null) return null; + + if (clazz.isInstance(object)) return (T)object; // primitive (String, Boolean, Integer, Double) + + try { + String payload = parser.toJson(object); + return parser.fromJson(payload, clazz); + } catch (Exception e) { + logger.error("Map to Java Object failed ({})", e.toString()); + } + + return null; + } + +} + diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java index de2ac643a..69ffd81a2 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -34,7 +34,9 @@ import org.junit.rules.ExpectedException; import java.lang.reflect.Type; +import java.util.HashMap; import java.util.List; +import java.util.Map; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.nullFeatureEnabledConfigJsonV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV2; @@ -44,8 +46,7 @@ import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.verifyProjectConfig; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.*; /** * Tests for {@link GsonConfigParser}. @@ -308,4 +309,50 @@ public void nullJsonExceptionWrapping() throws Exception { GsonConfigParser parser = new GsonConfigParser(); parser.parseProjectConfig(null); } + + @Test + public void testToJson() { + Map map = new HashMap<>(); + map.put("k1", "v1"); + map.put("k2", 3.5); + map.put("k3", true); + + String expectedString = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true}"; + + GsonConfigParser parser = new GsonConfigParser(); + String json = parser.toJson(map); + assertEquals(json, expectedString); + } + + @Test + public void testFromJson() { + String json = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true}"; + + Map expectedMap = new HashMap<>(); + expectedMap.put("k1", "v1"); + expectedMap.put("k2", 3.5); + expectedMap.put("k3", true); + + GsonConfigParser parser = new GsonConfigParser(); + + Map map = null; + try { + map = parser.fromJson(json, Map.class); + assertEquals(map, expectedMap); + } catch (JsonParseException e) { + fail("Parse to map failed: " + e.getMessage()); + } + + // invalid JSON string + + String invalidJson = "'k1':'v1','k2':3.5"; + try { + map = parser.fromJson(invalidJson, Map.class); + fail("Expected failure for parsing: " + map.toString()); + } catch (JsonParseException e) { + assertTrue(true); + } + + } + } diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java index 61c44e730..896cadcb6 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, 2019, Optimizely and contributors + * Copyright 2016-2017, 2019-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,6 +30,9 @@ import org.junit.Test; import org.junit.rules.ExpectedException; +import java.util.HashMap; +import java.util.Map; + import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.nullFeatureEnabledConfigJsonV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV2; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV3; @@ -38,8 +41,7 @@ import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.verifyProjectConfig; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.*; /** * Tests for {@link JacksonConfigParser}. @@ -300,4 +302,55 @@ public void nullJsonExceptionWrapping() throws Exception { JacksonConfigParser parser = new JacksonConfigParser(); parser.parseProjectConfig(null); } + + @Test + public void testToJson() { + Map map = new HashMap<>(); + map.put("k1", "v1"); + map.put("k2", 3.5); + map.put("k3", true); + + String expectedString = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true}"; + + JacksonConfigParser parser = new JacksonConfigParser(); + String json = null; + try { + json = parser.toJson(map); + assertEquals(json, expectedString); + } catch (JsonParseException e) { + fail("Parse to serialize to a JSON string: " + e.getMessage()); + } + } + + @Test + public void testFromJson() { + String json = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true}"; + + Map expectedMap = new HashMap<>(); + expectedMap.put("k1", "v1"); + expectedMap.put("k2", 3.5); + expectedMap.put("k3", true); + + JacksonConfigParser parser = new JacksonConfigParser(); + + Map map = null; + try { + map = parser.fromJson(json, Map.class); + assertEquals(map, expectedMap); + } catch (JsonParseException e) { + fail("Parse to map failed: " + e.getMessage()); + } + + // invalid JSON string + + String invalidJson = "'k1':'v1','k2':3.5"; + try { + map = parser.fromJson(invalidJson, Map.class); + fail("Expected failure for parsing: " + map.toString()); + } catch (JsonParseException e) { + assertTrue(true); + } + + } + } diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java index 27995b88f..b155c1954 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, 2019, Optimizely and contributors + * Copyright 2016-2017, 2019-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,7 +19,6 @@ import com.optimizely.ab.config.FeatureFlag; import com.optimizely.ab.config.FeatureVariable; import com.optimizely.ab.config.ProjectConfig; - import com.optimizely.ab.config.audience.AudienceIdCondition; import com.optimizely.ab.config.audience.Condition; import com.optimizely.ab.config.audience.UserAttribute; @@ -32,6 +31,10 @@ import org.junit.Test; import org.junit.rules.ExpectedException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.nullFeatureEnabledConfigJsonV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV2; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV4; @@ -40,8 +43,7 @@ import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.verifyProjectConfig; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.*; /** * Tests for {@link JsonConfigParser}. @@ -252,4 +254,48 @@ public void nullJsonExceptionWrapping() throws Exception { JsonConfigParser parser = new JsonConfigParser(); parser.parseProjectConfig(null); } + + @Test + public void testToJson() { + Map map = new HashMap<>(); + map.put("k1", "v1"); + map.put("k2", 3.5); + map.put("k3", true); + + String expectedString = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true}"; + + JsonConfigParser parser = new JsonConfigParser(); + String json = parser.toJson(map); + assertEquals(json, expectedString); + } + + @Test + public void testFromJson() { + String json = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true}"; + + Map expectedMap = new HashMap<>(); + expectedMap.put("k1", "v1"); + expectedMap.put("k2", 3.5); + expectedMap.put("k3", true); + + JsonConfigParser parser = new JsonConfigParser(); + + Map map = null; + try { + map = parser.fromJson(json, Map.class); + assertEquals(map, expectedMap); + } catch (JsonParseException e) { + fail("Parse to map failed: " + e.getMessage()); + } + + // not-supported parse type + + try { + List value = parser.fromJson(json, List.class); + fail("Unsupported parse target type: " + value.toString()); + } catch (JsonParseException e) { + assertTrue(true); + } + } + } diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonHelpersTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonHelpersTest.java new file mode 100644 index 000000000..330abea75 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonHelpersTest.java @@ -0,0 +1,89 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.parser; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.*; + +/** + * Tests for {@link JsonHelpers}. + */ +public class JsonHelpersTest { + private Map map; + private JSONArray jsonArray; + private JSONObject jsonObject; + + @Before + public void setUp() throws Exception { + List list = new ArrayList(); + list.add("vv1"); + list.add(true); + + map = new HashMap(); + map.put("k1", "v1"); + map.put("k2", 3.5); + map.put("k3", list); + + jsonArray = new JSONArray(); + jsonArray.put("vv1"); + jsonArray.put(true); + + jsonObject = new JSONObject(); + jsonObject.put("k1", "v1"); + jsonObject.put("k2", 3.5); + jsonObject.put("k3", jsonArray); + } + @Test + public void testConvertToJsonObject() { + JSONObject value = (JSONObject) JsonHelpers.convertToJsonObject(map); + + assertEquals(value.getString("k1"), "v1"); + assertEquals(value.getDouble("k2"), 3.5, 0.01); + JSONArray array = value.getJSONArray("k3"); + assertEquals(array.get(0), "vv1"); + assertEquals(array.get(1), true); + } + + @Test + public void testJsonObjectToMap() { + Map value = JsonHelpers.jsonObjectToMap(jsonObject); + + assertEquals(value.get("k1"), "v1"); + assertEquals((Double) value.get("k2"), 3.5, 0.01); + ArrayList array = (ArrayList) value.get("k3"); + assertEquals(array.get(0), "vv1"); + assertEquals(array.get(1), true); + } + + @Test + public void testJsonArrayToList() { + List value = JsonHelpers.jsonArrayToList(jsonArray); + + assertEquals(value.get(0), "vv1"); + assertEquals(value.get(1), true); + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java index e22192291..86150c51a 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, 2019, Optimizely and contributors + * Copyright 2016-2017, 2019-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,7 +32,9 @@ import org.junit.Test; import org.junit.rules.ExpectedException; +import java.util.HashMap; import java.util.List; +import java.util.Map; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.nullFeatureEnabledConfigJsonV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV2; @@ -42,8 +44,7 @@ import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.verifyProjectConfig; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.*; /** * Tests for {@link JsonSimpleConfigParser}. @@ -254,4 +255,48 @@ public void nullJsonExceptionWrapping() throws Exception { JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); parser.parseProjectConfig(null); } + + @Test + public void testToJson() { + Map map = new HashMap<>(); + map.put("k1", "v1"); + map.put("k2", 3.5); + map.put("k3", true); + + String expectedString = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true}"; + + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + String json = parser.toJson(map); + assertEquals(json, expectedString); + } + + @Test + public void testFromJson() { + String json = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true}"; + + Map expectedMap = new HashMap<>(); + expectedMap.put("k1", "v1"); + expectedMap.put("k2", 3.5); + expectedMap.put("k3", true); + + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + + Map map = null; + try { + map = parser.fromJson(json, Map.class); + assertEquals(map, expectedMap); + } catch (JsonParseException e) { + fail("Parse to map failed: " + e.getMessage()); + } + + // not-supported parse type + + try { + List value = parser.fromJson(json, List.class); + fail("Unsupported parse target type: " + value.toString()); + } catch (JsonParseException e) { + assertEquals(e.getMessage(), "Parsing fails with a unsupported type"); + } + } + } diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONTest.java new file mode 100644 index 000000000..501c5d17d --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONTest.java @@ -0,0 +1,299 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelyjson; + +import com.optimizely.ab.config.parser.*; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.io.IOException; +import java.util.*; + +import static org.junit.Assert.*; +import static org.junit.Assume.assumeTrue; + +/** + * Common tests for all JSON parsers + */ +@RunWith(Parameterized.class) +public class OptimizelyJSONTest { + + @Parameterized.Parameters(name = "{index}: {0}") + public static Collection data() throws IOException { + return Arrays.asList( + new GsonConfigParser(), + new JacksonConfigParser(), + new JsonConfigParser(), + new JsonSimpleConfigParser() + ); + } + + @Parameterized.Parameter(0) + public ConfigParser parser; + + private String orgJson; + private Map orgMap; + private boolean canSupportGetValue; + + @Before + public void setUp() throws Exception { + Class parserClass = parser.getClass(); + canSupportGetValue = parserClass.equals(GsonConfigParser.class) || + parserClass.equals(JacksonConfigParser.class); + + orgJson = + "{ " + + " \"k1\": \"v1\", " + + " \"k2\": true, " + + " \"k3\": { " + + " \"kk1\": 1.2, " + + " \"kk2\": { " + + " \"kkk1\": true, " + + " \"kkk2\": 3.5, " + + " \"kkk3\": \"vvv3\", " + + " \"kkk4\": [5.7, true, \"vvv4\"] " + + " } " + + " } " + + "} "; + + Map m3 = new HashMap(); + m3.put("kkk1", true); + m3.put("kkk2", 3.5); + m3.put("kkk3", "vvv3"); + m3.put("kkk4", new ArrayList(Arrays.asList(5.7, true, "vvv4"))); + + Map m2 = new HashMap(); + m2.put("kk1", 1.2); + m2.put("kk2", m3); + + Map m1 = new HashMap(); + m1.put("k1", "v1"); + m1.put("k2", true); + m1.put("k3", m2); + + orgMap = m1; + } + + private String compact(String str) { + return str.replaceAll("\\s", ""); + } + + + // Common tests for all parsers (GSON, Jackson, Json, JsonSimple) + @Test + public void testOptimizelyJSON() { + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + Map map = oj1.toMap(); + + OptimizelyJSON oj2 = new OptimizelyJSON(map, parser); + String data = oj2.toString(); + + assertEquals(compact(data), compact(orgJson)); + } + + @Test + public void testToStringFromString() { + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + assertEquals(compact(oj1.toString()), compact(orgJson)); + } + + @Test + public void testToStringFromMap() { + OptimizelyJSON oj1 = new OptimizelyJSON(orgMap, parser); + assertEquals(compact(oj1.toString()), compact(orgJson)); + } + + @Test + public void testToMapFromString() { + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + assertEquals(oj1.toMap(), orgMap); + } + + @Test + public void testToMapFromMap() { + OptimizelyJSON oj1 = new OptimizelyJSON(orgMap, parser); + assertEquals(oj1.toMap(), orgMap); + } + + // GetValue tests + + @Test + public void testGetValueNullKeyPath() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + TestTypes.MD1 md1 = oj1.getValue(null, TestTypes.MD1.class); + assertNotNull(md1); + assertEquals(md1.k1, "v1"); + assertEquals(md1.k2, true); + assertEquals(md1.k3.kk1, 1.2, 0.01); + assertEquals(md1.k3.kk2.kkk1, true); + assertEquals((Double)md1.k3.kk2.kkk4[0], 5.7, 0.01); + assertEquals(md1.k3.kk2.kkk4[2], "vvv4"); + } + + @Test + public void testGetValueEmptyKeyPath() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + TestTypes.MD1 md1 = oj1.getValue("", TestTypes.MD1.class); + assertEquals(md1.k1, "v1"); + assertEquals(md1.k2, true); + assertEquals(md1.k3.kk1, 1.2, 0.01); + assertEquals(md1.k3.kk2.kkk1, true); + assertEquals((Double) md1.k3.kk2.kkk4[0], 5.7, 0.01); + assertEquals(md1.k3.kk2.kkk4[2], "vvv4"); + } + + @Test + public void testGetValueWithKeyPathToMapWithLevel1() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + TestTypes.MD2 md2 = oj1.getValue("k3", TestTypes.MD2.class); + assertNotNull(md2); + assertEquals(md2.kk1, 1.2, 0.01); + assertEquals(md2.kk2.kkk1, true); + } + + @Test + public void testGetValueWithKeyPathToMapWithLevel2() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + TestTypes.MD3 md3 = oj1.getValue("k3.kk2", TestTypes.MD3.class); + assertNotNull(md3); + assertEquals(md3.kkk1, true); + } + + @Test + public void testGetValueWithKeyPathToBoolean() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + Boolean value = oj1.getValue("k3.kk2.kkk1", Boolean.class); + assertNotNull(value); + assertEquals(value, true); + } + + @Test + public void testGetValueWithKeyPathToDouble() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + Double value = oj1.getValue("k3.kk2.kkk2", Double.class); + assertNotNull(value); + assertEquals(value.doubleValue(), 3.5, 0.01); + } + + @Test + public void testGetValueWithKeyPathToString() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + String value = oj1.getValue("k3.kk2.kkk3", String.class); + assertNotNull(value); + assertEquals(value, "vvv3"); + } + + @Test + public void testGetValueNotDestroying() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + TestTypes.MD3 md3 = oj1.getValue("k3.kk2", TestTypes.MD3.class); + assertNotNull(md3); + assertEquals(md3.kkk1, true); + assertEquals(md3.kkk2, 3.5, 0.01); + assertEquals(md3.kkk3, "vvv3"); + assertEquals((Double) md3.kkk4[0], 5.7, 0.01); + assertEquals(md3.kkk4[2], "vvv4"); + + // verify previous getValue does not destroy the data + + TestTypes.MD3 newMd3 = oj1.getValue("k3.kk2", TestTypes.MD3.class); + assertNotNull(newMd3); + assertEquals(newMd3.kkk1, true); + assertEquals(newMd3.kkk2, 3.5, 0.01); + assertEquals(newMd3.kkk3, "vvv3"); + assertEquals((Double) newMd3.kkk4[0], 5.7, 0.01); + assertEquals(newMd3.kkk4[2], "vvv4"); + } + + @Test + public void testGetValueWithInvalidKeyPath() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + String value = oj1.getValue("k3..kkk3", String.class); + assertNull(value); + } + + @Test + public void testGetValueWithInvalidKeyPath2() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + String value = oj1.getValue("k1.", String.class); + assertNull(value); + } + + @Test + public void testGetValueWithInvalidKeyPath3() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + String value = oj1.getValue("x9", String.class); + assertNull(value); + } + + @Test + public void testGetValueWithInvalidKeyPath4() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + String value = oj1.getValue("k3.x9", String.class); + assertNull(value); + } + + @Test + public void testGetValueWithWrongType() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + Integer value = oj1.getValue("k3.kk2.kkk3", Integer.class); + assertNull(value); + } + +} + diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithGsonParserTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithGsonParserTest.java new file mode 100644 index 000000000..ebbed4bf5 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithGsonParserTest.java @@ -0,0 +1,103 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelyjson; + +import com.optimizely.ab.config.parser.ConfigParser; +import com.optimizely.ab.config.parser.GsonConfigParser; +import com.optimizely.ab.config.parser.JsonParseException; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +/** + * Tests for GSON parser only + */ +public class OptimizelyJSONWithGsonParserTest { + protected ConfigParser getParser() { + return new GsonConfigParser(); + } + + @Test + public void testGetValueWithNotMatchingType() throws JsonParseException { + OptimizelyJSON oj1 = new OptimizelyJSON("{\"k1\": 3.5}", getParser()); + + // GSON returns non-null object but variable is null (while Jackson returns null object) + + TestTypes.NotMatchingType md = oj1.getValue(null, TestTypes.NotMatchingType.class); + assertNull(md.x99); + } + + // Tests for integer/double processing + + @Test + public void testIntegerProcessing() throws JsonParseException { + + // GSON parser toMap() adds ".0" to all integers + + String json = "{\"k1\":1,\"k2\":2.5,\"k3\":{\"kk1\":3,\"kk2\":4.0}}"; + + Map m2 = new HashMap(); + m2.put("kk1", 3.0); + m2.put("kk2", 4.0); + + Map m1 = new HashMap(); + m1.put("k1", 1.0); + m1.put("k2", 2.5); + m1.put("k3", m2); + + OptimizelyJSON oj1 = new OptimizelyJSON(json, getParser()); + assertEquals(oj1.toMap(), m1); + } + + @Test + public void testIntegerProcessing2() throws JsonParseException { + + // GSON parser toString() keeps ".0" in double + + String json = "{\"k1\":1,\"k2\":2.5,\"k3\":{\"kk1\":3,\"kk2\":4.0}}"; + + Map m2 = new HashMap(); + m2.put("kk1", 3); + m2.put("kk2", 4.0); + + Map m1 = new HashMap(); + m1.put("k1", 1); + m1.put("k2", 2.5); + m1.put("k3", m2); + + OptimizelyJSON oj1 = new OptimizelyJSON(m1, getParser()); + assertEquals(oj1.toString(), json); + } + + @Test + public void testIntegerProcessing3() throws JsonParseException { + String json = "{\"k1\":1,\"k2\":2.5,\"k3\":{\"kk1\":3,\"kk2\":4.0}}"; + + OptimizelyJSON oj1 = new OptimizelyJSON(json, getParser()); + TestTypes.MDN1 obj = oj1.getValue(null, TestTypes.MDN1.class); + + assertEquals(obj.k1, 1); + assertEquals(obj.k2, 2.5, 0.01); + assertEquals(obj.k3.kk1, 3); + assertEquals(obj.k3.kk2, 4.0, 0.01); + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJacksonParserTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJacksonParserTest.java new file mode 100644 index 000000000..c8f800918 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJacksonParserTest.java @@ -0,0 +1,100 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelyjson; + +import com.optimizely.ab.config.parser.ConfigParser; +import com.optimizely.ab.config.parser.JacksonConfigParser; +import com.optimizely.ab.config.parser.JsonParseException; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +/** + * Tests for Jackson parser only + */ +public class OptimizelyJSONWithJacksonParserTest { + protected ConfigParser getParser() { + return new JacksonConfigParser(); + } + + @Test + public void testGetValueWithNotMatchingType() throws JsonParseException { + OptimizelyJSON oj1 = new OptimizelyJSON("{\"k1\": 3.5}", getParser()); + + // Jackson returns null object when variables not matching (while GSON returns an object with null variables + + TestTypes.NotMatchingType md = oj1.getValue(null, TestTypes.NotMatchingType.class); + assertNull(md); + } + + // Tests for integer/double processing + + @Test + public void testIntegerProcessing() throws JsonParseException { + + // Jackson parser toMap() keeps ".0" in double + + String json = "{\"k1\":1,\"k2\":2.5,\"k3\":{\"kk1\":3,\"kk2\":4.0}}"; + + Map m2 = new HashMap(); + m2.put("kk1", 3); + m2.put("kk2", 4.0); + + Map m1 = new HashMap(); + m1.put("k1", 1); + m1.put("k2", 2.5); + m1.put("k3", m2); + + OptimizelyJSON oj1 = new OptimizelyJSON(json, getParser()); + assertEquals(oj1.toMap(), m1); + } + + @Test + public void testIntegerProcessing2() throws JsonParseException { + + // Jackson parser toString() keeps ".0" in double + + String json = "{\"k1\":1,\"k2\":2.5,\"k3\":{\"kk1\":3,\"kk2\":4.0}}"; + + Map m2 = new HashMap(); + m2.put("kk1", 3); + m2.put("kk2", 4.0); + + Map m1 = new HashMap(); + m1.put("k1", 1); + m1.put("k2", 2.5); + m1.put("k3", m2); + + OptimizelyJSON oj1 = new OptimizelyJSON(m1, getParser()); + assertEquals(oj1.toString(), json); + } + + @Test + public void testIntegerProcessing3() throws JsonParseException { + String json = "{\"k1\":1,\"k2\":2.5,\"k3\":{\"kk1\":3,\"kk2\":4.0}}"; + + OptimizelyJSON oj1 = new OptimizelyJSON(json, getParser()); + TestTypes.MDN1 obj = oj1.getValue(null, TestTypes.MDN1.class); + + assertEquals(obj.k1, 1); + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJsonParserTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJsonParserTest.java new file mode 100644 index 000000000..05e308a39 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJsonParserTest.java @@ -0,0 +1,91 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelyjson; + +import com.optimizely.ab.config.parser.ConfigParser; +import com.optimizely.ab.config.parser.JsonConfigParser; +import com.optimizely.ab.config.parser.JsonParseException; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.*; + +/** + * Tests for org.json parser only + */ +public class OptimizelyJSONWithJsonParserTest { + protected ConfigParser getParser() { + return new JsonConfigParser(); + } + + @Test + public void testGetValueThrowsException() { + OptimizelyJSON oj1 = new OptimizelyJSON("{\"k1\": 3.5}", getParser()); + + try { + String str = oj1.getValue(null, String.class); + fail("GetValue is not supported for or.json paraser: " + str); + } catch (JsonParseException e) { + assertEquals(e.getMessage(), "A proper JSON parser is not available. Use Gson or Jackson parser for this operation."); + } + } + + // Tests for integer/double processing + + @Test + public void testIntegerProcessing() throws JsonParseException { + + // org.json parser toMap() keeps ".0" in double + + String json = "{\"k1\":1,\"k2\":2.5,\"k3\":{\"kk1\":3,\"kk2\":4.0}}"; + + Map m2 = new HashMap(); + m2.put("kk1", 3); + m2.put("kk2", 4.0); + + Map m1 = new HashMap(); + m1.put("k1", 1); + m1.put("k2", 2.5); + m1.put("k3", m2); + + OptimizelyJSON oj1 = new OptimizelyJSON(json, getParser()); + assertEquals(oj1.toMap(), m1); + } + + @Test + public void testIntegerProcessing2() throws JsonParseException { + + // org.json parser toString() drops ".0" from double + + String json = "{\"k1\":1,\"k2\":2.5,\"k3\":{\"kk1\":3,\"kk2\":4}}"; + + Map m2 = new HashMap(); + m2.put("kk1", 3); + m2.put("kk2", 4.0); + + Map m1 = new HashMap(); + m1.put("k1", 1); + m1.put("k2", 2.5); + m1.put("k3", m2); + + OptimizelyJSON oj1 = new OptimizelyJSON(m1, getParser()); + assertEquals(oj1.toString(), json); + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJsonSimpleParserTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJsonSimpleParserTest.java new file mode 100644 index 000000000..d66ca63a1 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJsonSimpleParserTest.java @@ -0,0 +1,93 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelyjson; + +import com.optimizely.ab.config.parser.ConfigParser; +import com.optimizely.ab.config.parser.JsonParseException; +import com.optimizely.ab.config.parser.JsonSimpleConfigParser; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +/** + * Tests for org.json.simple parser only + */ +public class OptimizelyJSONWithJsonSimpleParserTest { + protected ConfigParser getParser() { + return new JsonSimpleConfigParser(); + } + + @Test + public void testGetValueThrowsException() { + OptimizelyJSON oj1 = new OptimizelyJSON("{\"k1\": 3.5}", getParser()); + + try { + String str = oj1.getValue(null, String.class); + fail("GetValue is not supported for or.json paraser: " + str); + } catch (JsonParseException e) { + assertEquals(e.getMessage(), "A proper JSON parser is not available. Use Gson or Jackson parser for this operation."); + } + } + + // Tests for integer/double processing + + @Test + public void testIntegerProcessing() throws JsonParseException { + + // org.json.simple parser toMap() keeps ".0" in double + // org.json.simple parser toMap() return Long type for integer value + + String json = "{\"k1\":1,\"k2\":2.5,\"k3\":{\"kk1\":3,\"kk2\":4.0}}"; + + Map m2 = new HashMap(); + m2.put("kk1", Long.valueOf(3)); + m2.put("kk2", 4.0); + + Map m1 = new HashMap(); + m1.put("k1", Long.valueOf(1)); + m1.put("k2", 2.5); + m1.put("k3", m2); + + OptimizelyJSON oj1 = new OptimizelyJSON(json, getParser()); + assertEquals(oj1.toMap(), m1); + } + + @Test + public void testIntegerProcessing2() throws JsonParseException { + + // org.json.simple parser toString() keeps ".0" in double + + String json = "{\"k1\":1,\"k2\":2.5,\"k3\":{\"kk1\":3,\"kk2\":4.0}}"; + + Map m2 = new HashMap(); + m2.put("kk1", 3); + m2.put("kk2", 4.0); + + Map m1 = new HashMap(); + m1.put("k1", 1); + m1.put("k2", 2.5); + m1.put("k3", m2); + + OptimizelyJSON oj1 = new OptimizelyJSON(m1, getParser()); + assertEquals(oj1.toString(), json); + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyjson/TestTypes.java b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/TestTypes.java new file mode 100644 index 000000000..4fa8260fd --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/TestTypes.java @@ -0,0 +1,61 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelyjson; + +/** + * Test types for parsing JSON strings to Java objects (OptimizelyJSON) + */ +public class TestTypes { + + public static class MD1 { + public String k1; + public boolean k2; + public MD2 k3; + } + + public static class MD2 { + public double kk1; + public MD3 kk2; + } + + public static class MD3 { + public boolean kkk1; + public double kkk2; + public String kkk3; + public Object[] kkk4; + } + + // Invalid parse type + + public static class NotMatchingType { + public String x99; + } + + // Test types for integer parsing tests + + public static class MDN1 { + public int k1; + public double k2; + public MDN2 k3; + } + + public static class MDN2 { + public int kk1; + public double kk2; + } + +} From 4c6a834808fabd3eddfb51645eeb648a340c1f92 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Thu, 7 May 2020 13:02:04 -0700 Subject: [PATCH 004/147] feat: add getFeatureVariableJSON and getAllFeatureVariables apis (#375) --- .../java/com/optimizely/ab/Optimizely.java | 157 ++++++- .../ab/notification/DecisionNotification.java | 41 +- .../ab/notification/NotificationCenter.java | 3 +- .../com/optimizely/ab/OptimizelyTest.java | 385 +++++++++++++++++- .../ab/config/ValidProjectConfigV4.java | 27 +- .../config/parser/GsonConfigParserTest.java | 6 +- .../parser/JacksonConfigParserTest.java | 4 +- .../config/parser/JsonConfigParserTest.java | 4 +- .../parser/JsonSimpleConfigParserTest.java | 6 +- .../config/valid-project-config-v4.json | 10 +- 10 files changed, 593 insertions(+), 50 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 39690a82e..e7f614f2a 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -25,12 +25,16 @@ import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.error.NoOpErrorHandler; import com.optimizely.ab.event.*; -import com.optimizely.ab.event.internal.*; +import com.optimizely.ab.event.internal.ClientEngineInfo; +import com.optimizely.ab.event.internal.EventFactory; +import com.optimizely.ab.event.internal.UserEvent; +import com.optimizely.ab.event.internal.UserEventFactory; import com.optimizely.ab.event.internal.payload.EventBatch; import com.optimizely.ab.notification.*; import com.optimizely.ab.optimizelyconfig.OptimizelyConfig; import com.optimizely.ab.optimizelyconfig.OptimizelyConfigManager; import com.optimizely.ab.optimizelyconfig.OptimizelyConfigService; +import com.optimizely.ab.optimizelyjson.OptimizelyJSON; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,11 +42,7 @@ import javax.annotation.Nullable; import javax.annotation.concurrent.ThreadSafe; import java.io.Closeable; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import static com.optimizely.ab.internal.SafetyUtils.tryClose; @@ -601,6 +601,46 @@ public String getFeatureVariableString(@Nonnull String featureKey, FeatureVariable.STRING_TYPE); } + /** + * Get the JSON value of the specified variable in the feature. + * + * @param featureKey The unique key of the feature. + * @param variableKey The unique key of the variable. + * @param userId The ID of the user. + * @return An OptimizelyJSON instance for the JSON variable value. + * Null if the feature or variable could not be found. + */ + @Nullable + public OptimizelyJSON getFeatureVariableJSON(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId) { + return getFeatureVariableJSON(featureKey, variableKey, userId, Collections.emptyMap()); + } + + /** + * Get the JSON value of the specified variable in the feature. + * + * @param featureKey The unique key of the feature. + * @param variableKey The unique key of the variable. + * @param userId The ID of the user. + * @param attributes The user's attributes. + * @return An OptimizelyJSON instance for the JSON variable value. + * Null if the feature or variable could not be found. + */ + @Nullable + public OptimizelyJSON getFeatureVariableJSON(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId, + @Nonnull Map attributes) { + + return getFeatureVariableValueForType( + featureKey, + variableKey, + userId, + attributes, + FeatureVariable.JSON_TYPE); + } + @VisibleForTesting T getFeatureVariableValueForType(@Nonnull String featureKey, @Nonnull String variableKey, @@ -671,6 +711,10 @@ T getFeatureVariableValueForType(@Nonnull String featureKey, } Object convertedValue = convertStringToType(variableValue, variableType); + Object notificationValue = convertedValue; + if (convertedValue instanceof OptimizelyJSON) { + notificationValue = ((OptimizelyJSON) convertedValue).toMap(); + } DecisionNotification decisionNotification = DecisionNotification.newFeatureVariableDecisionNotificationBuilder() .withUserId(userId) @@ -679,7 +723,7 @@ T getFeatureVariableValueForType(@Nonnull String featureKey, .withFeatureEnabled(featureEnabled) .withVariableKey(variableKey) .withVariableType(variableType) - .withVariableValue(convertedValue) + .withVariableValue(notificationValue) .withFeatureDecision(featureDecision) .build(); @@ -714,6 +758,8 @@ Object convertStringToType(String variableValue, String type) { "\" as Integer. " + exception.toString()); } break; + case FeatureVariable.JSON_TYPE: + return new OptimizelyJSON(variableValue); default: return variableValue; } @@ -722,6 +768,103 @@ Object convertStringToType(String variableValue, String type) { return null; } + /** + * Get the values of all variables in the feature. + * + * @param featureKey The unique key of the feature. + * @param userId The ID of the user. + * @return An OptimizelyJSON instance for all variable values. + * Null if the feature could not be found. + */ + @Nullable + public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, + @Nonnull String userId) { + return getAllFeatureVariables(featureKey, userId, Collections.emptyMap()); + } + + /** + * Get the values of all variables in the feature. + * + * @param featureKey The unique key of the feature. + * @param userId The ID of the user. + * @param attributes The user's attributes. + * @return An OptimizelyJSON instance for all variable values. + * Null if the feature could not be found. + */ + @Nullable + public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, + @Nonnull String userId, + @Nonnull Map attributes) { + + if (featureKey == null) { + logger.warn("The featureKey parameter must be nonnull."); + return null; + } else if (userId == null) { + logger.warn("The userId parameter must be nonnull."); + return null; + } + + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing getAllFeatureVariableValues call. type"); + return null; + } + + FeatureFlag featureFlag = projectConfig.getFeatureKeyMapping().get(featureKey); + if (featureFlag == null) { + logger.info("No feature flag was found for key \"{}\".", featureKey); + return null; + } + + Map copiedAttributes = copyAttributes(attributes); + FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, copiedAttributes, projectConfig); + Boolean featureEnabled = false; + Variation variation = featureDecision.variation; + + if (variation != null) { + if (!variation.getFeatureEnabled()) { + logger.info("Feature \"{}\" for variation \"{}\" was not enabled. " + + "The default value is being returned.", featureKey, featureDecision.variation.getKey()); + } + + featureEnabled = variation.getFeatureEnabled(); + } else { + logger.info("User \"{}\" was not bucketed into any variation for feature flag \"{}\". " + + "The default values are being returned.", userId, featureKey); + } + + Map valuesMap = new HashMap(); + for (FeatureVariable variable : featureFlag.getVariables()) { + String value = variable.getDefaultValue(); + if (featureEnabled) { + FeatureVariableUsageInstance instance = variation.getVariableIdToFeatureVariableUsageInstanceMap().get(variable.getId()); + if (instance != null) { + value = instance.getValue(); + } + } + + Object convertedValue = convertStringToType(value, variable.getType()); + if (convertedValue instanceof OptimizelyJSON) { + convertedValue = ((OptimizelyJSON) convertedValue).toMap(); + } + + valuesMap.put(variable.getKey(), convertedValue); + } + + DecisionNotification decisionNotification = DecisionNotification.newFeatureVariableDecisionNotificationBuilder() + .withUserId(userId) + .withAttributes(copiedAttributes) + .withFeatureKey(featureKey) + .withFeatureEnabled(featureEnabled) + .withVariableValues(valuesMap) + .withFeatureDecision(featureDecision) + .build(); + + notificationCenter.send(decisionNotification); + + return new OptimizelyJSON(valuesMap); + } + /** * Get the list of features that are enabled for the user. * TODO revisit this method. Calling this as-is can dramatically increase visitor impression counts. diff --git a/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java b/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java index 741af7bd2..0a7ea6e3c 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java @@ -19,7 +19,6 @@ import com.optimizely.ab.OptimizelyRuntimeException; import com.optimizely.ab.bucketing.FeatureDecision; -import com.optimizely.ab.config.FeatureVariable; import com.optimizely.ab.config.Variation; import javax.annotation.Nonnull; @@ -239,13 +238,16 @@ public static class FeatureVariableDecisionNotificationBuilder { public static final String VARIABLE_KEY = "variableKey"; public static final String VARIABLE_TYPE = "variableType"; public static final String VARIABLE_VALUE = "variableValue"; + public static final String VARIABLE_VALUES = "variableValues"; + private NotificationCenter.DecisionNotificationType notificationType; private String featureKey; private Boolean featureEnabled; private FeatureDecision featureDecision; private String variableKey; private String variableType; private Object variableValue; + private Object variableValues; private String userId; private Map attributes; private Map decisionInfo; @@ -293,6 +295,11 @@ public FeatureVariableDecisionNotificationBuilder withVariableValue(Object varia return this; } + public FeatureVariableDecisionNotificationBuilder withVariableValues(Object variableValues) { + this.variableValues = variableValues; + return this; + } + public DecisionNotification build() { if (featureKey == null) { throw new OptimizelyRuntimeException("featureKey not set"); @@ -302,20 +309,30 @@ public DecisionNotification build() { throw new OptimizelyRuntimeException("featureEnabled not set"); } - if (variableKey == null) { - throw new OptimizelyRuntimeException("variableKey not set"); - } - - if (variableType == null) { - throw new OptimizelyRuntimeException("variableType not set"); - } decisionInfo = new HashMap<>(); decisionInfo.put(FEATURE_KEY, featureKey); decisionInfo.put(FEATURE_ENABLED, featureEnabled); - decisionInfo.put(VARIABLE_KEY, variableKey); - decisionInfo.put(VARIABLE_TYPE, variableType.toString()); - decisionInfo.put(VARIABLE_VALUE, variableValue); + + if (variableValues != null) { + notificationType = NotificationCenter.DecisionNotificationType.ALL_FEATURE_VARIABLES; + decisionInfo.put(VARIABLE_VALUES, variableValues); + } else { + notificationType = NotificationCenter.DecisionNotificationType.FEATURE_VARIABLE; + + if (variableKey == null) { + throw new OptimizelyRuntimeException("variableKey not set"); + } + + if (variableType == null) { + throw new OptimizelyRuntimeException("variableType not set"); + } + + decisionInfo.put(VARIABLE_KEY, variableKey); + decisionInfo.put(VARIABLE_TYPE, variableType.toString()); + decisionInfo.put(VARIABLE_VALUE, variableValue); + } + SourceInfo sourceInfo = new RolloutSourceInfo(); if (featureDecision != null && FeatureDecision.DecisionSource.FEATURE_TEST.equals(featureDecision.decisionSource)) { @@ -327,7 +344,7 @@ public DecisionNotification build() { decisionInfo.put(SOURCE_INFO, sourceInfo.get()); return new DecisionNotification( - NotificationCenter.DecisionNotificationType.FEATURE_VARIABLE.toString(), + notificationType.toString(), userId, attributes, decisionInfo); diff --git a/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java b/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java index 8b5fc933e..4b0b3e406 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java @@ -54,7 +54,8 @@ public enum DecisionNotificationType { AB_TEST("ab-test"), FEATURE("feature"), FEATURE_TEST("feature-test"), - FEATURE_VARIABLE("feature-variable"); + FEATURE_VARIABLE("feature-variable"), + ALL_FEATURE_VARIABLES("all-feature-variables"); private final String key; diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java index c8c5ddb53..f4c67df82 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -17,6 +17,9 @@ import ch.qos.logback.classic.Level; import com.google.common.collect.ImmutableMap; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; import com.optimizely.ab.bucketing.Bucketer; import com.optimizely.ab.bucketing.DecisionService; import com.optimizely.ab.bucketing.FeatureDecision; @@ -28,9 +31,10 @@ import com.optimizely.ab.event.EventProcessor; import com.optimizely.ab.event.LogEvent; import com.optimizely.ab.event.internal.UserEventFactory; -import com.optimizely.ab.internal.LogbackVerifier; import com.optimizely.ab.internal.ControlAttribute; +import com.optimizely.ab.internal.LogbackVerifier; import com.optimizely.ab.notification.*; +import com.optimizely.ab.optimizelyjson.OptimizelyJSON; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Before; import org.junit.Rule; @@ -43,14 +47,8 @@ import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; -import java.io.Closeable; import java.io.IOException; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.function.Function; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.*; @@ -63,10 +61,7 @@ import static junit.framework.TestCase.assertTrue; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; +import static org.junit.Assert.*; import static org.junit.Assume.assumeTrue; import static org.mockito.Matchers.*; import static org.mockito.Mockito.*; @@ -2215,6 +2210,197 @@ public void getFeatureVariableDoubleWithListenerUserInExperimentFeatureOn() thro assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); } + /** + * Verify that the {@link Optimizely#getFeatureVariableJSON(String, String, String, Map)} + * notification listener of getFeatureVariableString is called when feature is in experiment and feature is true + */ + @Test + public void getFeatureVariableJSONWithListenerUserInExperimentFeatureOn() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + isListenerCalled = false; + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String validVariableKey = VARIABLE_JSON_PATCHED_TYPE_KEY; + String expectedString = "{\"k1\":\"s1\",\"k2\":103.5,\"k3\":false,\"k4\":{\"kk1\":\"ss1\",\"kk2\":true}}"; + + Optimizely optimizely = optimizelyBuilder.build(); + + final Map testUserAttributes = new HashMap<>(); + testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + + final Map testSourceInfo = new HashMap<>(); + testSourceInfo.put(EXPERIMENT_KEY, "multivariate_experiment"); + testSourceInfo.put(VARIATION_KEY, "Fred"); + + final Map testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(FEATURE_KEY, validFeatureKey); + testDecisionInfoMap.put(FEATURE_ENABLED, true); + testDecisionInfoMap.put(VARIABLE_KEY, validVariableKey); + testDecisionInfoMap.put(VARIABLE_TYPE, FeatureVariable.JSON_TYPE); + testDecisionInfoMap.put(VARIABLE_VALUE, parseJsonString(expectedString)); + testDecisionInfoMap.put(SOURCE, FeatureDecision.DecisionSource.FEATURE_TEST.toString()); + testDecisionInfoMap.put(SOURCE_INFO, testSourceInfo); + + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.FEATURE_VARIABLE.toString(), + testUserId, + testUserAttributes, + testDecisionInfoMap)); + + OptimizelyJSON json = optimizely.getFeatureVariableJSON( + validFeatureKey, + validVariableKey, + testUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)); + + assertTrue(compareJsonStrings(json.toString(), expectedString)); + + // Verify that listener being called + assertTrue(isListenerCalled); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + } + + /** + * Verify that the {@link Optimizely#getFeatureVariableJSON(String, String, String, Map)} + * notification listener of getFeatureVariableString is called when feature is in experiment and feature enabled is false + * than default value will get returned and passing null attribute will send empty map instead of null + */ + @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") + @Test + public void getFeatureVariableJSONWithListenerUserInExperimentFeatureOff() { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + isListenerCalled = false; + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String validVariableKey = VARIABLE_JSON_PATCHED_TYPE_KEY; + String expectedString = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}"; + + String userID = "Gred"; + + Optimizely optimizely = optimizelyBuilder.build(); + + final Map testUserAttributes = new HashMap<>(); + + final Map testSourceInfo = new HashMap<>(); + testSourceInfo.put(EXPERIMENT_KEY, "multivariate_experiment"); + testSourceInfo.put(VARIATION_KEY, "Gred"); + + final Map testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(FEATURE_KEY, validFeatureKey); + testDecisionInfoMap.put(FEATURE_ENABLED, false); + testDecisionInfoMap.put(VARIABLE_KEY, validVariableKey); + testDecisionInfoMap.put(VARIABLE_TYPE, FeatureVariable.JSON_TYPE); + testDecisionInfoMap.put(VARIABLE_VALUE, parseJsonString(expectedString)); + testDecisionInfoMap.put(SOURCE, FeatureDecision.DecisionSource.FEATURE_TEST.toString()); + testDecisionInfoMap.put(SOURCE_INFO, testSourceInfo); + + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.FEATURE_VARIABLE.toString(), + userID, + testUserAttributes, + testDecisionInfoMap)); + + OptimizelyJSON json = optimizely.getFeatureVariableJSON( + validFeatureKey, + validVariableKey, + userID, + null); + + assertTrue(compareJsonStrings(json.toString(), expectedString)); + + // Verify that listener being called + assertTrue(isListenerCalled); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + } + + /** + * Verify that the {@link Optimizely#getAllFeatureVariables(String, String, Map)} + * notification listener of getAllFeatureVariables is called when feature is in experiment and feature is true + */ + @Test + public void getAllFeatureVariablesWithListenerUserInExperimentFeatureOn() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + isListenerCalled = false; + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String expectedString = "{\"first_letter\":\"F\",\"rest_of_name\":\"red\",\"json_patched\":{\"k1\":\"s1\",\"k2\":103.5,\"k3\":false,\"k4\":{\"kk1\":\"ss1\",\"kk2\":true}}}"; + + Optimizely optimizely = optimizelyBuilder.build(); + + final Map testUserAttributes = new HashMap<>(); + testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + + final Map testSourceInfo = new HashMap<>(); + testSourceInfo.put(EXPERIMENT_KEY, "multivariate_experiment"); + testSourceInfo.put(VARIATION_KEY, "Fred"); + + final Map testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(FEATURE_KEY, validFeatureKey); + testDecisionInfoMap.put(FEATURE_ENABLED, true); + testDecisionInfoMap.put(VARIABLE_VALUES, parseJsonString(expectedString)); + testDecisionInfoMap.put(SOURCE, FeatureDecision.DecisionSource.FEATURE_TEST.toString()); + testDecisionInfoMap.put(SOURCE_INFO, testSourceInfo); + + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.ALL_FEATURE_VARIABLES.toString(), + testUserId, + testUserAttributes, + testDecisionInfoMap)); + + String jsonString = optimizely.getAllFeatureVariables( + validFeatureKey, + testUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)).toString(); + assertTrue(compareJsonStrings(jsonString, expectedString)); + + // Verify that listener being called + assertTrue(isListenerCalled); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + } + + /** + * Verify that the {@link Optimizely#getAllFeatureVariables(String, String, Map)} + * notification listener of getAllFeatureVariables is called when feature is in experiment and feature enabled is false + * than default value will get returned and passing null attribute will send empty map instead of null + */ + @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") + @Test + public void getAllFeatureVariablesWithListenerUserInExperimentFeatureOff() { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + isListenerCalled = false; + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String expectedString = "{\"first_letter\":\"H\",\"rest_of_name\":\"arry\",\"json_patched\":{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}}"; + String userID = "Gred"; + + Optimizely optimizely = optimizelyBuilder.build(); + + final Map testUserAttributes = new HashMap<>(); + + final Map testSourceInfo = new HashMap<>(); + testSourceInfo.put(EXPERIMENT_KEY, "multivariate_experiment"); + testSourceInfo.put(VARIATION_KEY, "Gred"); + + final Map testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(FEATURE_KEY, validFeatureKey); + testDecisionInfoMap.put(FEATURE_ENABLED, false); + testDecisionInfoMap.put(VARIABLE_VALUES, parseJsonString(expectedString)); + testDecisionInfoMap.put(SOURCE, FeatureDecision.DecisionSource.FEATURE_TEST.toString()); + testDecisionInfoMap.put(SOURCE_INFO, testSourceInfo); + + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.ALL_FEATURE_VARIABLES.toString(), + userID, + testUserAttributes, + testDecisionInfoMap)); + + String jsonString = optimizely.getAllFeatureVariables( + validFeatureKey, + userID, + null).toString(); + assertTrue(compareJsonStrings(jsonString, expectedString)); + + // Verify that listener being called + assertTrue(isListenerCalled); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + } + /** * Verify that the {@link Optimizely#activate(String, String, Map)} call * correctly builds an endpoint url and request params @@ -4046,6 +4232,169 @@ public void getFeatureVariableIntegerCatchesExceptionFromParsing() throws Except assertNull(spyOptimizely.getFeatureVariableInteger(featureKey, variableKey, genericUserId)); } + /** + * Verify that the {@link Optimizely#getFeatureVariableJSON(String, String, String, Map)} + * is called when feature is in experiment and feature enabled is true + * returns variable value + */ + @Test + public void getFeatureVariableJSONUserInExperimentFeatureOn() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String validVariableKey = VARIABLE_JSON_PATCHED_TYPE_KEY; + String expectedString = "{\"k1\":\"s1\",\"k2\":103.5,\"k3\":false,\"k4\":{\"kk1\":\"ss1\",\"kk2\":true}}"; + + Optimizely optimizely = optimizelyBuilder.build(); + + OptimizelyJSON json = optimizely.getFeatureVariableJSON( + validFeatureKey, + validVariableKey, + testUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)); + + assertTrue(compareJsonStrings(json.toString(), expectedString)); + + assertEquals(json.toMap().get("k1"), "s1"); + assertEquals(json.toMap().get("k2"), 103.5); + assertEquals(json.toMap().get("k3"), false); + assertEquals(((Map)json.toMap().get("k4")).get("kk1"), "ss1"); + assertEquals(((Map)json.toMap().get("k4")).get("kk2"), true); + + assertEquals(json.getValue("k1", String.class), "s1"); + assertEquals(json.getValue("k4.kk2", Boolean.class), true); + } + + /** + * Verify that the {@link Optimizely#getFeatureVariableJSON(String, String, String, Map)} + * is called when feature is in experiment and feature enabled is false + * than default value will gets returned + */ + @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") + @Test + public void getFeatureVariableJSONUserInExperimentFeatureOff() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String validVariableKey = VARIABLE_JSON_PATCHED_TYPE_KEY; + String expectedString = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}"; + String userID = "Gred"; + + Optimizely optimizely = optimizelyBuilder.build(); + + OptimizelyJSON json = optimizely.getFeatureVariableJSON( + validFeatureKey, + validVariableKey, + userID, + null); + + assertTrue(compareJsonStrings(json.toString(), expectedString)); + + assertEquals(json.toMap().get("k1"), "v1"); + assertEquals(json.toMap().get("k2"), 3.5); + assertEquals(json.toMap().get("k3"), true); + assertEquals(((Map)json.toMap().get("k4")).get("kk1"), "vv1"); + assertEquals(((Map)json.toMap().get("k4")).get("kk2"), false); + + assertEquals(json.getValue("k1", String.class), "v1"); + assertEquals(json.getValue("k4.kk2", Boolean.class), false); + } + + /** + * Verify that the {@link Optimizely#getAllFeatureVariables(String,String, Map)} + * is called when feature is in experiment and feature enabled is true + * returns variable value + */ + @Test + public void getAllFeatureVariablesUserInExperimentFeatureOn() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String expectedString = "{\"first_letter\":\"F\",\"rest_of_name\":\"red\",\"json_patched\":{\"k1\":\"s1\",\"k2\":103.5,\"k3\":false,\"k4\":{\"kk1\":\"ss1\",\"kk2\":true}}}"; + + Optimizely optimizely = optimizelyBuilder.build(); + + OptimizelyJSON json = optimizely.getAllFeatureVariables( + validFeatureKey, + testUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)); + + assertTrue(compareJsonStrings(json.toString(), expectedString)); + + assertEquals(json.toMap().get("first_letter"), "F"); + assertEquals(json.toMap().get("rest_of_name"), "red"); + Map subMap = (Map)json.toMap().get("json_patched"); + assertEquals(subMap.get("k1"), "s1"); + assertEquals(subMap.get("k2"), 103.5); + assertEquals(subMap.get("k3"), false); + assertEquals(((Map)subMap.get("k4")).get("kk1"), "ss1"); + assertEquals(((Map)subMap.get("k4")).get("kk2"), true); + + assertEquals(json.getValue("first_letter", String.class), "F"); + assertEquals(json.getValue("json_patched.k1", String.class), "s1"); + assertEquals(json.getValue("json_patched.k4.kk2", Boolean.class), true); + } + + /** + * Verify that the {@link Optimizely#getAllFeatureVariables(String, String, Map)} + * is called when feature is in experiment and feature enabled is false + * than default value will gets returned + */ + @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") + @Test + public void getAllFeatureVariablesUserInExperimentFeatureOff() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String expectedString = "{\"first_letter\":\"H\",\"rest_of_name\":\"arry\",\"json_patched\":{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}}"; + String userID = "Gred"; + + Optimizely optimizely = optimizelyBuilder.build(); + + OptimizelyJSON json = optimizely.getAllFeatureVariables( + validFeatureKey, + userID, + null); + + assertTrue(compareJsonStrings(json.toString(), expectedString)); + + assertEquals(json.toMap().get("first_letter"), "H"); + assertEquals(json.toMap().get("rest_of_name"), "arry"); + Map subMap = (Map)json.toMap().get("json_patched"); + assertEquals(subMap.get("k1"), "v1"); + assertEquals(subMap.get("k2"), 3.5); + assertEquals(subMap.get("k3"), true); + assertEquals(((Map)subMap.get("k4")).get("kk1"), "vv1"); + assertEquals(((Map)subMap.get("k4")).get("kk2"), false); + + assertEquals(json.getValue("first_letter", String.class), "H"); + assertEquals(json.getValue("json_patched.k1", String.class), "v1"); + assertEquals(json.getValue("json_patched.k4.kk2", Boolean.class), false); + } + + /** + * Verify {@link Optimizely#getAllFeatureVariables(String,String, Map)} with invalid parameters + */ + @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") + @Test + public void getAllFeatureVariablesWithInvalidParameters() throws Exception { + Optimizely optimizely = optimizelyBuilder.build(); + + OptimizelyJSON value; + value = optimizely.getAllFeatureVariables(null, testUserId); + assertNull(value); + + value = optimizely.getAllFeatureVariables(FEATURE_MULTI_VARIATE_FEATURE_KEY, null); + assertNull(value); + + value = optimizely.getAllFeatureVariables("invalid-feature-flag", testUserId); + assertNull(value); + + Optimizely optimizelyInvalid = Optimizely.builder(invalidProjectConfigV5(), mockEventHandler).build(); + value = optimizelyInvalid.getAllFeatureVariables(FEATURE_MULTI_VARIATE_FEATURE_KEY, testUserId); + assertNull(value); + } + /** * Verify that {@link Optimizely#getVariation(String, String)} returns a variation when given an experiment * with no audiences and no user attributes and verify that listener is getting called. @@ -4161,6 +4510,18 @@ private EventType createUnknownEventType() { return new EventType("8765", "unknown_event_type", experimentIds); } + private boolean compareJsonStrings(String str1, String str2) { + JsonParser parser = new JsonParser(); + + JsonElement j1 = parser.parse(str1); + JsonElement j2 = parser.parse(str2); + return j1.equals(j2); + } + + private Map parseJsonString(String str) { + return new Gson().fromJson(str, Map.class); + } + /* Invalid Experiment */ @Test @SuppressFBWarnings("NP") diff --git a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java index 1c71667f8..f50a2780e 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java +++ b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java @@ -407,8 +407,8 @@ public class ValidProjectConfigV4 { null ); private static final String VARIABLE_JSON_PATCHED_TYPE_ID = "4111661000"; - private static final String VARIABLE_JSON_PATCHED_TYPE_KEY = "json_patched"; - private static final String VARIABLE_JSON_PATCHED_TYPE_DEFAULT_VALUE = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}"; + public static final String VARIABLE_JSON_PATCHED_TYPE_KEY = "json_patched"; + public static final String VARIABLE_JSON_PATCHED_TYPE_DEFAULT_VALUE = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}"; private static final FeatureVariable VARIABLE_JSON_PATCHED_TYPE_VARIABLE = new FeatureVariable( VARIABLE_JSON_PATCHED_TYPE_ID, VARIABLE_JSON_PATCHED_TYPE_KEY, @@ -417,9 +417,12 @@ public class ValidProjectConfigV4 { FeatureVariable.STRING_TYPE, FeatureVariable.JSON_TYPE ); + + private static final String FEATURE_MULTI_VARIATE_FUTURE_FEATURE_ID = "3263342227"; + public static final String FEATURE_MULTI_VARIATE_FUTURE_FEATURE_KEY = "multi_variate_future_feature"; private static final String VARIABLE_JSON_NATIVE_TYPE_ID = "4111661001"; - private static final String VARIABLE_JSON_NATIVE_TYPE_KEY = "json_native"; - private static final String VARIABLE_JSON_NATIVE_TYPE_DEFAULT_VALUE = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}"; + public static final String VARIABLE_JSON_NATIVE_TYPE_KEY = "json_native"; + public static final String VARIABLE_JSON_NATIVE_TYPE_DEFAULT_VALUE = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}"; private static final FeatureVariable VARIABLE_JSON_NATIVE_TYPE_VARIABLE = new FeatureVariable( VARIABLE_JSON_NATIVE_TYPE_ID, VARIABLE_JSON_NATIVE_TYPE_KEY, @@ -429,8 +432,8 @@ public class ValidProjectConfigV4 { null ); private static final String VARIABLE_FUTURE_TYPE_ID = "4111661002"; - private static final String VARIABLE_FUTURE_TYPE_KEY = "future_variable"; - private static final String VARIABLE_FUTURE_TYPE_DEFAULT_VALUE = "future_value"; + public static final String VARIABLE_FUTURE_TYPE_KEY = "future_variable"; + public static final String VARIABLE_FUTURE_TYPE_DEFAULT_VALUE = "future_value"; private static final FeatureVariable VARIABLE_FUTURE_TYPE_VARIABLE = new FeatureVariable( VARIABLE_FUTURE_TYPE_ID, VARIABLE_FUTURE_TYPE_KEY, @@ -439,6 +442,7 @@ public class ValidProjectConfigV4 { "future_type", null ); + private static final String FEATURE_MUTEX_GROUP_FEATURE_ID = "3263342226"; public static final String FEATURE_MUTEX_GROUP_FEATURE_KEY = "mutex_group_feature"; private static final String VARIABLE_CORRELATING_VARIATION_NAME_ID = "2059187672"; @@ -1308,7 +1312,15 @@ public class ValidProjectConfigV4 { DatafileProjectConfigTestUtils.createListOfObjects( VARIABLE_FIRST_LETTER_VARIABLE, VARIABLE_REST_OF_NAME_VARIABLE, - VARIABLE_JSON_PATCHED_TYPE_VARIABLE, + VARIABLE_JSON_PATCHED_TYPE_VARIABLE + ) + ); + public static final FeatureFlag FEATURE_FLAG_MULTI_VARIATE_FUTURE_FEATURE = new FeatureFlag( + FEATURE_MULTI_VARIATE_FUTURE_FEATURE_ID, + FEATURE_MULTI_VARIATE_FUTURE_FEATURE_KEY, + ROLLOUT_2_ID, + Collections.singletonList(EXPERIMENT_MULTIVARIATE_EXPERIMENT_ID), + DatafileProjectConfigTestUtils.createListOfObjects( VARIABLE_JSON_NATIVE_TYPE_VARIABLE, VARIABLE_FUTURE_TYPE_VARIABLE ) @@ -1401,6 +1413,7 @@ public static ProjectConfig generateValidProjectConfigV4() { featureFlags.add(FEATURE_FLAG_SINGLE_VARIABLE_BOOLEAN); featureFlags.add(FEATURE_FLAG_SINGLE_VARIABLE_STRING); featureFlags.add(FEATURE_FLAG_MULTI_VARIATE_FEATURE); + featureFlags.add(FEATURE_FLAG_MULTI_VARIATE_FUTURE_FEATURE); featureFlags.add(FEATURE_FLAG_MUTEX_GROUP_FEATURE); List groups = new ArrayList(); diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java index 69ffd81a2..7cf4610ca 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java @@ -46,6 +46,8 @@ import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.verifyProjectConfig; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.*; /** @@ -116,7 +118,7 @@ public void parseFeatureVariablesWithJsonNative() throws Exception { // native "json" type - FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_future_feature"); FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_native"); assertEquals(variable.getType(), "json"); @@ -129,7 +131,7 @@ public void parseFeatureVariablesWithFutureType() throws Exception { // unknown type - FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_future_feature"); FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("future_variable"); assertEquals(variable.getType(), "future_type"); diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java index 896cadcb6..e4e009e10 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java @@ -111,7 +111,7 @@ public void parseFeatureVariablesWithJsonNative() throws Exception { // native "json" type - FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_future_feature"); FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_native"); assertEquals(variable.getType(), "json"); @@ -124,7 +124,7 @@ public void parseFeatureVariablesWithFutureType() throws Exception { // unknown type - FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_future_feature"); FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("future_variable"); assertEquals(variable.getType(), "future_type"); diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java index b155c1954..d78f57e75 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java @@ -113,7 +113,7 @@ public void parseFeatureVariablesWithJsonNative() throws Exception { // native "json" type - FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_future_feature"); FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_native"); assertEquals(variable.getType(), "json"); @@ -126,7 +126,7 @@ public void parseFeatureVariablesWithFutureType() throws Exception { // unknown type - FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_future_feature"); FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("future_variable"); assertEquals(variable.getType(), "future_type"); diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java index 86150c51a..6c5dca1eb 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java @@ -19,7 +19,6 @@ import com.optimizely.ab.config.FeatureFlag; import com.optimizely.ab.config.FeatureVariable; import com.optimizely.ab.config.ProjectConfig; - import com.optimizely.ab.config.audience.AudienceIdCondition; import com.optimizely.ab.config.audience.Condition; import com.optimizely.ab.config.audience.UserAttribute; @@ -114,8 +113,7 @@ public void parseFeatureVariablesWithJsonNative() throws Exception { // native "json" type - FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); - FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_native"); + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_future_feature"); FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_native"); assertEquals(variable.getType(), "json"); } @@ -127,7 +125,7 @@ public void parseFeatureVariablesWithFutureType() throws Exception { // unknown type - FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_future_feature"); FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("future_variable"); assertEquals(variable.getType(), "future_type"); diff --git a/core-api/src/test/resources/config/valid-project-config-v4.json b/core-api/src/test/resources/config/valid-project-config-v4.json index 455a5e8bf..42e965967 100644 --- a/core-api/src/test/resources/config/valid-project-config-v4.json +++ b/core-api/src/test/resources/config/valid-project-config-v4.json @@ -716,7 +716,15 @@ "type": "string", "subType": "json", "defaultValue": "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}" - }, + } + ] + }, + { + "id": "3263342227", + "key": "multi_variate_future_feature", + "rolloutId": "813411034", + "experimentIds": ["3262035800"], + "variables": [ { "id": "4111661001", "key": "json_native", From 935dd9f5b7b52903470c09c0545951e56027e11e Mon Sep 17 00:00:00 2001 From: zashraf1985 <35262377+zashraf1985@users.noreply.github.com> Date: Wed, 27 May 2020 18:49:12 -0700 Subject: [PATCH 005/147] chore: Removed triggers for Benchmarking tests (#376) --- .travis.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index a48429109..73018dc09 100644 --- a/.travis.yml +++ b/.travis.yml @@ -37,7 +37,6 @@ after_failure: stages: - 'Lint markdown files' - 'Integration tests' - - 'Benchmarking tests' - 'Test' jobs: @@ -63,8 +62,7 @@ jobs: - mdspell -a -n -r --en-us '**/*.md' after_success: skip - - &integrationtest - stage: 'Integration tests' + - stage: 'Integration tests' addons: srcclr: true merge_mode: replace @@ -78,6 +76,3 @@ jobs: script: - $HOME/travisci-tools/trigger-script-with-status-update.sh after_success: travis_terminate 0 - - <<: *integrationtest - stage: 'Benchmarking tests' - env: SDK=java FULLSTACK_TEST_REPO=Benchmarking SDK_BRANCH=$TRAVIS_PULL_REQUEST_BRANCH From 3eba4fcf84a1887dce80e3963360a6beadee1847 Mon Sep 17 00:00:00 2001 From: mnoman09 Date: Fri, 5 Jun 2020 02:41:04 +0500 Subject: [PATCH 006/147] fix(log-level) Adjusting log level on audience evaluation logs (#377) --- .../ab/config/audience/AudienceIdCondition.java | 4 ++-- .../ab/internal/ExperimentUtilsTest.java | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java index 7bc9f665f..b5fb5fc96 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018-2019, Optimizely and contributors + * Copyright 2018-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -76,7 +76,7 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { } logger.debug("Starting to evaluate audience {} with conditions: \"{}\"", audience.getName(), audience.getConditions()); Boolean result = audience.getConditions().evaluate(config, attributes); - logger.info("Audience {} evaluated to {}", audience.getName(), result); + logger.debug("Audience {} evaluated to {}", audience.getName(), result); return result; } diff --git a/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java b/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java index 96b1389e4..216801388 100644 --- a/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java +++ b/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java @@ -142,7 +142,7 @@ public void isUserInExperimentEvaluatesEvenIfExperimentHasAudiencesButUserHasNoA "Evaluating audiences for experiment \"etag1\": \"[100]\""); logbackVerifier.expectMessage(Level.DEBUG, "Starting to evaluate audience not_firefox_users with conditions: \"[and, [or, [not, [or, {name='browser_type', type='custom_attribute', match='null', value='firefox'}]]]]\""); - logbackVerifier.expectMessage(Level.INFO, + logbackVerifier.expectMessage(Level.DEBUG, "Audience not_firefox_users evaluated to true"); logbackVerifier.expectMessage(Level.INFO, "Audiences for experiment etag1 collectively evaluated to true"); @@ -163,7 +163,7 @@ public void isUserInExperimentEvaluatesEvenIfExperimentHasAudiencesButUserSendNu "Evaluating audiences for experiment \"etag1\": \"[100]\""); logbackVerifier.expectMessage(Level.DEBUG, "Starting to evaluate audience not_firefox_users with conditions: \"[and, [or, [not, [or, {name='browser_type', type='custom_attribute', match='null', value='firefox'}]]]]\""); - logbackVerifier.expectMessage(Level.INFO, + logbackVerifier.expectMessage(Level.DEBUG, "Audience not_firefox_users evaluated to true"); logbackVerifier.expectMessage(Level.INFO, "Audiences for experiment etag1 collectively evaluated to true"); @@ -184,7 +184,7 @@ public void isUserInExperimentEvaluatesExperimentHasTypedAudiences() { "Evaluating audiences for experiment \"typed_audience_experiment\": \"[or, 3468206643, 3468206644, 3468206646, 3468206645]\""); logbackVerifier.expectMessage(Level.DEBUG, "Starting to evaluate audience BOOL with conditions: \"[and, [or, [or, {name='booleanKey', type='custom_attribute', match='exact', value=true}]]]\""); - logbackVerifier.expectMessage(Level.INFO, + logbackVerifier.expectMessage(Level.DEBUG, "Audience BOOL evaluated to true"); logbackVerifier.expectMessage(Level.INFO, "Audiences for experiment typed_audience_experiment collectively evaluated to true"); @@ -205,7 +205,7 @@ public void isUserInExperimentReturnsTrueIfUserSatisfiesAnAudience() { "Evaluating audiences for experiment \"etag1\": \"[100]\""); logbackVerifier.expectMessage(Level.DEBUG, "Starting to evaluate audience not_firefox_users with conditions: \"[and, [or, [not, [or, {name='browser_type', type='custom_attribute', match='null', value='firefox'}]]]]\""); - logbackVerifier.expectMessage(Level.INFO, + logbackVerifier.expectMessage(Level.DEBUG, "Audience not_firefox_users evaluated to true"); logbackVerifier.expectMessage(Level.INFO, "Audiences for experiment etag1 collectively evaluated to true"); @@ -226,7 +226,7 @@ public void isUserInExperimentReturnsTrueIfUserDoesNotSatisfyAnyAudiences() { "Evaluating audiences for experiment \"etag1\": \"[100]\""); logbackVerifier.expectMessage(Level.DEBUG, "Starting to evaluate audience not_firefox_users with conditions: \"[and, [or, [not, [or, {name='browser_type', type='custom_attribute', match='null', value='firefox'}]]]]\""); - logbackVerifier.expectMessage(Level.INFO, + logbackVerifier.expectMessage(Level.DEBUG, "Audience not_firefox_users evaluated to false"); logbackVerifier.expectMessage(Level.INFO, "Audiences for experiment etag1 collectively evaluated to false"); @@ -262,7 +262,7 @@ public void isUserInExperimentHandlesNullValueAttributesWithNull() { "Starting to evaluate audience audience_with_missing_value with conditions: \"[and, [or, [or, {name='nationality', type='custom_attribute', match='null', value='English'}, {name='nationality', type='custom_attribute', match='null', value=null}]]]\""); logbackVerifier.expectMessage(Level.WARN, "Audience condition \"{name='nationality', type='custom_attribute', match='null', value=null}\" has an unexpected value type. You may need to upgrade to a newer release of the Optimizely SDK"); - logbackVerifier.expectMessage(Level.INFO, + logbackVerifier.expectMessage(Level.DEBUG, "Audience audience_with_missing_value evaluated to null"); logbackVerifier.expectMessage(Level.INFO, "Audiences for experiment experiment_with_malformed_audience collectively evaluated to null"); @@ -283,7 +283,7 @@ public void isUserInExperimentHandlesNullConditionValue() { "Starting to evaluate audience audience_with_missing_value with conditions: \"[and, [or, [or, {name='nationality', type='custom_attribute', match='null', value='English'}, {name='nationality', type='custom_attribute', match='null', value=null}]]]\""); logbackVerifier.expectMessage(Level.WARN, "Audience condition \"{name='nationality', type='custom_attribute', match='null', value=null}\" has an unexpected value type. You may need to upgrade to a newer release of the Optimizely SDK"); - logbackVerifier.expectMessage(Level.INFO, + logbackVerifier.expectMessage(Level.DEBUG, "Audience audience_with_missing_value evaluated to null"); logbackVerifier.expectMessage(Level.INFO, "Audiences for experiment experiment_with_malformed_audience collectively evaluated to null"); From 0d3776ab54fb68c20ee9f926314009949bee5cd8 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Wed, 24 Jun 2020 14:03:58 -0700 Subject: [PATCH 007/147] feat: add support for authenticated datafile access (#378) --- .../com/optimizely/ab/OptimizelyFactory.java | 16 ++++++ .../ab/config/HttpProjectConfigManager.java | 57 ++++++++++++++++--- .../optimizely/ab/OptimizelyFactoryTest.java | 7 +++ .../config/HttpProjectConfigManagerTest.java | 56 +++++++++++++++++- 4 files changed, 125 insertions(+), 11 deletions(-) diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java index 93c08638b..989e578f0 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java @@ -179,6 +179,18 @@ public static Optimizely newDefaultInstance(String sdkKey) { * @param fallback Fallback datafile string used by the ProjectConfigManager to be immediately available. */ public static Optimizely newDefaultInstance(String sdkKey, String fallback) { + String datafileAccessToken = PropertyUtils.get(HttpProjectConfigManager.CONFIG_DATAFILE_AUTH_TOKEN); + return newDefaultInstance(sdkKey, fallback, datafileAccessToken); + } + + /** + * Returns a new Optimizely instance with authenticated datafile support. + * + * @param sdkKey SDK key used to build the ProjectConfigManager. + * @param fallback Fallback datafile string used by the ProjectConfigManager to be immediately available. + * @param datafileAccessToken Token for authenticated datafile access. + */ + public static Optimizely newDefaultInstance(String sdkKey, String fallback, String datafileAccessToken) { NotificationCenter notificationCenter = new NotificationCenter(); HttpProjectConfigManager.Builder builder = HttpProjectConfigManager.builder() @@ -186,6 +198,10 @@ public static Optimizely newDefaultInstance(String sdkKey, String fallback) { .withNotificationCenter(notificationCenter) .withSdkKey(sdkKey); + if (datafileAccessToken != null) { + builder.withDatafileAccessToken(datafileAccessToken); + } + return newDefaultInstance(builder.build(), notificationCenter); } diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java index f2f8a61be..afe7db451 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java @@ -18,6 +18,7 @@ import com.optimizely.ab.HttpClientUtils; import com.optimizely.ab.OptimizelyHttpClient; +import com.optimizely.ab.annotations.VisibleForTesting; import com.optimizely.ab.config.parser.ConfigParseException; import com.optimizely.ab.internal.PropertyUtils; import com.optimizely.ab.notification.NotificationCenter; @@ -44,6 +45,7 @@ public class HttpProjectConfigManager extends PollingProjectConfigManager { public static final String CONFIG_BLOCKING_DURATION = "http.project.config.manager.blocking.duration"; public static final String CONFIG_BLOCKING_UNIT = "http.project.config.manager.blocking.unit"; public static final String CONFIG_SDK_KEY = "http.project.config.manager.sdk.key"; + public static final String CONFIG_DATAFILE_AUTH_TOKEN = "http.project.config.manager.datafile.auth.token"; public static final long DEFAULT_POLLING_DURATION = 5; public static final TimeUnit DEFAULT_POLLING_UNIT = TimeUnit.MINUTES; @@ -54,12 +56,21 @@ public class HttpProjectConfigManager extends PollingProjectConfigManager { private final OptimizelyHttpClient httpClient; private final URI uri; + private final String datafileAccessToken; private String datafileLastModified; - private HttpProjectConfigManager(long period, TimeUnit timeUnit, OptimizelyHttpClient httpClient, String url, long blockingTimeoutPeriod, TimeUnit blockingTimeoutUnit, NotificationCenter notificationCenter) { + private HttpProjectConfigManager(long period, + TimeUnit timeUnit, + OptimizelyHttpClient httpClient, + String url, + String datafileAccessToken, + long blockingTimeoutPeriod, + TimeUnit blockingTimeoutUnit, + NotificationCenter notificationCenter) { super(period, timeUnit, blockingTimeoutPeriod, blockingTimeoutUnit, notificationCenter); this.httpClient = httpClient; this.uri = URI.create(url); + this.datafileAccessToken = datafileAccessToken; } public URI getUri() { @@ -104,11 +115,7 @@ static ProjectConfig parseProjectConfig(String datafile) throws ConfigParseExcep @Override protected ProjectConfig poll() { - HttpGet httpGet = new HttpGet(uri); - - if (datafileLastModified != null) { - httpGet.setHeader(HttpHeaders.IF_MODIFIED_SINCE, datafileLastModified); - } + HttpGet httpGet = createHttpRequest(); logger.debug("Fetching datafile from: {}", httpGet.getURI()); try { @@ -125,6 +132,21 @@ protected ProjectConfig poll() { return null; } + @VisibleForTesting + HttpGet createHttpRequest() { + HttpGet httpGet = new HttpGet(uri); + + if (datafileAccessToken != null) { + httpGet.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + datafileAccessToken); + } + + if (datafileLastModified != null) { + httpGet.setHeader(HttpHeaders.IF_MODIFIED_SINCE, datafileLastModified); + } + + return httpGet; + } + public static Builder builder() { return new Builder(); } @@ -132,7 +154,9 @@ public static Builder builder() { public static class Builder { private String datafile; private String url; + private String datafileAccessToken = null; private String format = "https://cdn.optimizely.com/datafiles/%s.json"; + private String authFormat = "https://config.optimizely.com/datafiles/auth/%s.json"; private OptimizelyHttpClient httpClient; private NotificationCenter notificationCenter; @@ -153,6 +177,11 @@ public Builder withSdkKey(String sdkKey) { return this; } + public Builder withDatafileAccessToken(String token) { + this.datafileAccessToken = token; + return this; + } + public Builder withUrl(String url) { this.url = url; return this; @@ -261,14 +290,26 @@ public HttpProjectConfigManager build(boolean defer) { throw new NullPointerException("sdkKey cannot be null"); } - url = String.format(format, sdkKey); + if (datafileAccessToken == null) { + url = String.format(format, sdkKey); + } else { + url = String.format(authFormat, sdkKey); + } } if (notificationCenter == null) { notificationCenter = new NotificationCenter(); } - HttpProjectConfigManager httpProjectManager = new HttpProjectConfigManager(period, timeUnit, httpClient, url, blockingTimeoutPeriod, blockingTimeoutUnit, notificationCenter); + HttpProjectConfigManager httpProjectManager = new HttpProjectConfigManager( + period, + timeUnit, + httpClient, + url, + datafileAccessToken, + blockingTimeoutPeriod, + blockingTimeoutUnit, + notificationCenter); if (datafile != null) { try { diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java index 9dfe0658e..8b595a019 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java @@ -191,6 +191,13 @@ public void newDefaultInstanceWithFallback() throws Exception { assertTrue(optimizely.isValid()); } + @Test + public void newDefaultInstanceWithDatafileAccessToken() throws Exception { + String datafileString = Resources.toString(Resources.getResource("valid-project-config-v4.json"), Charsets.UTF_8); + optimizely = OptimizelyFactory.newDefaultInstance("sdk-key", datafileString, "auth-token"); + assertTrue(optimizely.isValid()); + } + @Test public void newDefaultInstanceWithProjectConfig() throws Exception { optimizely = OptimizelyFactory.newDefaultInstance(() -> null); diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java index aa5555de4..43c0d3c31 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java @@ -43,9 +43,7 @@ import static java.util.concurrent.TimeUnit.SECONDS; import static org.junit.Assert.*; import static org.mockito.Matchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.reset; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; @RunWith(MockitoJUnitRunner.class) public class HttpProjectConfigManagerTest { @@ -125,6 +123,58 @@ public void testHttpGetByCustomUrl() throws Exception { assertEquals(new URI(expected), actual); } + @Test + public void testHttpGetBySdkKeyForAuthDatafile() throws Exception { + projectConfigManager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("sdk-key") + .withDatafileAccessToken("auth-token") + .build(); + + URI actual = projectConfigManager.getUri(); + assertEquals(new URI("https://config.optimizely.com/datafiles/auth/sdk-key.json"), actual); + } + + @Test + public void testHttpGetByCustomUrlForAuthDatafile() throws Exception { + String expected = "https://custom.optimizely.com/custom-location.json"; + + projectConfigManager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withUrl(expected) + .withSdkKey("sdk-key") + .withDatafileAccessToken("auth-token") + .build(); + + URI actual = projectConfigManager.getUri(); + assertEquals(new URI(expected), actual); + } + + @Test + public void testCreateHttpRequest() throws Exception { + projectConfigManager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("sdk-key") + .build(); + + HttpGet request = projectConfigManager.createHttpRequest(); + assertEquals(request.getURI().toString(), "https://cdn.optimizely.com/datafiles/sdk-key.json"); + assertEquals(request.getHeaders("Authorization").length, 0); + } + + @Test + public void testCreateHttpRequestForAuthDatafile() throws Exception { + projectConfigManager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("sdk-key") + .withDatafileAccessToken("auth-token") + .build(); + + HttpGet request = projectConfigManager.createHttpRequest(); + assertEquals(request.getURI().toString(), "https://config.optimizely.com/datafiles/auth/sdk-key.json"); + assertEquals(request.getHeaders("Authorization")[0].getValue(), "Bearer auth-token"); + } + @Test public void testPoll() throws Exception { projectConfigManager = builder() From 8b5bdf9317f497189c32b1ec0fa10bedb19276b4 Mon Sep 17 00:00:00 2001 From: Mike Davis Date: Thu, 2 Jul 2020 14:08:20 -0700 Subject: [PATCH 008/147] (chore): prepare for release 3.5.0-beta (#381) --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59b1c4398..1c4935ad6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Optimizely Java X SDK Changelog +## [3.5.0-beta] +July 2nd, 2020 + +### New Features +- Add support for JSON feature variables ([#372](https://github.com/optimizely/java-sdk/pull/372), [#371](https://github.com/optimizely/java-sdk/pull/371), [#375](https://github.com/optimizely/java-sdk/pull/375)) +- Add support for authenticated datafile access ([#378](https://github.com/optimizely/java-sdk/pull/378)) + +### Bug Fixes: +- Adjust log level on audience evaluation logs ([#377](https://github.com/optimizely/java-sdk/pull/377)) + ## [3.4.3] April 28th, 2020 From 7d0547ec3219bbdaf2d8b384896a81deafc20f07 Mon Sep 17 00:00:00 2001 From: Mike Davis Date: Tue, 7 Jul 2020 10:48:15 -0700 Subject: [PATCH 009/147] (chore): prepare for release 3.5.0 (#383) --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c4935ad6..93d14c7da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Optimizely Java X SDK Changelog +## [3.5.0] +July 7th, 2020 + +### New Features +- Add support for JSON feature variables ([#372](https://github.com/optimizely/java-sdk/pull/372), [#371](https://github.com/optimizely/java-sdk/pull/371), [#375](https://github.com/optimizely/java-sdk/pull/375)) +- Add support for authenticated datafile access ([#378](https://github.com/optimizely/java-sdk/pull/378)) + +### Bug Fixes: +- Adjust log level on audience evaluation logs ([#377](https://github.com/optimizely/java-sdk/pull/377)) + ## [3.5.0-beta] July 2nd, 2020 From 24205e962185b094fc6ccdcd6da227f26772279c Mon Sep 17 00:00:00 2001 From: mnoman09 Date: Fri, 10 Jul 2020 03:19:58 +0500 Subject: [PATCH 010/147] feat: Added SetDatafileAccessToken method in OptimizelyFactory (#384) --- .travis.yml | 12 ------------ core-httpclient-impl/README.md | 2 ++ .../com/optimizely/ab/OptimizelyFactory.java | 16 +++++++++++++++- .../optimizely/ab/OptimizelyFactoryTest.java | 18 +++++++++++++++++- 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/.travis.yml b/.travis.yml index 73018dc09..ff3e76108 100644 --- a/.travis.yml +++ b/.travis.yml @@ -50,18 +50,6 @@ jobs: notifications: email: false - - stage: 'Lint markdown files' - os: linux - language: generic - before_install: skip - install: - - npm i -g markdown-spellcheck - before_script: - - wget --quiet https://raw.githubusercontent.com/optimizely/mdspell-config/master/.spelling - script: - - mdspell -a -n -r --en-us '**/*.md' - after_success: skip - - stage: 'Integration tests' addons: srcclr: true diff --git a/core-httpclient-impl/README.md b/core-httpclient-impl/README.md index 0546a8f68..8e70b2ddb 100644 --- a/core-httpclient-impl/README.md +++ b/core-httpclient-impl/README.md @@ -171,6 +171,7 @@ The following builder methods can be used to custom configure the `HttpProjectCo |`withPollingInterval(Long, TimeUnit)`|5 minutes|Fixed delay between fetches for the datafile.| |`withBlockingTimeout(Long, TimeUnit)`|10 seconds|Maximum time to wait for initial bootstrapping.| |`withSdkKey(String)`|null|Optimizely project SDK key. Required unless source URL is overridden.| +|`withDatafileAccessToken(String)`|null|Token for authenticated datafile access.| ### Advanced configuration The following properties can be set to override the default configuration. @@ -182,6 +183,7 @@ The following properties can be set to override the default configuration. |**http.project.config.manager.blocking.duration**|10|Maximum time to wait for initial bootstrapping| |**http.project.config.manager.blocking.unit**|SECONDS|Time unit corresponding to blocking duration| |**http.project.config.manager.sdk.key**|null|Optimizely project SDK key| +|**http.project.config.manager.datafile.auth.token**|null|Token for authenticated datafile access| ## Update Config Notifications A notification signal will be triggered whenever a _new_ datafile is fetched. To subscribe to these notifications you can diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java index 989e578f0..bd51a4cc0 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019, Optimizely + * Copyright 2019-2020, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -42,6 +42,7 @@ *
  • {@link OptimizelyFactory#setBlockingTimeout}
  • *
  • {@link OptimizelyFactory#setPollingInterval}
  • *
  • {@link OptimizelyFactory#setSdkKey}
  • + *
  • {@link OptimizelyFactory#setDatafileAccessToken}
  • * * */ @@ -144,6 +145,19 @@ public static void setSdkKey(String sdkKey) { PropertyUtils.set(HttpProjectConfigManager.CONFIG_SDK_KEY, sdkKey); } + /** + * Convenience method for setting the Datafile Access Token on System properties. + * {@link HttpProjectConfigManager.Builder#withDatafileAccessToken(String)} + */ + public static void setDatafileAccessToken(String datafileAccessToken) { + if (datafileAccessToken == null) { + logger.warn("Datafile Access Token cannot be null. Reverting to default configuration."); + return; + } + + PropertyUtils.set(HttpProjectConfigManager.CONFIG_DATAFILE_AUTH_TOKEN, datafileAccessToken); + } + /** * Returns a new Optimizely instance based on preset configuration. * EventHandler - {@link AsyncEventHandler} diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java index 8b595a019..c860ee98b 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019, Optimizely + * Copyright 2019-2020, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -170,6 +170,22 @@ public void setInvalidSdkKey() { assertEquals(expected, PropertyUtils.get(HttpProjectConfigManager.CONFIG_SDK_KEY)); } + @Test + public void setDatafileAccessToken() { + String expected = "datafile-access-token"; + OptimizelyFactory.setDatafileAccessToken(expected); + + assertEquals(expected, PropertyUtils.get(HttpProjectConfigManager.CONFIG_DATAFILE_AUTH_TOKEN)); + } + + @Test + public void setInvalidDatafileAccessToken() { + String expected = "datafile-access-token"; + OptimizelyFactory.setDatafileAccessToken(expected); + OptimizelyFactory.setDatafileAccessToken(null); + assertEquals(expected, PropertyUtils.get(HttpProjectConfigManager.CONFIG_DATAFILE_AUTH_TOKEN)); + } + @Test public void newDefaultInstanceInvalid() { optimizely = OptimizelyFactory.newDefaultInstance(); From a81fce66a0ba53339925312038842ca508dd28fa Mon Sep 17 00:00:00 2001 From: mnoman09 Date: Sat, 11 Jul 2020 03:21:44 +0500 Subject: [PATCH 011/147] Updated gradle version to gradle 6 (#379) * updated gradle version to gradle 6 * updated header Co-authored-by: Ali Abbas Rizvi --- build.gradle | 19 ++++++++++++------- .../ab/OptimizelyHttpClientTest.java | 4 ++-- gradle/wrapper/gradle-wrapper.properties | 2 +- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/build.gradle b/build.gradle index 5f6d659fa..881e9bea7 100644 --- a/build.gradle +++ b/build.gradle @@ -17,6 +17,7 @@ plugins { id 'me.champeau.gradle.jmh' version '0.4.5' id 'nebula.optional-base' version '3.2.0' id 'com.github.hierynomus.license' version '0.15.0' + id 'com.github.spotbugs' version "4.3.0" } allprojects { @@ -44,7 +45,7 @@ allprojects { subprojects { apply plugin: 'com.jfrog.bintray' - apply plugin: 'findbugs' + apply plugin: 'com.github.spotbugs' apply plugin: 'jacoco' apply plugin: 'java' apply plugin: 'maven-publish' @@ -52,11 +53,15 @@ subprojects { apply plugin: 'nebula.optional-base' apply plugin: 'com.github.hierynomus.license' + sourceCompatibility = 1.8 targetCompatibility = 1.8 repositories { jcenter() + maven { + url 'https://plugins.gradle.org/m2/' + } } task sourcesJar(type: Jar, dependsOn: classes) { @@ -74,15 +79,15 @@ subprojects { archives javadocJar } - tasks.withType(FindBugs) { + spotbugsMain { reports { xml.enabled = false html.enabled = true } } - findbugs { - findbugsJmh.enabled = false + spotbugs { + spotbugsJmh.enabled = false } test { @@ -207,9 +212,9 @@ task jacocoRootReport(type: JacocoReport, group: 'Coverage reports') { description = 'Generates an aggregate report from all subprojects' dependsOn publishedProjects.test, jacocoMerge - additionalSourceDirs = files(publishedProjects.sourceSets.main.allSource.srcDirs) - sourceDirectories = files(publishedProjects.sourceSets.main.allSource.srcDirs) - classDirectories = files(publishedProjects.sourceSets.main.output) + getAdditionalSourceDirs().setFrom(files(publishedProjects.sourceSets.main.allSource.srcDirs)) + getSourceDirectories().setFrom(files(publishedProjects.sourceSets.main.allSource.srcDirs)) + getAdditionalClassDirs().setFrom(files(publishedProjects.sourceSets.main.output)) executionData jacocoMerge.destinationFile reports { diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyHttpClientTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyHttpClientTest.java index a67e61501..8b92e6fc1 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyHttpClientTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyHttpClientTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019, Optimizely + * Copyright 2019-2020, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -73,7 +73,7 @@ public void testProxySettings() throws IOException { @Test public void testExecute() throws IOException { HttpUriRequest httpUriRequest = RequestBuilder.get().build(); - ResponseHandler responseHandler = response -> null; + ResponseHandler responseHandler = response -> false; CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); when(mockHttpClient.execute(httpUriRequest, responseHandler)).thenReturn(true); diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d75ee9126..a9a50f830 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.5.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip From 04b4e95131b8b2210beb8f8957fc82054a261237 Mon Sep 17 00:00:00 2001 From: Owais Akbani Date: Tue, 14 Jul 2020 22:46:40 +0500 Subject: [PATCH 012/147] ci: add source clear as a separate stage (#385) --- .travis.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index ff3e76108..5621c07c1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -38,6 +38,7 @@ stages: - 'Lint markdown files' - 'Integration tests' - 'Test' + - 'Source Clear' jobs: include: @@ -51,8 +52,6 @@ jobs: email: false - stage: 'Integration tests' - addons: - srcclr: true merge_mode: replace env: SDK=java SDK_BRANCH=$TRAVIS_PULL_REQUEST_BRANCH cache: false @@ -64,3 +63,13 @@ jobs: script: - $HOME/travisci-tools/trigger-script-with-status-update.sh after_success: travis_terminate 0 + + - stage: 'Source Clear' + if: type = cron + addons: + srcclr: true + before_install: skip + install: skip + before_script: skip + script: skip + after_success: skip From 16f144d970fe12009e066062f2d838e50c520632 Mon Sep 17 00:00:00 2001 From: Owais Akbani Date: Thu, 16 Jul 2020 05:03:23 +0500 Subject: [PATCH 013/147] ci: hook fps on travis (#382) --- .travis.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 5621c07c1..1573c09bc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -37,6 +37,7 @@ after_failure: stages: - 'Lint markdown files' - 'Integration tests' + - 'Full stack production tests' - 'Test' - 'Source Clear' @@ -51,7 +52,8 @@ jobs: notifications: email: false - - stage: 'Integration tests' + - &integrationtest + stage: 'Integration tests' merge_mode: replace env: SDK=java SDK_BRANCH=$TRAVIS_PULL_REQUEST_BRANCH cache: false @@ -64,6 +66,13 @@ jobs: - $HOME/travisci-tools/trigger-script-with-status-update.sh after_success: travis_terminate 0 + - <<: *integrationtest + stage: 'Full stack production tests' + env: + SDK=java + SDK_BRANCH=$TRAVIS_PULL_REQUEST_BRANCH + FULLSTACK_TEST_REPO=ProdTesting + - stage: 'Source Clear' if: type = cron addons: From acf8cb9d31733c44b35be1f21a937cf948be3cc5 Mon Sep 17 00:00:00 2001 From: mnoman09 Date: Mon, 27 Jul 2020 23:29:52 +0500 Subject: [PATCH 014/147] refact(audience-logs): Added and refactored audience and feature variable evaluation logs (#380) --- .../java/com/optimizely/ab/Optimizely.java | 18 +-- .../ab/bucketing/DecisionService.java | 18 +-- .../config/audience/AudienceIdCondition.java | 4 +- .../ab/config/audience/UserAttribute.java | 6 +- .../match/UnexpectedValueTypeException.java | 4 +- .../match/UnknownMatchTypeException.java | 4 +- .../ab/internal/ExperimentUtils.java | 40 ++++--- .../ab/internal/LoggingConstants.java | 24 ++++ .../com/optimizely/ab/OptimizelyTest.java | 13 +- .../ab/bucketing/DecisionServiceTest.java | 18 ++- .../AudienceConditionEvaluationTest.java | 6 +- .../ab/internal/ExperimentUtilsTest.java | 112 +++++++++--------- 12 files changed, 163 insertions(+), 104 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/internal/LoggingConstants.java diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index e7f614f2a..e32a39cb5 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -693,13 +693,15 @@ T getFeatureVariableValueForType(@Nonnull String featureKey, featureDecision.variation.getVariableIdToFeatureVariableUsageInstanceMap().get(variable.getId()); if (featureVariableUsageInstance != null) { variableValue = featureVariableUsageInstance.getValue(); + logger.info("Got variable value \"{}\" for variable \"{}\" of feature flag \"{}\".", variableValue, variableKey, featureKey); } else { variableValue = variable.getDefaultValue(); + logger.info("Value is not defined for variable \"{}\". Returning default value \"{}\".", variableKey, variableValue); } } else { - logger.info("Feature \"{}\" for variation \"{}\" was not enabled. " + - "The default value is being returned.", - featureKey, featureDecision.variation.getKey(), variableValue, variableKey + logger.info("Feature \"{}\" is not enabled for user \"{}\". " + + "Returning the default variable value \"{}\".", + featureKey, userId, variableValue ); } featureEnabled = featureDecision.variation.getFeatureEnabled(); @@ -822,12 +824,12 @@ public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, Variation variation = featureDecision.variation; if (variation != null) { - if (!variation.getFeatureEnabled()) { - logger.info("Feature \"{}\" for variation \"{}\" was not enabled. " + - "The default value is being returned.", featureKey, featureDecision.variation.getKey()); - } - featureEnabled = variation.getFeatureEnabled(); + if (featureEnabled) { + logger.info("Feature \"{}\" is enabled for user \"{}\".", featureKey, userId); + } else { + logger.info("Feature \"{}\" is not enabled for user \"{}\".", featureKey, userId); + } } else { logger.info("User \"{}\" was not bucketed into any variation for feature flag \"{}\". " + "The default values are being returned.", userId, featureKey); diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java index c1115f4a4..13091472d 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2017-2019, Optimizely, Inc. and contributors * + * Copyright 2017-2020, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -17,7 +17,6 @@ import com.optimizely.ab.OptimizelyRuntimeException; import com.optimizely.ab.config.*; -import com.optimizely.ab.config.audience.Audience; import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.internal.ExperimentUtils; import com.optimizely.ab.internal.ControlAttribute; @@ -32,6 +31,9 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; +import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.EXPERIMENT; +import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.RULE; + /** * Optimizely's decision service that determines which variation of an experiment the user will be allocated to. * @@ -133,7 +135,7 @@ public Variation getVariation(@Nonnull Experiment experiment, userProfile = new UserProfile(userId, new HashMap()); } - if (ExperimentUtils.isUserInExperiment(projectConfig, experiment, filteredAttributes)) { + if (ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, experiment, filteredAttributes, EXPERIMENT, experiment.getKey())) { String bucketingId = getBucketingId(userId, filteredAttributes); variation = bucketer.bucket(experiment, bucketingId, projectConfig); @@ -221,8 +223,7 @@ FeatureDecision getVariationForFeatureInRollout(@Nonnull FeatureFlag featureFlag Variation variation; for (int i = 0; i < rolloutRulesLength - 1; i++) { Experiment rolloutRule = rollout.getExperiments().get(i); - Audience audience = projectConfig.getAudienceIdMapping().get(rolloutRule.getAudienceIds().get(0)); - if (ExperimentUtils.isUserInExperiment(projectConfig, rolloutRule, filteredAttributes)) { + if (ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, rolloutRule, filteredAttributes, RULE, Integer.toString(i + 1))) { variation = bucketer.bucket(rolloutRule, bucketingId, projectConfig); if (variation == null) { break; @@ -230,16 +231,16 @@ FeatureDecision getVariationForFeatureInRollout(@Nonnull FeatureFlag featureFlag return new FeatureDecision(rolloutRule, variation, FeatureDecision.DecisionSource.ROLLOUT); } else { - logger.debug("User \"{}\" did not meet the conditions to be in rollout rule for audience \"{}\".", - userId, audience.getName()); + logger.debug("User \"{}\" does not meet conditions for targeting rule \"{}\".", userId, i + 1); } } // get last rule which is the fall back rule Experiment finalRule = rollout.getExperiments().get(rolloutRulesLength - 1); - if (ExperimentUtils.isUserInExperiment(projectConfig, finalRule, filteredAttributes)) { + if (ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, finalRule, filteredAttributes, RULE, "Everyone Else")) { variation = bucketer.bucket(finalRule, bucketingId, projectConfig); if (variation != null) { + logger.debug("User \"{}\" meets conditions for targeting rule \"Everyone Else\".", userId); return new FeatureDecision(finalRule, variation, FeatureDecision.DecisionSource.ROLLOUT); } @@ -394,7 +395,6 @@ public boolean setForcedVariation(@Nonnull Experiment experiment, @Nullable String variationKey) { - Variation variation = null; // keep in mind that you can pass in a variationKey that is null if you want to diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java index b5fb5fc96..57a4e5bec 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java @@ -74,9 +74,9 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { logger.error("Audience {} could not be found.", audienceId); return null; } - logger.debug("Starting to evaluate audience {} with conditions: \"{}\"", audience.getName(), audience.getConditions()); + logger.debug("Starting to evaluate audience \"{}\" with conditions: {}.", audience.getId(), audience.getConditions()); Boolean result = audience.getConditions().evaluate(config, attributes); - logger.debug("Audience {} evaluated to {}", audience.getName(), result); + logger.debug("Audience \"{}\" evaluated to {}.", audience.getId(), result); return result; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java b/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java index eca5b08be..be1e11169 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2019, Optimizely and contributors + * Copyright 2016-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -82,7 +82,7 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { Object userAttributeValue = attributes.get(name); if (!"custom_attribute".equals(type)) { - logger.warn("Audience condition \"{}\" has an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK", this); + logger.warn("Audience condition \"{}\" uses an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK.", this); return null; // unknown type } // check user attribute value is equal @@ -103,7 +103,7 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { userAttributeValue.getClass().getCanonicalName(), name); } else { - logger.warn( + logger.debug( "Audience condition \"{}\" evaluated to UNKNOWN because a null value was passed for user attribute \"{}\"", this, name); diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnexpectedValueTypeException.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnexpectedValueTypeException.java index 58a34f81f..cf513bc7d 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnexpectedValueTypeException.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnexpectedValueTypeException.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019, Optimizely and contributors + * Copyright 2019-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,7 +18,7 @@ package com.optimizely.ab.config.audience.match; public class UnexpectedValueTypeException extends Exception { - private static String message = "has an unexpected value type. You may need to upgrade to a newer release of the Optimizely SDK"; + private static String message = "has an unsupported condition value. You may need to upgrade to a newer release of the Optimizely SDK."; public UnexpectedValueTypeException() { super(message); diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnknownMatchTypeException.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnknownMatchTypeException.java index b08a66fb6..0c5a972a7 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnknownMatchTypeException.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnknownMatchTypeException.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019, Optimizely and contributors + * Copyright 2019-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,7 +18,7 @@ package com.optimizely.ab.config.audience.match; public class UnknownMatchTypeException extends Exception { - private static String message = "uses an unknown match type. You may need to upgrade to a newer release of the Optimizely SDK"; + private static String message = "uses an unknown match type. You may need to upgrade to a newer release of the Optimizely SDK."; public UnknownMatchTypeException() { super(message); diff --git a/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java b/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java index 53662e9a6..f5109b624 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java @@ -1,6 +1,6 @@ /** * - * Copyright 2017-2019, Optimizely and contributors + * Copyright 2017-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -56,19 +56,24 @@ public static boolean isExperimentActive(@Nonnull Experiment experiment) { /** * Determines whether a user satisfies audience conditions for the experiment. * - * @param projectConfig the current projectConfig - * @param experiment the experiment we are evaluating audiences for - * @param attributes the attributes of the user + * @param projectConfig the current projectConfig + * @param experiment the experiment we are evaluating audiences for + * @param attributes the attributes of the user + * @param loggingEntityType It can be either experiment or rule. + * @param loggingKey In case of loggingEntityType is experiment it will be experiment key or else it will be rule number. * @return whether the user meets the criteria for the experiment */ - public static boolean isUserInExperiment(@Nonnull ProjectConfig projectConfig, - @Nonnull Experiment experiment, - @Nonnull Map attributes) { + public static boolean doesUserMeetAudienceConditions(@Nonnull ProjectConfig projectConfig, + @Nonnull Experiment experiment, + @Nonnull Map attributes, + @Nonnull String loggingEntityType, + @Nonnull String loggingKey) { if (experiment.getAudienceConditions() != null) { - Boolean resolveReturn = evaluateAudienceConditions(projectConfig, experiment, attributes); + logger.debug("Evaluating audiences for {} \"{}\": {}.", loggingEntityType, loggingKey, experiment.getAudienceConditions()); + Boolean resolveReturn = evaluateAudienceConditions(projectConfig, experiment, attributes, loggingEntityType, loggingKey); return resolveReturn == null ? false : resolveReturn; } else { - Boolean resolveReturn = evaluateAudience(projectConfig, experiment, attributes); + Boolean resolveReturn = evaluateAudience(projectConfig, experiment, attributes, loggingEntityType, loggingKey); return Boolean.TRUE.equals(resolveReturn); } } @@ -76,12 +81,13 @@ public static boolean isUserInExperiment(@Nonnull ProjectConfig projectConfig, @Nullable public static Boolean evaluateAudience(@Nonnull ProjectConfig projectConfig, @Nonnull Experiment experiment, - @Nonnull Map attributes) { + @Nonnull Map attributes, + @Nonnull String loggingEntityType, + @Nonnull String loggingKey) { List experimentAudienceIds = experiment.getAudienceIds(); // if there are no audiences, ALL users should be part of the experiment if (experimentAudienceIds.isEmpty()) { - logger.debug("There is no Audience associated with experiment {}", experiment.getKey()); return true; } @@ -93,11 +99,11 @@ public static Boolean evaluateAudience(@Nonnull ProjectConfig projectConfig, OrCondition implicitOr = new OrCondition(conditions); - logger.debug("Evaluating audiences for experiment \"{}\": \"{}\"", experiment.getKey(), conditions); + logger.debug("Evaluating audiences for {} \"{}\": {}.", loggingEntityType, loggingKey, conditions); Boolean result = implicitOr.evaluate(projectConfig, attributes); - logger.info("Audiences for experiment {} collectively evaluated to {}", experiment.getKey(), result); + logger.info("Audiences for {} \"{}\" collectively evaluated to {}.", loggingEntityType, loggingKey, result); return result; } @@ -105,14 +111,16 @@ public static Boolean evaluateAudience(@Nonnull ProjectConfig projectConfig, @Nullable public static Boolean evaluateAudienceConditions(@Nonnull ProjectConfig projectConfig, @Nonnull Experiment experiment, - @Nonnull Map attributes) { + @Nonnull Map attributes, + @Nonnull String loggingEntityType, + @Nonnull String loggingKey) { Condition conditions = experiment.getAudienceConditions(); if (conditions == null) return null; - logger.debug("Evaluating audiences for experiment \"{}\": \"{}\"", experiment.getKey(), conditions.toString()); + try { Boolean result = conditions.evaluate(projectConfig, attributes); - logger.info("Audiences for experiment {} collectively evaluated to {}", experiment.getKey(), result); + logger.info("Audiences for {} \"{}\" collectively evaluated to {}.", loggingEntityType, loggingKey, result); return result; } catch (Exception e) { logger.error("Condition invalid", e); diff --git a/core-api/src/main/java/com/optimizely/ab/internal/LoggingConstants.java b/core-api/src/main/java/com/optimizely/ab/internal/LoggingConstants.java new file mode 100644 index 000000000..66387f2e7 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/internal/LoggingConstants.java @@ -0,0 +1,24 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +public class LoggingConstants { + public static class LoggingEntityType { + public static final String EXPERIMENT = "experiment"; + public static final String RULE = "rule"; + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java index f4c67df82..6c32975a8 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -2890,8 +2890,7 @@ public void getFeatureVariableValueReturnsDefaultValueWhenFeatureEnabledIsFalse( logbackVerifier.expectMessage( Level.INFO, - "Feature \"" + validFeatureKey + "\" for variation \"Gred\" was not enabled. " + - "The default value is being returned." + "Feature \"multi_variate_feature\" is not enabled for user \"genericUserId\". Returning the default variable value \"H\"." ); assertEquals(expectedValue, value); @@ -2918,6 +2917,11 @@ public void getFeatureVariableUserInExperimentFeatureOn() throws Exception { testUserId, Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), expectedValue); + + logbackVerifier.expectMessage( + Level.INFO, + "Got variable value \"F\" for variable \"first_letter\" of feature flag \"multi_variate_feature\"." + ); } /** @@ -3061,6 +3065,11 @@ public void getFeatureVariableValueReturnsDefaultValueWhenNoVariationUsageIsPres ); assertEquals(expectedValue, value); + + logbackVerifier.expectMessage( + Level.INFO, + "Value is not defined for variable \"integer_variable\". Returning default value \"7\"." + ); } /** diff --git a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java index 5779be07f..2a3030314 100644 --- a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2017-2019, Optimizely, Inc. and contributors * + * Copyright 2017-2020, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -625,6 +625,14 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsAllAudie Collections.emptyMap(), v4ProjectConfig ); + logbackVerifier.expectMessage(Level.DEBUG, "Evaluating audiences for rule \"1\": [3468206642]."); + logbackVerifier.expectMessage(Level.INFO, "Audiences for rule \"1\" collectively evaluated to null."); + logbackVerifier.expectMessage(Level.DEBUG, "Evaluating audiences for rule \"2\": [3988293898]."); + logbackVerifier.expectMessage(Level.INFO, "Audiences for rule \"2\" collectively evaluated to null."); + logbackVerifier.expectMessage(Level.DEBUG, "Evaluating audiences for rule \"3\": [4194404272]."); + logbackVerifier.expectMessage(Level.INFO, "Audiences for rule \"3\" collectively evaluated to null."); + logbackVerifier.expectMessage(Level.DEBUG, "User \"genericUserId\" meets conditions for targeting rule \"Everyone Else\"."); + assertEquals(expectedVariation, featureDecision.variation); assertEquals(FeatureDecision.DecisionSource.ROLLOUT, featureDecision.decisionSource); @@ -664,6 +672,8 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI assertEquals(expectedVariation, featureDecision.variation); assertEquals(FeatureDecision.DecisionSource.ROLLOUT, featureDecision.decisionSource); + logbackVerifier.expectMessage(Level.DEBUG, "User \"genericUserId\" meets conditions for targeting rule \"Everyone Else\"."); + // verify user is only bucketed once for everyone else rule verify(mockBucketer, times(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); } @@ -743,7 +753,11 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTargetin ); assertEquals(englishCitizenVariation, featureDecision.variation); assertEquals(FeatureDecision.DecisionSource.ROLLOUT, featureDecision.decisionSource); - + logbackVerifier.expectMessage(Level.INFO, "Audiences for rule \"2\" collectively evaluated to null"); + logbackVerifier.expectMessage(Level.DEBUG, "Evaluating audiences for rule \"3\": [4194404272]."); + logbackVerifier.expectMessage(Level.DEBUG, "Starting to evaluate audience \"4194404272\" with conditions: [and, [or, [or, {name='nationality', type='custom_attribute', match='exact', value='English'}]]]."); + logbackVerifier.expectMessage(Level.DEBUG, "Audience \"4194404272\" evaluated to true."); + logbackVerifier.expectMessage(Level.INFO, "Audiences for rule \"3\" collectively evaluated to true"); // verify user is only bucketed once for everyone else rule verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); } diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java index a167845b9..83c5e41df 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2019, Optimizely and contributors + * Copyright 2016-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -136,7 +136,7 @@ public void unexpectedAttributeType() throws Exception { public void unexpectedAttributeTypeNull() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "gt", 20); assertNull(testInstance.evaluate(null, Collections.singletonMap("browser_type", null))); - logbackVerifier.expectMessage(Level.WARN, + logbackVerifier.expectMessage(Level.DEBUG, "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because a null value was passed for user attribute \"browser_type\""); } @@ -171,7 +171,7 @@ public void unknownConditionType() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "blah", "exists", "firefox"); assertNull(testInstance.evaluate(null, testUserAttributes)); logbackVerifier.expectMessage(Level.WARN, - "Audience condition \"{name='browser_type', type='blah', match='exists', value='firefox'}\" has an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK"); + "Audience condition \"{name='browser_type', type='blah', match='exists', value='firefox'}\" uses an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK."); } /** diff --git a/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java b/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java index 216801388..841e5a504 100644 --- a/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java +++ b/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2017, 2019, Optimizely and contributors + * Copyright 2017, 2019-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -41,7 +41,9 @@ import static com.optimizely.ab.config.ValidProjectConfigV4.AUDIENCE_WITH_MISSING_VALUE_VALUE; import static com.optimizely.ab.config.ValidProjectConfigV4.EXPERIMENT_WITH_MALFORMED_AUDIENCE_KEY; import static com.optimizely.ab.internal.ExperimentUtils.isExperimentActive; -import static com.optimizely.ab.internal.ExperimentUtils.isUserInExperiment; +import static com.optimizely.ab.internal.ExperimentUtils.doesUserMeetAudienceConditions; +import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.EXPERIMENT; +import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.RULE; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -121,115 +123,115 @@ public void isExperimentActiveReturnsFalseWhenTheExperimentIsNotStarted() { /** * If the {@link Experiment} does not have any {@link Audience}s, - * then {@link ExperimentUtils#isUserInExperiment(ProjectConfig, Experiment, Map)} should return true; + * then {@link ExperimentUtils#doesUserMeetAudienceConditions(ProjectConfig, Experiment, Map, String, String)} should return true; */ @Test - public void isUserInExperimentReturnsTrueIfExperimentHasNoAudiences() { + public void doesUserMeetAudienceConditionsReturnsTrueIfExperimentHasNoAudiences() { Experiment experiment = noAudienceProjectConfig.getExperiments().get(0); - assertTrue(isUserInExperiment(noAudienceProjectConfig, experiment, Collections.emptyMap())); + assertTrue(doesUserMeetAudienceConditions(noAudienceProjectConfig, experiment, Collections.emptyMap(), RULE, "Everyone Else")); } /** * If the {@link Experiment} contains at least one {@link Audience}, but attributes is empty, - * then {@link ExperimentUtils#isUserInExperiment(ProjectConfig, Experiment, Map)} should return false. + * then {@link ExperimentUtils#doesUserMeetAudienceConditions(ProjectConfig, Experiment, Map, String, String)} should return false. */ @Test - public void isUserInExperimentEvaluatesEvenIfExperimentHasAudiencesButUserHasNoAttributes() { + public void doesUserMeetAudienceConditionsEvaluatesEvenIfExperimentHasAudiencesButUserHasNoAttributes() { Experiment experiment = projectConfig.getExperiments().get(0); - Boolean result = isUserInExperiment(projectConfig, experiment, Collections.emptyMap()); + Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, Collections.emptyMap(), EXPERIMENT, experiment.getKey()); assertTrue(result); logbackVerifier.expectMessage(Level.DEBUG, - "Evaluating audiences for experiment \"etag1\": \"[100]\""); + "Evaluating audiences for experiment \"etag1\": [100]."); logbackVerifier.expectMessage(Level.DEBUG, - "Starting to evaluate audience not_firefox_users with conditions: \"[and, [or, [not, [or, {name='browser_type', type='custom_attribute', match='null', value='firefox'}]]]]\""); + "Starting to evaluate audience \"100\" with conditions: [and, [or, [not, [or, {name='browser_type', type='custom_attribute', match='null', value='firefox'}]]]]."); logbackVerifier.expectMessage(Level.DEBUG, - "Audience not_firefox_users evaluated to true"); + "Audience \"100\" evaluated to true."); logbackVerifier.expectMessage(Level.INFO, - "Audiences for experiment etag1 collectively evaluated to true"); + "Audiences for experiment \"etag1\" collectively evaluated to true."); } /** * If the {@link Experiment} contains at least one {@link Audience}, but attributes is empty, - * then {@link ExperimentUtils#isUserInExperiment(ProjectConfig, Experiment, Map)} should return false. + * then {@link ExperimentUtils#doesUserMeetAudienceConditions(ProjectConfig, Experiment, Map, String, String)} should return false. */ @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @Test - public void isUserInExperimentEvaluatesEvenIfExperimentHasAudiencesButUserSendNullAttributes() throws Exception { + public void doesUserMeetAudienceConditionsEvaluatesEvenIfExperimentHasAudiencesButUserSendNullAttributes() throws Exception { Experiment experiment = projectConfig.getExperiments().get(0); - Boolean result = isUserInExperiment(projectConfig, experiment, null); + Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, null, EXPERIMENT, experiment.getKey()); assertTrue(result); logbackVerifier.expectMessage(Level.DEBUG, - "Evaluating audiences for experiment \"etag1\": \"[100]\""); + "Evaluating audiences for experiment \"etag1\": [100]."); logbackVerifier.expectMessage(Level.DEBUG, - "Starting to evaluate audience not_firefox_users with conditions: \"[and, [or, [not, [or, {name='browser_type', type='custom_attribute', match='null', value='firefox'}]]]]\""); + "Starting to evaluate audience \"100\" with conditions: [and, [or, [not, [or, {name='browser_type', type='custom_attribute', match='null', value='firefox'}]]]]."); logbackVerifier.expectMessage(Level.DEBUG, - "Audience not_firefox_users evaluated to true"); + "Audience \"100\" evaluated to true."); logbackVerifier.expectMessage(Level.INFO, - "Audiences for experiment etag1 collectively evaluated to true"); + "Audiences for experiment \"etag1\" collectively evaluated to true."); } /** * If the {@link Experiment} contains {@link TypedAudience}, and attributes is valid and true, - * then {@link ExperimentUtils#isUserInExperiment(ProjectConfig, Experiment, Map)} should return true. + * then {@link ExperimentUtils#doesUserMeetAudienceConditions(ProjectConfig, Experiment, Map, String, String)} should return true. */ @Test - public void isUserInExperimentEvaluatesExperimentHasTypedAudiences() { + public void doesUserMeetAudienceConditionsEvaluatesExperimentHasTypedAudiences() { Experiment experiment = v4ProjectConfig.getExperiments().get(1); Map attribute = Collections.singletonMap("booleanKey", true); - Boolean result = isUserInExperiment(v4ProjectConfig, experiment, attribute); + Boolean result = doesUserMeetAudienceConditions(v4ProjectConfig, experiment, attribute, EXPERIMENT, experiment.getKey()); assertTrue(result); logbackVerifier.expectMessage(Level.DEBUG, - "Evaluating audiences for experiment \"typed_audience_experiment\": \"[or, 3468206643, 3468206644, 3468206646, 3468206645]\""); + "Evaluating audiences for experiment \"typed_audience_experiment\": [or, 3468206643, 3468206644, 3468206646, 3468206645]."); logbackVerifier.expectMessage(Level.DEBUG, - "Starting to evaluate audience BOOL with conditions: \"[and, [or, [or, {name='booleanKey', type='custom_attribute', match='exact', value=true}]]]\""); + "Starting to evaluate audience \"3468206643\" with conditions: [and, [or, [or, {name='booleanKey', type='custom_attribute', match='exact', value=true}]]]."); logbackVerifier.expectMessage(Level.DEBUG, - "Audience BOOL evaluated to true"); + "Audience \"3468206643\" evaluated to true."); logbackVerifier.expectMessage(Level.INFO, - "Audiences for experiment typed_audience_experiment collectively evaluated to true"); + "Audiences for experiment \"typed_audience_experiment\" collectively evaluated to true."); } /** * If the attributes satisfies at least one {@link Condition} in an {@link Audience} of the {@link Experiment}, - * then {@link ExperimentUtils#isUserInExperiment(ProjectConfig, Experiment, Map)} should return true. + * then {@link ExperimentUtils#doesUserMeetAudienceConditions(ProjectConfig, Experiment, Map, String, String)} should return true. */ @Test - public void isUserInExperimentReturnsTrueIfUserSatisfiesAnAudience() { + public void doesUserMeetAudienceConditionsReturnsTrueIfUserSatisfiesAnAudience() { Experiment experiment = projectConfig.getExperiments().get(0); Map attributes = Collections.singletonMap("browser_type", "chrome"); - Boolean result = isUserInExperiment(projectConfig, experiment, attributes); + Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, attributes, EXPERIMENT, experiment.getKey()); assertTrue(result); logbackVerifier.expectMessage(Level.DEBUG, - "Evaluating audiences for experiment \"etag1\": \"[100]\""); + "Evaluating audiences for experiment \"etag1\": [100]."); logbackVerifier.expectMessage(Level.DEBUG, - "Starting to evaluate audience not_firefox_users with conditions: \"[and, [or, [not, [or, {name='browser_type', type='custom_attribute', match='null', value='firefox'}]]]]\""); + "Starting to evaluate audience \"100\" with conditions: [and, [or, [not, [or, {name='browser_type', type='custom_attribute', match='null', value='firefox'}]]]]."); logbackVerifier.expectMessage(Level.DEBUG, - "Audience not_firefox_users evaluated to true"); + "Audience \"100\" evaluated to true."); logbackVerifier.expectMessage(Level.INFO, - "Audiences for experiment etag1 collectively evaluated to true"); + "Audiences for experiment \"etag1\" collectively evaluated to true."); } /** * If the attributes satisfies no {@link Condition} of any {@link Audience} of the {@link Experiment}, - * then {@link ExperimentUtils#isUserInExperiment(ProjectConfig, Experiment, Map)} should return false. + * then {@link ExperimentUtils#doesUserMeetAudienceConditions(ProjectConfig, Experiment, Map, String, String)} should return false. */ @Test - public void isUserInExperimentReturnsTrueIfUserDoesNotSatisfyAnyAudiences() { + public void doesUserMeetAudienceConditionsReturnsTrueIfUserDoesNotSatisfyAnyAudiences() { Experiment experiment = projectConfig.getExperiments().get(0); Map attributes = Collections.singletonMap("browser_type", "firefox"); - Boolean result = isUserInExperiment(projectConfig, experiment, attributes); + Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, attributes, EXPERIMENT, experiment.getKey()); assertFalse(result); logbackVerifier.expectMessage(Level.DEBUG, - "Evaluating audiences for experiment \"etag1\": \"[100]\""); + "Evaluating audiences for experiment \"etag1\": [100]."); logbackVerifier.expectMessage(Level.DEBUG, - "Starting to evaluate audience not_firefox_users with conditions: \"[and, [or, [not, [or, {name='browser_type', type='custom_attribute', match='null', value='firefox'}]]]]\""); + "Starting to evaluate audience \"100\" with conditions: [and, [or, [not, [or, {name='browser_type', type='custom_attribute', match='null', value='firefox'}]]]]."); logbackVerifier.expectMessage(Level.DEBUG, - "Audience not_firefox_users evaluated to false"); + "Audience \"100\" evaluated to false."); logbackVerifier.expectMessage(Level.INFO, - "Audiences for experiment etag1 collectively evaluated to false"); + "Audiences for experiment \"etag1\" collectively evaluated to false."); } @@ -238,55 +240,55 @@ public void isUserInExperimentReturnsTrueIfUserDoesNotSatisfyAnyAudiences() { * they must explicitly pass in null in order for us to evaluate this. Otherwise we will say they do not match. */ @Test - public void isUserInExperimentHandlesNullValue() { + public void doesUserMeetAudienceConditionsHandlesNullValue() { Experiment experiment = v4ProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_WITH_MALFORMED_AUDIENCE_KEY); Map satisfiesFirstCondition = Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, AUDIENCE_WITH_MISSING_VALUE_VALUE); Map nonMatchingMap = Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, "American"); - assertTrue(isUserInExperiment(v4ProjectConfig, experiment, satisfiesFirstCondition)); - assertFalse(isUserInExperiment(v4ProjectConfig, experiment, nonMatchingMap)); + assertTrue(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, satisfiesFirstCondition, EXPERIMENT, experiment.getKey())); + assertFalse(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, nonMatchingMap, EXPERIMENT, experiment.getKey())); } /** * Audience will evaluate null when condition value is null and attribute value passed is also null */ @Test - public void isUserInExperimentHandlesNullValueAttributesWithNull() { + public void doesUserMeetAudienceConditionsHandlesNullValueAttributesWithNull() { Experiment experiment = v4ProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_WITH_MALFORMED_AUDIENCE_KEY); Map attributesWithNull = Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, null); - assertFalse(isUserInExperiment(v4ProjectConfig, experiment, attributesWithNull)); + assertFalse(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, attributesWithNull, EXPERIMENT, experiment.getKey())); logbackVerifier.expectMessage(Level.DEBUG, - "Starting to evaluate audience audience_with_missing_value with conditions: \"[and, [or, [or, {name='nationality', type='custom_attribute', match='null', value='English'}, {name='nationality', type='custom_attribute', match='null', value=null}]]]\""); + "Starting to evaluate audience \"2196265320\" with conditions: [and, [or, [or, {name='nationality', type='custom_attribute', match='null', value='English'}, {name='nationality', type='custom_attribute', match='null', value=null}]]]."); logbackVerifier.expectMessage(Level.WARN, - "Audience condition \"{name='nationality', type='custom_attribute', match='null', value=null}\" has an unexpected value type. You may need to upgrade to a newer release of the Optimizely SDK"); + "Audience condition \"{name='nationality', type='custom_attribute', match='null', value=null}\" has an unsupported condition value. You may need to upgrade to a newer release of the Optimizely SDK."); logbackVerifier.expectMessage(Level.DEBUG, - "Audience audience_with_missing_value evaluated to null"); + "Audience \"2196265320\" evaluated to null."); logbackVerifier.expectMessage(Level.INFO, - "Audiences for experiment experiment_with_malformed_audience collectively evaluated to null"); + "Audiences for experiment \"experiment_with_malformed_audience\" collectively evaluated to null."); } /** * Audience will evaluate null when condition value is null */ @Test - public void isUserInExperimentHandlesNullConditionValue() { + public void doesUserMeetAudienceConditionsHandlesNullConditionValue() { Experiment experiment = v4ProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_WITH_MALFORMED_AUDIENCE_KEY); Map attributesEmpty = Collections.emptyMap(); // It should explicitly be set to null otherwise we will return false on empty maps - assertFalse(isUserInExperiment(v4ProjectConfig, experiment, attributesEmpty)); + assertFalse(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, attributesEmpty, EXPERIMENT, experiment.getKey())); logbackVerifier.expectMessage(Level.DEBUG, - "Starting to evaluate audience audience_with_missing_value with conditions: \"[and, [or, [or, {name='nationality', type='custom_attribute', match='null', value='English'}, {name='nationality', type='custom_attribute', match='null', value=null}]]]\""); + "Starting to evaluate audience \"2196265320\" with conditions: [and, [or, [or, {name='nationality', type='custom_attribute', match='null', value='English'}, {name='nationality', type='custom_attribute', match='null', value=null}]]]."); logbackVerifier.expectMessage(Level.WARN, - "Audience condition \"{name='nationality', type='custom_attribute', match='null', value=null}\" has an unexpected value type. You may need to upgrade to a newer release of the Optimizely SDK"); + "Audience condition \"{name='nationality', type='custom_attribute', match='null', value=null}\" has an unsupported condition value. You may need to upgrade to a newer release of the Optimizely SDK."); logbackVerifier.expectMessage(Level.DEBUG, - "Audience audience_with_missing_value evaluated to null"); + "Audience \"2196265320\" evaluated to null."); logbackVerifier.expectMessage(Level.INFO, - "Audiences for experiment experiment_with_malformed_audience collectively evaluated to null"); + "Audiences for experiment \"experiment_with_malformed_audience\" collectively evaluated to null."); } /** From 12acebad6c5574fa4e57662306c4d4701ee15fbd Mon Sep 17 00:00:00 2001 From: Owais Akbani Date: Wed, 29 Jul 2020 23:25:50 +0500 Subject: [PATCH 015/147] Update .travis.yml (#387) --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 1573c09bc..f53c61158 100644 --- a/.travis.yml +++ b/.travis.yml @@ -35,11 +35,11 @@ after_failure: # Integration tests need to run first to reset the PR build status to pending stages: + - 'Source Clear' - 'Lint markdown files' - 'Integration tests' - 'Full stack production tests' - 'Test' - - 'Source Clear' jobs: include: From 312a1d55307584242f0ad470e1ddd7aaf08bbc0a Mon Sep 17 00:00:00 2001 From: mnoman09 Date: Wed, 19 Aug 2020 05:06:28 +0500 Subject: [PATCH 016/147] feature: add semantic version types and test (#386) * semantic versioning * Fix * added comments and refact * comments fix * Added tests and refact logic * refact * Refactored entire logic of Semantic version comparision and added new tests for preRelease * Nit fix * Added Additional tests for Semantic Versioning * Allowed numbers to parse * Revert "Allowed numbers to parse" This reverts commit 6af531274c849b55729f44d2cd1708c04b363a43. * Refactored Logic and made it similar to other sdks * SpotBug bug fix * Removed static functions * remove string field and use proprety version Co-authored-by: Tom Zurkan --- .../ab/config/audience/match/GEMatch.java | 41 ++ .../ab/config/audience/match/LEMatch.java | 42 ++ .../ab/config/audience/match/MatchType.java | 37 +- .../audience/match/SemanticVersion.java | 133 +++++ .../match/SemanticVersionEqualsMatch.java | 41 ++ .../match/SemanticVersionGEMatch.java | 41 ++ .../match/SemanticVersionGTMatch.java | 41 ++ .../match/SemanticVersionLEMatch.java | 41 ++ .../match/SemanticVersionLTMatch.java | 41 ++ .../ab/internal/AttributesUtil.java | 25 +- .../AudienceConditionEvaluationTest.java | 535 ++++++++++++++++++ .../config/audience/SemanticVersionTest.java | 120 ++++ 12 files changed, 1136 insertions(+), 2 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/config/audience/match/GEMatch.java create mode 100644 core-api/src/main/java/com/optimizely/ab/config/audience/match/LEMatch.java create mode 100644 core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java create mode 100644 core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionEqualsMatch.java create mode 100644 core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGEMatch.java create mode 100644 core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGTMatch.java create mode 100644 core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLEMatch.java create mode 100644 core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLTMatch.java create mode 100644 core-api/src/test/java/com/optimizely/ab/config/audience/SemanticVersionTest.java diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/GEMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/GEMatch.java new file mode 100644 index 000000000..8724cfcb0 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/GEMatch.java @@ -0,0 +1,41 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +import static com.optimizely.ab.internal.AttributesUtil.isValidNumber; + +class GEMatch extends AttributeMatch { + Number value; + + protected GEMatch(Number value) { + this.value = value; + } + + @Nullable + public Boolean eval(Object attributeValue) { + try { + if(isValidNumber(attributeValue)) { + return castToValueType(attributeValue, value).doubleValue() >= value.doubleValue(); + } + } catch (Exception e) { + return null; + } + return null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/LEMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/LEMatch.java new file mode 100644 index 000000000..23d1c03fc --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/LEMatch.java @@ -0,0 +1,42 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +import static com.optimizely.ab.internal.AttributesUtil.isValidNumber; + +class LEMatch extends AttributeMatch { + Number value; + + protected LEMatch(Number value) { + this.value = value; + } + + @Nullable + public Boolean eval(Object attributeValue) { + try { + if(isValidNumber(attributeValue)) { + return castToValueType(attributeValue, value).doubleValue() <= value.doubleValue(); + } + } catch (Exception e) { + return null; + } + return null; + } +} + diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchType.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchType.java index 3bdbb4a7c..7455f1270 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchType.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchType.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018-2019, Optimizely and contributors + * Copyright 2018-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -50,11 +50,21 @@ public static MatchType getMatchType(String matchType, Object conditionValue) th return new MatchType(matchType, new SubstringMatch((String) conditionValue)); } break; + case "ge": + if (isValidNumber(conditionValue)) { + return new MatchType(matchType, new GEMatch((Number) conditionValue)); + } + break; case "gt": if (isValidNumber(conditionValue)) { return new MatchType(matchType, new GTMatch((Number) conditionValue)); } break; + case "le": + if (isValidNumber(conditionValue)) { + return new MatchType(matchType, new LEMatch((Number) conditionValue)); + } + break; case "lt": if (isValidNumber(conditionValue)) { return new MatchType(matchType, new LTMatch((Number) conditionValue)); @@ -65,6 +75,31 @@ public static MatchType getMatchType(String matchType, Object conditionValue) th return new MatchType(matchType, new DefaultMatchForLegacyAttributes((String) conditionValue)); } break; + case "semver_eq": + if (conditionValue instanceof String) { + return new MatchType(matchType, new SemanticVersionEqualsMatch((String) conditionValue)); + } + break; + case "semver_ge": + if (conditionValue instanceof String) { + return new MatchType(matchType, new SemanticVersionGEMatch((String) conditionValue)); + } + break; + case "semver_gt": + if (conditionValue instanceof String) { + return new MatchType(matchType, new SemanticVersionGTMatch((String) conditionValue)); + } + break; + case "semver_le": + if (conditionValue instanceof String) { + return new MatchType(matchType, new SemanticVersionLEMatch((String) conditionValue)); + } + break; + case "semver_lt": + if (conditionValue instanceof String) { + return new MatchType(matchType, new SemanticVersionLTMatch((String) conditionValue)); + } + break; default: throw new UnknownMatchTypeException(); } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java new file mode 100644 index 000000000..1eed1d8fc --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java @@ -0,0 +1,133 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static com.optimizely.ab.internal.AttributesUtil.parseNumeric; +import static com.optimizely.ab.internal.AttributesUtil.stringIsNullOrEmpty; + +public final class SemanticVersion { + + private static final String BUILD_SEPERATOR = "+"; + private static final String PRE_RELEASE_SEPERATOR = "-"; + + private final String version; + + public SemanticVersion(String version) { + this.version = version; + } + + public int compare(SemanticVersion targetedVersion) throws Exception { + + if (targetedVersion == null || stringIsNullOrEmpty(targetedVersion.version)) { + return 0; + } + + String[] targetedVersionParts = targetedVersion.splitSemanticVersion(); + String[] userVersionParts = splitSemanticVersion(); + + for (int index = 0; index < targetedVersionParts.length; index++) { + + if (userVersionParts.length <= index) { + return targetedVersion.isPreRelease() ? 1 : -1; + } + Integer targetVersionPartInt = parseNumeric(targetedVersionParts[index]); + Integer userVersionPartInt = parseNumeric(userVersionParts[index]); + + if (userVersionPartInt == null) { + // Compare strings + int result = userVersionParts[index].compareTo(targetedVersionParts[index]); + if (result != 0) { + return result; + } + } else if (targetVersionPartInt != null) { + if (!userVersionPartInt.equals(targetVersionPartInt)) { + return userVersionPartInt < targetVersionPartInt ? -1 : 1; + } + } else { + return -1; + } + } + + if (!targetedVersion.isPreRelease() && + isPreRelease()) { + return -1; + } + + return 0; + } + + public boolean isPreRelease() { + return version.contains(PRE_RELEASE_SEPERATOR); + } + + public boolean isBuild() { + return version.contains(BUILD_SEPERATOR); + } + + public String[] splitSemanticVersion() throws Exception { + List versionParts = new ArrayList<>(); + // pre-release or build. + String versionSuffix = ""; + // for example: beta.2.1 + String[] preVersionParts; + + // Contains white spaces + if (version.contains(" ")) { // log and throw error + throw new Exception("Semantic version contains white spaces. Invalid Semantic Version."); + } + + if (isBuild() || isPreRelease()) { + String[] partialVersionParts = version.split(isPreRelease() ? + PRE_RELEASE_SEPERATOR : BUILD_SEPERATOR); + + if (partialVersionParts.length <= 1) { + // throw error + throw new Exception("Invalid Semantic Version."); + } + // major.minor.patch + String versionPrefix = partialVersionParts[0]; + + versionSuffix = partialVersionParts[1]; + + preVersionParts = versionPrefix.split("\\."); + } else { + preVersionParts = version.split("\\."); + } + + if (preVersionParts.length > 3) { + // Throw error as pre version should only contain major.minor.patch version + throw new Exception("Invalid Semantic Version."); + } + + for (String preVersionPart : preVersionParts) { + if (parseNumeric(preVersionPart) == null) { + throw new Exception("Invalid Semantic Version."); + } + } + + Collections.addAll(versionParts, preVersionParts); + if (!stringIsNullOrEmpty(versionSuffix)) { + versionParts.add(versionSuffix); + } + + return versionParts.toArray(new String[0]); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionEqualsMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionEqualsMatch.java new file mode 100644 index 000000000..b727d88cf --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionEqualsMatch.java @@ -0,0 +1,41 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +class SemanticVersionEqualsMatch implements Match { + String value; + + protected SemanticVersionEqualsMatch(String value) { + this.value = value; + } + + @Nullable + public Boolean eval(Object attributeValue) { + try { + if (this.value != null && attributeValue instanceof String) { + SemanticVersion conditionalVersion = new SemanticVersion(value); + SemanticVersion userSemanticVersion = new SemanticVersion((String) attributeValue); + return userSemanticVersion.compare(conditionalVersion) == 0; + } + } catch (Exception e) { + return null; + } + return null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGEMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGEMatch.java new file mode 100644 index 000000000..fd31e1ab2 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGEMatch.java @@ -0,0 +1,41 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +class SemanticVersionGEMatch implements Match { + String value; + + protected SemanticVersionGEMatch(String value) { + this.value = value; + } + + @Nullable + public Boolean eval(Object attributeValue) { + try { + if (this.value != null && attributeValue instanceof String) { + SemanticVersion conditionalVersion = new SemanticVersion(value); + SemanticVersion userSemanticVersion = new SemanticVersion((String) attributeValue); + return userSemanticVersion.compare(conditionalVersion) >= 0; + } + } catch (Exception e) { + return null; + } + return null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGTMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGTMatch.java new file mode 100644 index 000000000..7ca0f31b1 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGTMatch.java @@ -0,0 +1,41 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +class SemanticVersionGTMatch implements Match { + String value; + + protected SemanticVersionGTMatch(String target) { + this.value = target; + } + + @Nullable + public Boolean eval(Object attributeValue) { + try { + if (this.value != null && attributeValue instanceof String) { + SemanticVersion conditionalVersion = new SemanticVersion(value); + SemanticVersion userSemanticVersion = new SemanticVersion((String) attributeValue); + return userSemanticVersion.compare(conditionalVersion) > 0; + } + } catch (Exception e) { + return null; + } + return null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLEMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLEMatch.java new file mode 100644 index 000000000..6c7629672 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLEMatch.java @@ -0,0 +1,41 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +class SemanticVersionLEMatch implements Match { + String value; + + protected SemanticVersionLEMatch(String target) { + this.value = target; + } + + @Nullable + public Boolean eval(Object attributeValue) { + try { + if (this.value != null && attributeValue instanceof String) { + SemanticVersion conditionalVersion = new SemanticVersion(value); + SemanticVersion userSemanticVersion = new SemanticVersion((String) attributeValue); + return userSemanticVersion.compare(conditionalVersion) <= 0; + } + } catch (Exception e) { + return null; + } + return null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLTMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLTMatch.java new file mode 100644 index 000000000..6f67863a1 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLTMatch.java @@ -0,0 +1,41 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +class SemanticVersionLTMatch implements Match { + String value; + + protected SemanticVersionLTMatch(String target) { + this.value = target; + } + + @Nullable + public Boolean eval(Object attributeValue) { + try { + if (this.value != null && attributeValue instanceof String) { + SemanticVersion conditionalVersion = new SemanticVersion(value); + SemanticVersion userSemanticVersion = new SemanticVersion((String) attributeValue); + return userSemanticVersion.compare(conditionalVersion) < 0; + } + } catch (Exception e) { + return null; + } + return null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/internal/AttributesUtil.java b/core-api/src/main/java/com/optimizely/ab/internal/AttributesUtil.java index 61e0356d0..378e4acb0 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/AttributesUtil.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/AttributesUtil.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019, Optimizely and contributors + * Copyright 2019-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -37,4 +37,27 @@ public static boolean isValidNumber(Object value) { return false; } + /** + * Parse and validate that String is parse able to integer. + * + * @param str String value of integer. + * @return Integer value if is valid and null if not. + */ + public static Integer parseNumeric(String str) { + try { + return Integer.parseInt(str, 10); + } catch (NumberFormatException e) { + return null; + } + } + + /** + * Checks if string is null or empty. + * + * @param str String value. + * @return true if is null or empty else false. + */ + public static boolean stringIsNullOrEmpty(String str) { + return str == null || str.isEmpty(); + } } diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java index 83c5e41df..645025476 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java @@ -469,6 +469,129 @@ public void gtMatchConditionEvaluatesNull() { assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); } + + /** + * Verify that UserAttribute.evaluate for GE match type returns true for known visitor + * attributes where the value's type is a number, and the UserAttribute's value is greater or equal than + * the condition's value. + */ + @Test + public void geMatchConditionEvaluatesTrue() { + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "ge", 2); + UserAttribute testInstanceFloat = new UserAttribute("num_size", "custom_attribute", "ge", (float) 2); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "ge", 2.55); + + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 2))); + assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + + Map badAttributes = new HashMap<>(); + badAttributes.put("num_size", "bobs burgers"); + assertNull(testInstanceInteger.evaluate(null, badAttributes)); + } + + /** + * Verify that UserAttribute.evaluate for GE match type returns null if the UserAttribute's + * value type is invalid number. + */ + @Test + public void geMatchConditionEvaluatesNullWithInvalidUserAttr() { + BigInteger bigInteger = new BigInteger("33221312312312312"); + Double infinitePositiveInfiniteDouble = Double.POSITIVE_INFINITY; + Double infiniteNegativeInfiniteDouble = Double.NEGATIVE_INFINITY; + Double infiniteNANDouble = Double.NaN; + Double largeDouble = Math.pow(2, 53) + 2; + float invalidFloatValue = (float) (Math.pow(2, 53) + 2000000000); + + UserAttribute testInstanceInteger = new UserAttribute( + "num_size", + "custom_attribute", + "ge", + 5); + UserAttribute testInstanceFloat = new UserAttribute( + "num_size", + "custom_attribute", + "ge", + (float) 5); + UserAttribute testInstanceDouble = new UserAttribute( + "num_counts", + "custom_attribute", + "ge", + 5.2); + + assertNull(testInstanceInteger.evaluate( + null, + Collections.singletonMap("num_size", bigInteger))); + assertNull(testInstanceFloat.evaluate( + null, + Collections.singletonMap("num_size", invalidFloatValue))); + assertNull(testInstanceDouble.evaluate( + null, + Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); + assertNull(testInstanceDouble.evaluate( + null, + Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); + assertNull(testInstanceDouble.evaluate( + null, Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", infiniteNANDouble)))); + assertNull(testInstanceDouble.evaluate( + null, Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", largeDouble)))); + } + + /** + * Verify that UserAttribute.evaluate for GE match type returns null if the UserAttribute's + * value type is invalid number. + */ + @Test + public void geMatchConditionEvaluatesNullWithInvalidAttr() { + BigInteger bigInteger = new BigInteger("33221312312312312"); + Double infinitePositiveInfiniteDouble = Double.POSITIVE_INFINITY; + Double infiniteNegativeInfiniteDouble = Double.NEGATIVE_INFINITY; + Double infiniteNANDouble = Double.NaN; + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "ge", bigInteger); + UserAttribute testInstancePositiveInfinite = new UserAttribute("num_counts", "custom_attribute", "ge", infinitePositiveInfiniteDouble); + UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "ge", infiniteNegativeInfiniteDouble); + UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "ge", infiniteNANDouble); + + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); + } + + /** + * Verify that UserAttribute.evaluate for GE match type returns false for known visitor + * attributes where the value's type is a number, and the UserAttribute's value is not greater or equal + * than the condition's value. + */ + @Test + public void geMatchConditionEvaluatesFalse() { + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "ge", 5); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "ge", 5.55); + + assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + } + + /** + * Verify that UserAttribute.evaluate for GE match type returns null if the UserAttribute's + * value type is not a number. + */ + @Test + public void geMatchConditionEvaluatesNull() { + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "ge", 3.5); + UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute", "ge", 3.5); + UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "ge", 3.5); + UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "ge", 3.5); + + assertNull(testInstanceString.evaluate(null, testUserAttributes)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + } + + /** * Verify that UserAttribute.evaluate for GT match type returns true for known visitor * attributes where the value's type is a number, and the UserAttribute's value is less than @@ -584,6 +707,122 @@ public void ltMatchConditionEvaluatesNullWithInvalidAttributes() { assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); } + + /** + * Verify that UserAttribute.evaluate for LE match type returns true for known visitor + * attributes where the value's type is a number, and the UserAttribute's value is less or equal than + * the condition's value. + */ + @Test + public void leMatchConditionEvaluatesTrue() { + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "le", 5); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "le", 5.55); + + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceDouble.evaluate(null, Collections.singletonMap("num_counts", 5.55))); + } + + /** + * Verify that UserAttribute.evaluate for LE match type returns true for known visitor + * attributes where the value's type is a number, and the UserAttribute's value is not less or equal + * than the condition's value. + */ + @Test + public void leMatchConditionEvaluatesFalse() { + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "le", 2); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "le", 2.55); + + assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + } + + /** + * Verify that UserAttribute.evaluate for LE match type returns null if the UserAttribute's + * value type is not a number. + */ + @Test + public void leMatchConditionEvaluatesNull() { + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "le", 3.5); + UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute", "le", 3.5); + UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "le", 3.5); + UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "le", 3.5); + + assertNull(testInstanceString.evaluate(null, testUserAttributes)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + } + + /** + * Verify that UserAttribute.evaluate for LE match type returns null if the UserAttribute's + * value type is not a valid number. + */ + @Test + public void leMatchConditionEvaluatesNullWithInvalidUserAttr() { + BigInteger bigInteger = new BigInteger("33221312312312312"); + Double infinitePositiveInfiniteDouble = Double.POSITIVE_INFINITY; + Double infiniteNegativeInfiniteDouble = Double.NEGATIVE_INFINITY; + Double infiniteNANDouble = Double.NaN; + Double largeDouble = Math.pow(2,53) + 2; + float invalidFloatValue = (float) (Math.pow(2, 53) + 2000000000); + + UserAttribute testInstanceInteger = new UserAttribute( + "num_size", + "custom_attribute", + "le", + 5); + UserAttribute testInstanceFloat = new UserAttribute( + "num_size", + "custom_attribute", + "le", + (float) 5); + UserAttribute testInstanceDouble = new UserAttribute( + "num_counts", + "custom_attribute", + "le", + 5.2); + + assertNull(testInstanceInteger.evaluate( + null, + Collections.singletonMap("num_size", bigInteger))); + assertNull(testInstanceFloat.evaluate( + null, + Collections.singletonMap("num_size", invalidFloatValue))); + assertNull(testInstanceDouble.evaluate( + null, + Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); + assertNull(testInstanceDouble.evaluate( + null, + Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); + assertNull(testInstanceDouble.evaluate( + null, Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", infiniteNANDouble)))); + assertNull(testInstanceDouble.evaluate( + null, Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", largeDouble)))); + } + + /** + * Verify that UserAttribute.evaluate for LE match type returns null if the condition + * value type is not a valid number. + */ + @Test + public void leMatchConditionEvaluatesNullWithInvalidAttributes() { + BigInteger bigInteger = new BigInteger("33221312312312312"); + Double infinitePositiveInfiniteDouble = Double.POSITIVE_INFINITY; + Double infiniteNegativeInfiniteDouble = Double.NEGATIVE_INFINITY; + Double infiniteNANDouble = Double.NaN; + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "le", bigInteger); + UserAttribute testInstancePositiveInfinite = new UserAttribute("num_counts", "custom_attribute", "le", infinitePositiveInfiniteDouble); + UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "le", infiniteNegativeInfiniteDouble); + UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "le", infiniteNANDouble); + + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); + } + /** * Verify that UserAttribute.evaluate for SUBSTRING match type returns true if the * UserAttribute's value is a substring of the condition's value. @@ -633,6 +872,302 @@ public void substringMatchConditionEvaluatesNull() { assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); } + //======== Semantic version evaluation tests ========// + + // Test SemanticVersionEqualsMatch returns null if given invalid value type + @Test + public void testSemanticVersionEqualsMatchInvalidInput() { + Map testAttributes = new HashMap(); + testAttributes.put("version", 2.0); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); + assertNull(testInstanceString.evaluate(null, testAttributes)); + } + + @Test + public void semanticVersionInvalidMajorShouldBeNumberOnly() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "a.1.2"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); + assertNull(testInstanceString.evaluate(null, testAttributes)); + } + + @Test + public void semanticVersionInvalidMinorShouldBeNumberOnly() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "1.b.2"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); + assertNull(testInstanceString.evaluate(null, testAttributes)); + } + + @Test + public void semanticVersionInvalidPatchShouldBeNumberOnly() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "1.2.c"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); + assertNull(testInstanceString.evaluate(null, testAttributes)); + } + + // Test SemanticVersionEqualsMatch returns null if given invalid UserCondition Variable type + @Test + public void testSemanticVersionEqualsMatchInvalidUserConditionVariable() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.0"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", 2.0); + assertNull(testInstanceString.evaluate(null, testAttributes)); + } + + // Test SemanticVersionGTMatch returns null if given invalid value type + @Test + public void testSemanticVersionGTMatchInvalidInput() { + Map testAttributes = new HashMap(); + testAttributes.put("version", false); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.0.0"); + assertNull(testInstanceString.evaluate(null, testAttributes)); + } + + // Test SemanticVersionGEMatch returns null if given invalid value type + @Test + public void testSemanticVersionGEMatchInvalidInput() { + Map testAttributes = new HashMap(); + testAttributes.put("version", 2); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.0.0"); + assertNull(testInstanceString.evaluate(null, testAttributes)); + } + + // Test SemanticVersionLTMatch returns null if given invalid value type + @Test + public void testSemanticVersionLTMatchInvalidInput() { + Map testAttributes = new HashMap(); + testAttributes.put("version", 2); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.0.0"); + assertNull(testInstanceString.evaluate(null, testAttributes)); + } + + // Test SemanticVersionLEMatch returns null if given invalid value type + @Test + public void testSemanticVersionLEMatchInvalidInput() { + Map testAttributes = new HashMap(); + testAttributes.put("version", 2); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.0.0"); + assertNull(testInstanceString.evaluate(null, testAttributes)); + } + + // Test if not same when targetVersion is only major.minor.patch and version is major.minor + @Test + public void testIsSemanticNotSameConditionValueMajorMinorPatch() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "1.2"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "1.2.0"); + assertFalse(testInstanceString.evaluate(null, testAttributes)); + } + + // Test if same when target is only major but user condition checks only major.minor,patch + @Test + public void testIsSemanticSameSingleDigit() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "3.0.0"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test if greater when User value patch is greater even when its beta + @Test + public void testIsSemanticGreaterWhenUserConditionComparesMajorMinorAndPatchVersion() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "3.1.1-beta"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.0"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test if greater when preRelease is greater alphabetically + @Test + public void testIsSemanticGreaterWhenMajorMinorPatchReleaseVersionCharacter() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "3.1.1-beta.y.1+1.1"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.1-beta.x.1+1.1"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test if greater when preRelease version number is greater + @Test + public void testIsSemanticGreaterWhenMajorMinorPatchPreReleaseVersionNum() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "3.1.1-beta.x.2+1.1"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.1-beta.x.1+1.1"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test if equals semantic version even when only same preRelease is passed in user attribute and no build meta + @Test + public void testIsSemanticEqualWhenMajorMinorPatchPreReleaseVersionNum() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "3.1.1-beta.x.1"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3.1.1-beta.x.1"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test if not same + @Test + public void testIsSemanticNotSameReturnsFalse() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.2"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.1.1"); + assertFalse(testInstanceString.evaluate(null, testAttributes)); + } + + // Test when target is full semantic version major.minor.patch + @Test + public void testIsSemanticSameFull() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "3.0.1"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3.0.1"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare less when user condition checks only major.minor + @Test + public void testIsSemanticLess() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.6"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.2"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // When user condition checks major.minor but target is major.minor.patch then its equals + @Test + public void testIsSemanticLessFalse() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.0"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.1"); + assertFalse(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare less when target is full major.minor.patch + @Test + public void testIsSemanticFullLess() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.6"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.1.9"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare greater when user condition checks only major.minor + @Test + public void testIsSemanticMore() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.3.6"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.2"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare greater when both are major.minor.patch-beta but target is greater than user condition + @Test + public void testIsSemanticMoreWhenBeta() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.3.6-beta"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.3.5-beta"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare greater when target is major.minor.patch + @Test + public void testIsSemanticFullMore() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.7"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.6"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare greater when target is major.minor.patch is smaller then it returns false + @Test + public void testSemanticVersionGTFullMoreReturnsFalse() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.9"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.10"); + assertFalse(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare equal when both are exactly same - major.minor.patch-beta + @Test + public void testIsSemanticFullEqual() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.9-beta"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.1.9-beta"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare equal when both major.minor.patch is same, but due to beta user condition is smaller + @Test + public void testIsSemanticLessWhenBeta() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.9"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.9-beta"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare greater when target is major.minor.patch-beta and user condition only compares major.minor.patch + @Test + public void testIsSemanticGreaterBeta() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.9"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.9-beta"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare equal when target is major.minor.patch + @Test + public void testIsSemanticLessEqualsWhenEqualsReturnsTrue() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.9"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.1.9"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare less when target is major.minor.patch + @Test + public void testIsSemanticLessEqualsWhenLessReturnsTrue() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.132.9"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.233.91"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare less when target is major.minor.patch + @Test + public void testIsSemanticLessEqualsWhenGreaterReturnsFalse() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.233.91"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.132.009"); + assertFalse(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare equal when target is major.minor.patch + @Test + public void testIsSemanticGreaterEqualsWhenEqualsReturnsTrue() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.9"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.1.9"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare less when target is major.minor.patch + @Test + public void testIsSemanticGreaterEqualsWhenLessReturnsTrue() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.233.91"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.132.9"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare less when target is major.minor.patch + @Test + public void testIsSemanticGreaterEqualsWhenLessReturnsFalse() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.132.009"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.233.91"); + assertFalse(testInstanceString.evaluate(null, testAttributes)); + } + /** * Verify that NotCondition.evaluate returns null when its condition is null. */ diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/SemanticVersionTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/SemanticVersionTest.java new file mode 100644 index 000000000..daa195a2b --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/SemanticVersionTest.java @@ -0,0 +1,120 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience; + +import com.optimizely.ab.config.audience.match.SemanticVersion; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import static org.junit.Assert.*; + +public class SemanticVersionTest { + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Test + public void semanticVersionInvalidMajorShouldBeNumberOnly() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("a.2.1"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidMinorShouldBeNumberOnly() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("1.b.1"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidPatchShouldBeNumberOnly() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("1.2.c"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidShouldBeOfSizeLessThan3() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("1.2.2.3"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionCompareTo() throws Exception { + SemanticVersion targetSV = new SemanticVersion("3.7.1"); + SemanticVersion actualSV = new SemanticVersion("3.7.1"); + assertTrue(actualSV.compare(targetSV) == 0); + } + + @Test + public void semanticVersionCompareToActualLess() throws Exception { + SemanticVersion targetSV = new SemanticVersion("3.7.1"); + SemanticVersion actualSV = new SemanticVersion("3.7.0"); + assertTrue(actualSV.compare(targetSV) < 0); + } + + @Test + public void semanticVersionCompareToActualGreater() throws Exception { + SemanticVersion targetSV = new SemanticVersion("3.7.1"); + SemanticVersion actualSV = new SemanticVersion("3.7.2"); + assertTrue(actualSV.compare(targetSV) > 0); + } + + @Test + public void semanticVersionCompareToPatchMissing() throws Exception { + SemanticVersion targetSV = new SemanticVersion("3.7"); + SemanticVersion actualSV = new SemanticVersion("3.7.1"); + assertTrue(actualSV.compare(targetSV) == 0); + } + + @Test + public void semanticVersionCompareToActualPatchMissing() throws Exception { + SemanticVersion targetSV = new SemanticVersion("3.7.1"); + SemanticVersion actualSV = new SemanticVersion("3.7"); + assertTrue(actualSV.compare(targetSV) < 0); + } + + @Test + public void semanticVersionCompareToActualPreReleaseMissing() throws Exception { + SemanticVersion targetSV = new SemanticVersion("3.7.1-beta"); + SemanticVersion actualSV = new SemanticVersion("3.7.1"); + assertTrue(actualSV.compare(targetSV) > 0); + } + + @Test + public void semanticVersionCompareToAlphaBetaAsciiComparision() throws Exception { + SemanticVersion targetSV = new SemanticVersion("3.7.1-alpha"); + SemanticVersion actualSV = new SemanticVersion("3.7.1-beta"); + assertTrue(actualSV.compare(targetSV) > 0); + } + + @Test + public void semanticVersionCompareToIgnoreMetaComparision() throws Exception { + SemanticVersion targetSV = new SemanticVersion("3.7.1-beta.1+2.3"); + SemanticVersion actualSV = new SemanticVersion("3.7.1-beta.1+2.3"); + assertTrue(actualSV.compare(targetSV) == 0); + } + + @Test + public void semanticVersionCompareToPreReleaseComparision() throws Exception { + SemanticVersion targetSV = new SemanticVersion("3.7.1-beta.1"); + SemanticVersion actualSV = new SemanticVersion("3.7.1-beta.2"); + assertTrue(actualSV.compare(targetSV) > 0); + } +} From 66d42c3be5702a966d1d387cc92d05282e8f5df4 Mon Sep 17 00:00:00 2001 From: Umer Mansoor Date: Fri, 21 Aug 2020 10:57:46 -0700 Subject: [PATCH 017/147] docs: changed reference from the wrong classname to the correct one in Javadocs (#388) --- .../src/main/java/com/optimizely/ab/OptimizelyFactory.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java index bd51a4cc0..fa4a83dc4 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java @@ -29,12 +29,12 @@ import java.util.concurrent.TimeUnit; /** - * OptimizelyClients is a utility class to instantiate an {@link Optimizely} client with a minimal + * OptimizelyFactory is a utility class to instantiate an {@link Optimizely} client with a minimal * number of configuration options. Basic default parameters can be configured via system properties * or through the use of an optimizely.properties file. System properties takes precedence over * the properties file and are managed via the {@link PropertyUtils} class. * - * OptimizelyClients also provides setter methods to override the system properties at runtime. + * OptimizelyFactory also provides setter methods to override the system properties at runtime. *
      *
    • {@link OptimizelyFactory#setMaxEventBatchSize}
    • *
    • {@link OptimizelyFactory#setMaxEventBatchInterval}
    • From a256a2ecc46913b09e5ecdd6ba3f4d5aad16ca97 Mon Sep 17 00:00:00 2001 From: mnoman09 Date: Thu, 27 Aug 2020 22:27:13 +0500 Subject: [PATCH 018/147] feat: Add datafile accessor (#392) --- .../ab/config/DatafileProjectConfig.java | 13 +++++++++++-- .../com/optimizely/ab/config/ProjectConfig.java | 4 +++- .../ab/optimizelyconfig/OptimizelyConfig.java | 16 +++++++++++++++- .../OptimizelyConfigService.java | 3 ++- .../java/com/optimizely/ab/OptimizelyTest.java | 9 ++++++++- .../config/DatafileProjectConfigBuilderTest.java | 2 ++ 6 files changed, 41 insertions(+), 6 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java index 050f57add..0b188f064 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2019, Optimizely and contributors + * Copyright 2016-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -43,7 +43,6 @@ * Optimizely provides custom JSON parsers to extract objects from the JSON payload * to populate the members of this class. {@link DefaultConfigParser} for details. */ -@Immutable @JsonIgnoreProperties(ignoreUnknown = true) public class DatafileProjectConfig implements ProjectConfig { @@ -88,6 +87,8 @@ public class DatafileProjectConfig implements ProjectConfig { // other mappings private final Map variationIdToExperimentMapping; + private String datafile; + // v2 constructor public DatafileProjectConfig(String accountId, String projectId, String version, String revision, List groups, List experiments, List attributes, List eventType, @@ -301,6 +302,11 @@ public String getAccountId() { return accountId; } + @Override + public String toDatafile() { + return datafile; + } + @Override public String getProjectId() { return projectId; @@ -481,6 +487,9 @@ public ProjectConfig build() throws ConfigParseException { } ProjectConfig projectConfig = DefaultConfigParser.getInstance().parseProjectConfig(datafile); + if (projectConfig instanceof DatafileProjectConfig) { + ((DatafileProjectConfig) projectConfig).datafile = datafile; + } if (!supportedVersions.contains(projectConfig.getVersion())) { throw new ConfigParseException("This version of the Java SDK does not support the given datafile version: " + projectConfig.getVersion()); diff --git a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java index 6f7f3f066..7f83e30b8 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2019, Optimizely and contributors + * Copyright 2016-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -47,6 +47,8 @@ Experiment getExperimentForKey(@Nonnull String experimentKey, String getAccountId(); + String toDatafile(); + String getProjectId(); String getVersion(); diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfig.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfig.java index 2696a4b1c..3e1ab5d5b 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfig.java @@ -15,6 +15,7 @@ ***************************************************************************/ package com.optimizely.ab.optimizelyconfig; + import java.util.*; /** @@ -25,13 +26,22 @@ public class OptimizelyConfig { private Map experimentsMap; private Map featuresMap; private String revision; + private String datafile; public OptimizelyConfig(Map experimentsMap, Map featuresMap, String revision) { + this(experimentsMap, featuresMap, revision, null); + } + + public OptimizelyConfig(Map experimentsMap, + Map featuresMap, + String revision, + String datafile) { this.experimentsMap = experimentsMap; this.featuresMap = featuresMap; - this.revision = revision; + this.revision = revision; + this.datafile = datafile; } public Map getExperimentsMap() { @@ -46,6 +56,10 @@ public String getRevision() { return revision; } + public String getDatafile() { + return datafile; + } + @Override public boolean equals(Object obj) { if (obj == null || getClass() != obj.getClass()) return false; diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java index 4ec447ead..f739ae549 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java @@ -31,7 +31,8 @@ public OptimizelyConfigService(ProjectConfig projectConfig) { optimizelyConfig = new OptimizelyConfig( experimentsMap, getFeaturesMap(experimentsMap), - projectConfig.getRevision() + projectConfig.getRevision(), + projectConfig.toDatafile() ); } diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java index 6c32975a8..21dcd017e 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2016-2019, Optimizely, Inc. and contributors * + * Copyright 2016-2020, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -4572,4 +4572,11 @@ public void getForcedVariationEmptyExperimentKey() { Optimizely optimizely = optimizelyBuilder.build(); assertNull(optimizely.getForcedVariation("", "testUser1")); } + + @Test + public void getOptimizelyConfigValidDatafile() { + Optimizely optimizely = optimizelyBuilder.build(); + assertEquals(optimizely.getOptimizelyConfig().getDatafile(), validDatafile); + } + } diff --git a/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigBuilderTest.java b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigBuilderTest.java index 7b4f78bf9..533be8be2 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigBuilderTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigBuilderTest.java @@ -55,6 +55,8 @@ public void withValidDatafile() throws Exception { ProjectConfig projectConfig = new DatafileProjectConfig.Builder() .withDatafile(validConfigJsonV4()) .build(); + + assertEquals(projectConfig.toDatafile(), validConfigJsonV4()); assertNotNull(projectConfig); assertEquals("4", projectConfig.getVersion()); } From d3dd94d7f5b58e909183612df010c122e9431a66 Mon Sep 17 00:00:00 2001 From: mnoman09 Date: Fri, 28 Aug 2020 22:57:10 +0500 Subject: [PATCH 019/147] Added semversioning additional invalid unit Tests. (#393) Co-authored-by: msohailhussain --- .../audience/match/SemanticVersion.java | 23 +++++- .../config/audience/SemanticVersionTest.java | 78 +++++++++++++++++++ 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java index 1eed1d8fc..c3bd3502e 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java @@ -82,8 +82,20 @@ public boolean isBuild() { return version.contains(BUILD_SEPERATOR); } + private int dotCount(String prefixVersion) { + char[] vCharArray = prefixVersion.toCharArray(); + int count = 0; + for (char c : vCharArray) { + if (c == '.') { + count++; + } + } + return count; + } + public String[] splitSemanticVersion() throws Exception { List versionParts = new ArrayList<>(); + String versionPrefix = ""; // pre-release or build. String versionSuffix = ""; // for example: beta.2.1 @@ -103,16 +115,19 @@ public String[] splitSemanticVersion() throws Exception { throw new Exception("Invalid Semantic Version."); } // major.minor.patch - String versionPrefix = partialVersionParts[0]; + versionPrefix = partialVersionParts[0]; versionSuffix = partialVersionParts[1]; - preVersionParts = versionPrefix.split("\\."); } else { - preVersionParts = version.split("\\."); + versionPrefix = version; } - if (preVersionParts.length > 3) { + preVersionParts = versionPrefix.split("\\."); + + if (preVersionParts.length > 3 || + preVersionParts.length == 0 || + dotCount(versionPrefix) >= preVersionParts.length) { // Throw error as pre version should only contain major.minor.patch version throw new Exception("Invalid Semantic Version."); } diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/SemanticVersionTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/SemanticVersionTest.java index daa195a2b..5a7e542d7 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/audience/SemanticVersionTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/SemanticVersionTest.java @@ -27,6 +27,84 @@ public class SemanticVersionTest { @Rule public ExpectedException thrown = ExpectedException.none(); + + @Test + public void semanticVersionInvalidOnlyDash() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("-"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidOnlyDot() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("."); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidDoubleDot() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion(".."); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidPlus() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("+"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidPlusTest() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("+test"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidOnlySpace() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion(" "); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidSpaces() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("2 .3. 0"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidDotButNoMinorVersion() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("2."); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidDotButNoMajorVersion() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion(".2.1"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidComma() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion(","); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidMissingMajorMinorPatch() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("+build-prerelease"); + semanticVersion.splitSemanticVersion(); + } + @Test public void semanticVersionInvalidMajorShouldBeNumberOnly() throws Exception { thrown.expect(Exception.class); From 4da4b7869f98d5c75659b00c954ffa9d375fdd96 Mon Sep 17 00:00:00 2001 From: msohailhussain Date: Wed, 2 Sep 2020 11:51:25 -0700 Subject: [PATCH 020/147] fix: Semantic version fix and multiple plus signs check added (#394) * Semantic version fix and multiple plus signs check added * renamed buildMetacount function to isValidBuildMetadata Co-authored-by: FOLIO3PK\muhammadnoman --- .../audience/match/SemanticVersion.java | 43 +++++++++++++--- .../config/audience/SemanticVersionTest.java | 50 +++++++++++++++++++ 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java index c3bd3502e..9e37ac02b 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java @@ -25,7 +25,7 @@ public final class SemanticVersion { - private static final String BUILD_SEPERATOR = "+"; + private static final String BUILD_SEPERATOR = "\\+"; private static final String PRE_RELEASE_SEPERATOR = "-"; private final String version; @@ -54,8 +54,10 @@ public int compare(SemanticVersion targetedVersion) throws Exception { if (userVersionPartInt == null) { // Compare strings int result = userVersionParts[index].compareTo(targetedVersionParts[index]); - if (result != 0) { - return result; + if (result < 0) { + return targetedVersion.isPreRelease() && !isPreRelease() ? 1 : -1; + } else if (result > 0) { + return !targetedVersion.isPreRelease() && isPreRelease() ? -1 : 1; } } else if (targetVersionPartInt != null) { if (!userVersionPartInt.equals(targetVersionPartInt)) { @@ -75,11 +77,25 @@ public int compare(SemanticVersion targetedVersion) throws Exception { } public boolean isPreRelease() { - return version.contains(PRE_RELEASE_SEPERATOR); + int buildIndex = version.indexOf("+"); + int preReleaseIndex = version.indexOf("-"); + if (buildIndex < 0) { + return preReleaseIndex > 0; + } else if(preReleaseIndex < 0) { + return false; + } + return preReleaseIndex < buildIndex; } public boolean isBuild() { - return version.contains(BUILD_SEPERATOR); + int buildIndex = version.indexOf("+"); + int preReleaseIndex = version.indexOf("-"); + if (preReleaseIndex < 0) { + return buildIndex > 0; + } else if(buildIndex < 0) { + return false; + } + return buildIndex < preReleaseIndex; } private int dotCount(String prefixVersion) { @@ -93,6 +109,17 @@ private int dotCount(String prefixVersion) { return count; } + private boolean isValidBuildMetadata() { + char[] vCharArray = version.toCharArray(); + int count = 0; + for (char c : vCharArray) { + if (c == '+') { + count++; + } + } + return count > 1; + } + public String[] splitSemanticVersion() throws Exception { List versionParts = new ArrayList<>(); String versionPrefix = ""; @@ -102,13 +129,13 @@ public String[] splitSemanticVersion() throws Exception { String[] preVersionParts; // Contains white spaces - if (version.contains(" ")) { // log and throw error - throw new Exception("Semantic version contains white spaces. Invalid Semantic Version."); + if (version.contains(" ") || isValidBuildMetadata()) { // log and throw error + throw new Exception("Invalid Semantic Version."); } if (isBuild() || isPreRelease()) { String[] partialVersionParts = version.split(isPreRelease() ? - PRE_RELEASE_SEPERATOR : BUILD_SEPERATOR); + PRE_RELEASE_SEPERATOR : BUILD_SEPERATOR, 2); if (partialVersionParts.length <= 1) { // throw error diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/SemanticVersionTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/SemanticVersionTest.java index 5a7e542d7..6d4605e54 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/audience/SemanticVersionTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/SemanticVersionTest.java @@ -49,6 +49,13 @@ public void semanticVersionInvalidDoubleDot() throws Exception { semanticVersion.splitSemanticVersion(); } + @Test + public void semanticVersionInvalidMultipleBuild() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("3.1.2-2+2.3+1"); + semanticVersion.splitSemanticVersion(); + } + @Test public void semanticVersionInvalidPlus() throws Exception { thrown.expect(Exception.class); @@ -175,6 +182,34 @@ public void semanticVersionCompareToActualPreReleaseMissing() throws Exception { assertTrue(actualSV.compare(targetSV) > 0); } + @Test + public void semanticVersionCompareTargetBetaComplex() throws Exception { + SemanticVersion targetSV = new SemanticVersion("2.1.3-beta+1"); + SemanticVersion actualSV = new SemanticVersion("2.1.3-beta+1.2.3"); + assertTrue(actualSV.compare(targetSV) > 0); + } + + @Test + public void semanticVersionCompareTargetBuildIgnores() throws Exception { + SemanticVersion targetSV = new SemanticVersion("2.1.3"); + SemanticVersion actualSV = new SemanticVersion("2.1.3+build"); + assertTrue(actualSV.compare(targetSV) == 0); + } + + @Test + public void semanticVersionCompareTargetBuildComplex() throws Exception { + SemanticVersion targetSV = new SemanticVersion("2.1.3-beta+1.2.3"); + SemanticVersion actualSV = new SemanticVersion("2.1.3-beta+1"); + assertTrue(actualSV.compare(targetSV) < 0); + } + + @Test + public void semanticVersionCompareMultipleDash() throws Exception { + SemanticVersion targetSV = new SemanticVersion("2.1.3-beta-1.2.3"); + SemanticVersion actualSV = new SemanticVersion("2.1.3-beta-1"); + assertTrue(actualSV.compare(targetSV) < 0); + } + @Test public void semanticVersionCompareToAlphaBetaAsciiComparision() throws Exception { SemanticVersion targetSV = new SemanticVersion("3.7.1-alpha"); @@ -182,6 +217,21 @@ public void semanticVersionCompareToAlphaBetaAsciiComparision() throws Exception assertTrue(actualSV.compare(targetSV) > 0); } + @Test + public void semanticVersionComparePrereleaseSmallerThanBuild() throws Exception { + SemanticVersion targetSV = new SemanticVersion("3.7.1-prerelease"); + SemanticVersion actualSV = new SemanticVersion("3.7.1+build"); + assertTrue(actualSV.compare(targetSV) > 0); + } + + + @Test + public void semanticVersionCompareAgainstPreReleaseToPreRelease() throws Exception { + SemanticVersion targetSV = new SemanticVersion("3.7.1-prerelease+build"); + SemanticVersion actualSV = new SemanticVersion("3.7.1-prerelease-prerelease+rc"); + assertTrue(actualSV.compare(targetSV) > 0); + } + @Test public void semanticVersionCompareToIgnoreMetaComparision() throws Exception { SemanticVersion targetSV = new SemanticVersion("3.7.1-beta.1+2.3"); From 302eda6e15b18e891f81715c471039968636c4d4 Mon Sep 17 00:00:00 2001 From: mnoman09 Date: Wed, 9 Sep 2020 22:03:57 +0500 Subject: [PATCH 021/147] Updated slf4j version to latest released version (#396) Updated org.apache.httpcomponents to latest released version --- core-httpclient-impl/gradle.properties | 2 +- gradle.properties | 2 +- java-quickstart/build.gradle | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core-httpclient-impl/gradle.properties b/core-httpclient-impl/gradle.properties index 01204199a..e02f2c2d8 100644 --- a/core-httpclient-impl/gradle.properties +++ b/core-httpclient-impl/gradle.properties @@ -1 +1 @@ -httpClientVersion = 4.5.6 +httpClientVersion = 4.5.12 diff --git a/gradle.properties b/gradle.properties index 373aabdc1..bf269fc7f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,7 +17,7 @@ jacksonVersion = 2.9.8 jsonVersion = 20160212 jsonSimpleVersion = 1.1.1 logbackVersion = 1.1.5 -slf4jVersion = 1.7.25 +slf4jVersion = 1.7.30 # Style Packages findbugsAnnotationVersion = 3.0.1 diff --git a/java-quickstart/build.gradle b/java-quickstart/build.gradle index 1bedc6b79..faa76f87d 100644 --- a/java-quickstart/build.gradle +++ b/java-quickstart/build.gradle @@ -4,7 +4,7 @@ dependencies { compile group: 'com.google.code.gson', name: 'gson', version: '2.8.5' compile group: 'org.apache.httpcomponents', name: 'httpclient', version: '4.5.2' - compile group: 'org.slf4j', name: 'slf4j-log4j12', version: '1.7.25' + compile group: 'org.slf4j', name: 'slf4j-log4j12', version: '1.7.30' testCompile group: 'junit', name: 'junit', version: '4.12' } From 7b4e7144e6121817906f6c1ae93775451c707efa Mon Sep 17 00:00:00 2001 From: mnoman09 Date: Mon, 14 Sep 2020 21:27:25 +0500 Subject: [PATCH 022/147] Feat: updated all library versions to latest to avoid vulnerability issues (#397) --- build.gradle | 4 +++- gradle.properties | 13 +++++++------ java-quickstart/build.gradle | 4 ++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/build.gradle b/build.gradle index 881e9bea7..91b68e235 100644 --- a/build.gradle +++ b/build.gradle @@ -17,7 +17,7 @@ plugins { id 'me.champeau.gradle.jmh' version '0.4.5' id 'nebula.optional-base' version '3.2.0' id 'com.github.hierynomus.license' version '0.15.0' - id 'com.github.spotbugs' version "4.3.0" + id 'com.github.spotbugs' version "4.5.0" } allprojects { @@ -110,6 +110,8 @@ subprojects { } dependencies { + compile group: 'commons-codec', name: 'commons-codec', version: commonCodecVersion + testCompile group: 'junit', name: 'junit', version: junitVersion testCompile group: 'org.mockito', name: 'mockito-core', version: mockitoVersion testCompile group: 'org.hamcrest', name: 'hamcrest-all', version: hamcrestVersion diff --git a/gradle.properties b/gradle.properties index bf269fc7f..c67b677d9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,13 +10,13 @@ org.gradle.daemon = true org.gradle.parallel = true # Application Packages -gsonVersion = 2.6.1 -guavaVersion = 19.0 +gsonVersion = 2.8.6 +guavaVersion = 22.0 hamcrestVersion = 1.3 -jacksonVersion = 2.9.8 -jsonVersion = 20160212 +jacksonVersion = 2.11.2 +jsonVersion = 20190722 jsonSimpleVersion = 1.1.1 -logbackVersion = 1.1.5 +logbackVersion = 1.2.3 slf4jVersion = 1.7.30 # Style Packages @@ -24,5 +24,6 @@ findbugsAnnotationVersion = 3.0.1 findbugsJsrVersion = 3.0.2 # Test Packages -junitVersion = 4.12 +junitVersion = 4.13 mockitoVersion = 1.10.19 +commonCodecVersion = 1.15 diff --git a/java-quickstart/build.gradle b/java-quickstart/build.gradle index faa76f87d..30a3a8b2b 100644 --- a/java-quickstart/build.gradle +++ b/java-quickstart/build.gradle @@ -2,8 +2,8 @@ dependencies { compile project(':core-api') compile project(':core-httpclient-impl') - compile group: 'com.google.code.gson', name: 'gson', version: '2.8.5' - compile group: 'org.apache.httpcomponents', name: 'httpclient', version: '4.5.2' + compile group: 'com.google.code.gson', name: 'gson', version: '2.8.6' + compile group: 'org.apache.httpcomponents', name: 'httpclient', version: '4.5.12' compile group: 'org.slf4j', name: 'slf4j-log4j12', version: '1.7.30' testCompile group: 'junit', name: 'junit', version: '4.12' } From 2ffd6d9b0c3ad3900a5d93df27f8783024a39f9d Mon Sep 17 00:00:00 2001 From: mnoman09 Date: Tue, 15 Sep 2020 22:43:56 +0500 Subject: [PATCH 023/147] Fix: Resolved log4j vulnerability issue (#398) --- java-quickstart/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/java-quickstart/build.gradle b/java-quickstart/build.gradle index 30a3a8b2b..8f16c3100 100644 --- a/java-quickstart/build.gradle +++ b/java-quickstart/build.gradle @@ -4,7 +4,7 @@ dependencies { compile group: 'com.google.code.gson', name: 'gson', version: '2.8.6' compile group: 'org.apache.httpcomponents', name: 'httpclient', version: '4.5.12' - compile group: 'org.slf4j', name: 'slf4j-log4j12', version: '1.7.30' + compile group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: '2.13.3' testCompile group: 'junit', name: 'junit', version: '4.12' } From c6da789cfc22581c6c76bd35ec10c8cf792235f4 Mon Sep 17 00:00:00 2001 From: Mike Davis Date: Tue, 15 Sep 2020 15:16:19 -0700 Subject: [PATCH 024/147] Add MatchRegistry for custom match implementations. (#390) * Replace MatchType in favor of MatchRegistry * Add conditionValue to Match#match interface. * Remove the abstract AttributeMatch class. * Remove ExactNumberMatch class. * Add NumberComparator for standardizing Number comparisons * Refactor Semantic Versioning to use static convenience method Incorporating a public MatchRegistry will allow consumers to provide their own implementations of the Match interface. --- .../ab/config/audience/UserAttribute.java | 56 ++++---- .../DefaultMatchForLegacyAttributes.java | 21 ++- .../ab/config/audience/match/ExactMatch.java | 35 +++-- .../audience/match/ExactNumberMatch.java | 47 ------- .../ab/config/audience/match/ExistsMatch.java | 16 +-- .../ab/config/audience/match/GEMatch.java | 24 +--- .../ab/config/audience/match/GTMatch.java | 26 +--- .../ab/config/audience/match/LEMatch.java | 24 +--- .../ab/config/audience/match/LTMatch.java | 27 +--- .../ab/config/audience/match/Match.java | 4 +- .../config/audience/match/MatchRegistry.java | 82 ++++++++++++ .../ab/config/audience/match/MatchType.java | 124 ------------------ .../audience/match/NumberComparator.java | 41 ++++++ .../audience/match/SemanticVersion.java | 25 ++++ .../match/SemanticVersionEqualsMatch.java | 22 +--- .../match/SemanticVersionGEMatch.java | 23 +--- .../match/SemanticVersionGTMatch.java | 22 +--- .../match/SemanticVersionLEMatch.java | 23 +--- .../match/SemanticVersionLTMatch.java | 22 +--- .../config/audience/match/SubstringMatch.java | 30 ++--- .../match/UnexpectedValueTypeException.java | 4 + .../match/UnknownMatchTypeException.java | 3 + ...ch.java => UnknownValueTypeException.java} | 23 ++-- .../AudienceConditionEvaluationTest.java | 4 +- .../config/audience/match/ExactMatchTest.java | 84 ++++++++++++ .../audience/match/MatchRegistryTest.java | 61 +++++++++ .../audience/match/NumberComparatorTest.java | 75 +++++++++++ .../{ => match}/SemanticVersionTest.java | 118 +++-------------- .../audience/match/SubstringMatchTest.java | 65 +++++++++ 29 files changed, 607 insertions(+), 524 deletions(-) delete mode 100644 core-api/src/main/java/com/optimizely/ab/config/audience/match/ExactNumberMatch.java create mode 100644 core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchRegistry.java delete mode 100644 core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchType.java create mode 100644 core-api/src/main/java/com/optimizely/ab/config/audience/match/NumberComparator.java rename core-api/src/main/java/com/optimizely/ab/config/audience/match/{AttributeMatch.java => UnknownValueTypeException.java} (60%) create mode 100644 core-api/src/test/java/com/optimizely/ab/config/audience/match/ExactMatchTest.java create mode 100644 core-api/src/test/java/com/optimizely/ab/config/audience/match/MatchRegistryTest.java create mode 100644 core-api/src/test/java/com/optimizely/ab/config/audience/match/NumberComparatorTest.java rename core-api/src/test/java/com/optimizely/ab/config/audience/{ => match}/SemanticVersionTest.java (52%) create mode 100644 core-api/src/test/java/com/optimizely/ab/config/audience/match/SubstringMatchTest.java diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java b/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java index be1e11169..277f2f184 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java @@ -20,10 +20,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.audience.match.Match; -import com.optimizely.ab.config.audience.match.MatchType; -import com.optimizely.ab.config.audience.match.UnexpectedValueTypeException; -import com.optimizely.ab.config.audience.match.UnknownMatchTypeException; +import com.optimizely.ab.config.audience.match.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -87,35 +84,36 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { } // check user attribute value is equal try { - Match matchType = MatchType.getMatchType(match, value).getMatcher(); - Boolean result = matchType.eval(userAttributeValue); - + Match matcher = MatchRegistry.getMatch(match); + Boolean result = matcher.eval(value, userAttributeValue); if (result == null) { - if (!attributes.containsKey(name)) { - //Missing attribute value - logger.debug("Audience condition \"{}\" evaluated to UNKNOWN because no value was passed for user attribute \"{}\"", this, name); + throw new UnknownValueTypeException(); + } + + return result; + } catch(UnknownValueTypeException e) { + if (!attributes.containsKey(name)) { + //Missing attribute value + logger.debug("Audience condition \"{}\" evaluated to UNKNOWN because no value was passed for user attribute \"{}\"", this, name); + } else { + //if attribute value is not valid + if (userAttributeValue != null) { + logger.warn( + "Audience condition \"{}\" evaluated to UNKNOWN because a value of type \"{}\" was passed for user attribute \"{}\"", + this, + userAttributeValue.getClass().getCanonicalName(), + name); } else { - //if attribute value is not valid - if (userAttributeValue != null) { - logger.warn( - "Audience condition \"{}\" evaluated to UNKNOWN because a value of type \"{}\" was passed for user attribute \"{}\"", - this, - userAttributeValue.getClass().getCanonicalName(), - name); - } else { - logger.debug( - "Audience condition \"{}\" evaluated to UNKNOWN because a null value was passed for user attribute \"{}\"", - this, - name); - } + logger.debug( + "Audience condition \"{}\" evaluated to UNKNOWN because a null value was passed for user attribute \"{}\"", + this, + name); } } - return result; - } catch (UnknownMatchTypeException | UnexpectedValueTypeException ex) { - logger.warn("Audience condition \"{}\" " + ex.getMessage(), - this); - } catch (NullPointerException np) { - logger.error("attribute or value null for match {}", match != null ? match : "legacy condition", np); + } catch (UnknownMatchTypeException | UnexpectedValueTypeException e) { + logger.warn("Audience condition \"{}\" " + e.getMessage(), this); + } catch (NullPointerException e) { + logger.error("attribute or value null for match {}", match != null ? match : "legacy condition", e); } return null; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/DefaultMatchForLegacyAttributes.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/DefaultMatchForLegacyAttributes.java index cb2dfa671..c3c970541 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/DefaultMatchForLegacyAttributes.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/DefaultMatchForLegacyAttributes.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018-2019, Optimizely and contributors + * Copyright 2018-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,17 +21,16 @@ /** * This is a temporary class. It mimics the current behaviour for * legacy custom attributes. This will be dropped for ExactMatch and the unit tests need to be fixed. - * @param */ -class DefaultMatchForLegacyAttributes extends AttributeMatch { - T value; - - protected DefaultMatchForLegacyAttributes(T value) { - this.value = value; - } - +class DefaultMatchForLegacyAttributes implements Match { @Nullable - public Boolean eval(Object attributeValue) { - return value.equals(castToValueType(attributeValue, value)); + public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + if (!(conditionValue instanceof String)) { + throw new UnexpectedValueTypeException(); + } + if (attributeValue == null) { + return false; + } + return conditionValue.toString().equals(attributeValue.toString()); } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExactMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExactMatch.java index 9d8d4e8c3..5781ac892 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExactMatch.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExactMatch.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018-2019, Optimizely and contributors + * Copyright 2018-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,18 +18,31 @@ import javax.annotation.Nullable; -class ExactMatch extends AttributeMatch { - T value; - - protected ExactMatch(T value) { - this.value = value; - } +import static com.optimizely.ab.internal.AttributesUtil.isValidNumber; +/** + * ExactMatch supports matching Numbers, Strings and Booleans. Numbers are first converted to doubles + * before the comparison is evaluated. See {@link NumberComparator} Strings and Booleans are evaulated + * via the Object equals method. + */ +class ExactMatch implements Match { @Nullable - public Boolean eval(Object attributeValue) { - T converted = castToValueType(attributeValue, value); - if (value != null && converted == null) return null; + public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + if (isValidNumber(attributeValue)) { + if (isValidNumber(conditionValue)) { + return NumberComparator.compareUnsafe(attributeValue, conditionValue) == 0; + } + return null; + } + + if (!(conditionValue instanceof String || conditionValue instanceof Boolean)) { + throw new UnexpectedValueTypeException(); + } + + if (attributeValue == null || attributeValue.getClass() != conditionValue.getClass()) { + return null; + } - return value == null ? attributeValue == null : value.equals(converted); + return conditionValue.equals(attributeValue); } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExactNumberMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExactNumberMatch.java deleted file mode 100644 index 56984537a..000000000 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExactNumberMatch.java +++ /dev/null @@ -1,47 +0,0 @@ -/** - * - * Copyright 2018-2019, Optimizely and contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.optimizely.ab.config.audience.match; - -import com.optimizely.ab.config.ProjectConfig; - -import javax.annotation.Nullable; - -import static com.optimizely.ab.internal.AttributesUtil.isValidNumber; - -// Because json number is a double in most java json parsers. at this -// point we allow comparision of Integer and Double. The instance class is Double and -// Integer which would fail in our normal exact match. So, we are special casing for now. We have already filtered -// out other Number types. -public class ExactNumberMatch extends AttributeMatch { - Number value; - - protected ExactNumberMatch(Number value) { - this.value = value; - } - - @Nullable - public Boolean eval(Object attributeValue) { - try { - if(isValidNumber(attributeValue)) { - return value.doubleValue() == castToValueType(attributeValue, value).doubleValue(); - } - } catch (Exception e) { - } - - return null; - } -} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExistsMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExistsMatch.java index 594ea6fc4..38fb5a884 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExistsMatch.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExistsMatch.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018-2019, Optimizely and contributors + * Copyright 2018-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,20 +16,14 @@ */ package com.optimizely.ab.config.audience.match; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; - import javax.annotation.Nullable; +/** + * ExistsMatch checks that the attribute value is NOT null. + */ class ExistsMatch implements Match { - @SuppressFBWarnings("URF_UNREAD_FIELD") - Object value; - - protected ExistsMatch(Object value) { - this.value = value; - } - @Nullable - public Boolean eval(Object attributeValue) { + public Boolean eval(Object conditionValue, Object attributeValue) { return attributeValue != null; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/GEMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/GEMatch.java index 8724cfcb0..e66012cba 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/GEMatch.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/GEMatch.java @@ -18,24 +18,12 @@ import javax.annotation.Nullable; -import static com.optimizely.ab.internal.AttributesUtil.isValidNumber; - -class GEMatch extends AttributeMatch { - Number value; - - protected GEMatch(Number value) { - this.value = value; - } - +/** + * GEMatch performs a "greater than or equal to" number comparison via {@link NumberComparator}. + */ +class GEMatch implements Match { @Nullable - public Boolean eval(Object attributeValue) { - try { - if(isValidNumber(attributeValue)) { - return castToValueType(attributeValue, value).doubleValue() >= value.doubleValue(); - } - } catch (Exception e) { - return null; - } - return null; + public Boolean eval(Object conditionValue, Object attributeValue) throws UnknownValueTypeException { + return NumberComparator.compare(attributeValue, conditionValue) >= 0; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/GTMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/GTMatch.java index 8b9e9dd7b..ba6689c9e 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/GTMatch.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/GTMatch.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018-2019, Optimizely and contributors + * Copyright 2018-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,24 +18,12 @@ import javax.annotation.Nullable; -import static com.optimizely.ab.internal.AttributesUtil.isValidNumber; - -class GTMatch extends AttributeMatch { - Number value; - - protected GTMatch(Number value) { - this.value = value; - } - +/** + * GTMatch performs a "greater than" number comparison via {@link NumberComparator}. + */ +class GTMatch implements Match { @Nullable - public Boolean eval(Object attributeValue) { - try { - if(isValidNumber(attributeValue)) { - return castToValueType(attributeValue, value).doubleValue() > value.doubleValue(); - } - } catch (Exception e) { - return null; - } - return null; + public Boolean eval(Object conditionValue, Object attributeValue) throws UnknownValueTypeException { + return NumberComparator.compare(attributeValue, conditionValue) > 0; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/LEMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/LEMatch.java index 23d1c03fc..b222fa022 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/LEMatch.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/LEMatch.java @@ -18,25 +18,13 @@ import javax.annotation.Nullable; -import static com.optimizely.ab.internal.AttributesUtil.isValidNumber; - -class LEMatch extends AttributeMatch { - Number value; - - protected LEMatch(Number value) { - this.value = value; - } - +/** + * GEMatch performs a "less than or equal to" number comparison via {@link NumberComparator}. + */ +class LEMatch implements Match { @Nullable - public Boolean eval(Object attributeValue) { - try { - if(isValidNumber(attributeValue)) { - return castToValueType(attributeValue, value).doubleValue() <= value.doubleValue(); - } - } catch (Exception e) { - return null; - } - return null; + public Boolean eval(Object conditionValue, Object attributeValue) throws UnknownValueTypeException { + return NumberComparator.compare(attributeValue, conditionValue) <= 0; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/LTMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/LTMatch.java index 951adbffb..3000aedff 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/LTMatch.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/LTMatch.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018-2019, Optimizely and contributors + * Copyright 2018-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,25 +18,12 @@ import javax.annotation.Nullable; -import static com.optimizely.ab.internal.AttributesUtil.isValidNumber; - -class LTMatch extends AttributeMatch { - Number value; - - protected LTMatch(Number value) { - this.value = value; - } - +/** + * GTMatch performs a "less than" number comparison via {@link NumberComparator}. + */ +class LTMatch implements Match { @Nullable - public Boolean eval(Object attributeValue) { - try { - if(isValidNumber(attributeValue)) { - return castToValueType(attributeValue, value).doubleValue() < value.doubleValue(); - } - } catch (Exception e) { - return null; - } - return null; + public Boolean eval(Object conditionValue, Object attributeValue) throws UnknownValueTypeException { + return NumberComparator.compare(attributeValue, conditionValue) < 0; } } - diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/Match.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/Match.java index 2f0d3a2a1..7bef74e6c 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/Match.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/Match.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018-2019, Optimizely and contributors + * Copyright 2018-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,5 +20,5 @@ public interface Match { @Nullable - Boolean eval(Object attributeValue); + Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException, UnknownValueTypeException; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchRegistry.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchRegistry.java new file mode 100644 index 000000000..a468bc5e2 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchRegistry.java @@ -0,0 +1,82 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * MatchRegistry maps a string match "type" to a match implementation. + * All supported Match implementations must be registed with this registry. + * Third-party {@link Match} implementations may also be registered to provide + * additional functionality. + */ +public class MatchRegistry { + + private static final Map registry = new ConcurrentHashMap<>(); + public static final String EXACT = "exact"; + public static final String EXISTS = "exists"; + public static final String GREATER_THAN = "gt"; + public static final String GREATER_THAN_EQ = "ge"; + public static final String LEGACY = "legacy"; + public static final String LESS_THAN = "lt"; + public static final String LESS_THAN_EQ = "le"; + public static final String SEMVER_EQ = "semver_eq"; + public static final String SEMVER_GE = "semver_ge"; + public static final String SEMVER_GT = "semver_gt"; + public static final String SEMVER_LE = "semver_le"; + public static final String SEMVER_LT = "semver_lt"; + public static final String SUBSTRING = "substring"; + + static { + register(EXACT, new ExactMatch()); + register(EXISTS, new ExistsMatch()); + register(GREATER_THAN, new GTMatch()); + register(GREATER_THAN_EQ, new GEMatch()); + register(LEGACY, new DefaultMatchForLegacyAttributes()); + register(LESS_THAN, new LTMatch()); + register(LESS_THAN_EQ, new LEMatch()); + register(SEMVER_EQ, new SemanticVersionEqualsMatch()); + register(SEMVER_GE, new SemanticVersionGEMatch()); + register(SEMVER_GT, new SemanticVersionGTMatch()); + register(SEMVER_LE, new SemanticVersionLEMatch()); + register(SEMVER_LT, new SemanticVersionLTMatch()); + register(SUBSTRING, new SubstringMatch()); + } + + // TODO rename Match to Matcher + public static Match getMatch(String name) throws UnknownMatchTypeException { + Match match = registry.get(name == null ? LEGACY : name); + if (match == null) { + throw new UnknownMatchTypeException(); + } + + return match; + } + + /** + * register registers a Match implementation with it's name. + * NOTE: This does not check for existence so default implementations can + * be overridden. + * @param name + * @param match + */ + public static void register(String name, Match match) { + registry.put(name, match); + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchType.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchType.java deleted file mode 100644 index 7455f1270..000000000 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchType.java +++ /dev/null @@ -1,124 +0,0 @@ -/** - * - * Copyright 2018-2020, Optimizely and contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.optimizely.ab.config.audience.match; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import javax.annotation.Nonnull; - -import static com.optimizely.ab.internal.AttributesUtil.isValidNumber; - -public class MatchType { - - public static final Logger logger = LoggerFactory.getLogger(MatchType.class); - - private String matchType; - private Match matcher; - - public static MatchType getMatchType(String matchType, Object conditionValue) throws UnexpectedValueTypeException, UnknownMatchTypeException { - if (matchType == null) matchType = "legacy_custom_attribute"; - - switch (matchType) { - case "exists": - return new MatchType(matchType, new ExistsMatch(conditionValue)); - case "exact": - if (conditionValue instanceof String) { - return new MatchType(matchType, new ExactMatch((String) conditionValue)); - } else if (isValidNumber(conditionValue)) { - return new MatchType(matchType, new ExactNumberMatch((Number) conditionValue)); - } else if (conditionValue instanceof Boolean) { - return new MatchType(matchType, new ExactMatch((Boolean) conditionValue)); - } - break; - case "substring": - if (conditionValue instanceof String) { - return new MatchType(matchType, new SubstringMatch((String) conditionValue)); - } - break; - case "ge": - if (isValidNumber(conditionValue)) { - return new MatchType(matchType, new GEMatch((Number) conditionValue)); - } - break; - case "gt": - if (isValidNumber(conditionValue)) { - return new MatchType(matchType, new GTMatch((Number) conditionValue)); - } - break; - case "le": - if (isValidNumber(conditionValue)) { - return new MatchType(matchType, new LEMatch((Number) conditionValue)); - } - break; - case "lt": - if (isValidNumber(conditionValue)) { - return new MatchType(matchType, new LTMatch((Number) conditionValue)); - } - break; - case "legacy_custom_attribute": - if (conditionValue instanceof String) { - return new MatchType(matchType, new DefaultMatchForLegacyAttributes((String) conditionValue)); - } - break; - case "semver_eq": - if (conditionValue instanceof String) { - return new MatchType(matchType, new SemanticVersionEqualsMatch((String) conditionValue)); - } - break; - case "semver_ge": - if (conditionValue instanceof String) { - return new MatchType(matchType, new SemanticVersionGEMatch((String) conditionValue)); - } - break; - case "semver_gt": - if (conditionValue instanceof String) { - return new MatchType(matchType, new SemanticVersionGTMatch((String) conditionValue)); - } - break; - case "semver_le": - if (conditionValue instanceof String) { - return new MatchType(matchType, new SemanticVersionLEMatch((String) conditionValue)); - } - break; - case "semver_lt": - if (conditionValue instanceof String) { - return new MatchType(matchType, new SemanticVersionLTMatch((String) conditionValue)); - } - break; - default: - throw new UnknownMatchTypeException(); - } - - throw new UnexpectedValueTypeException(); - } - - private MatchType(String type, Match matcher) { - this.matchType = type; - this.matcher = matcher; - } - - @Nonnull - public Match getMatcher() { - return matcher; - } - - @Override - public String toString() { - return matchType; - } -} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/NumberComparator.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/NumberComparator.java new file mode 100644 index 000000000..49ce94eab --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/NumberComparator.java @@ -0,0 +1,41 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import static com.optimizely.ab.internal.AttributesUtil.isValidNumber; + +/** + * NumberComparator performs a numeric comparison. The input values are assumed to be numbers else + * compare will throw an {@link UnknownValueTypeException}. + */ +public class NumberComparator { + public static int compare(Object o1, Object o2) throws UnknownValueTypeException { + if (!isValidNumber(o1) || !isValidNumber(o2)) { + throw new UnknownValueTypeException(); + } + + return compareUnsafe(o1, o2); + } + + /** + * compareUnsafe is provided to avoid checking the input values are numbers. It's assumed that the inputs + * are known to be Numbers. + */ + static int compareUnsafe(Object o1, Object o2) { + return Double.compare(((Number) o1).doubleValue(), ((Number) o2).doubleValue()); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java index 9e37ac02b..d963e7702 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java @@ -16,6 +16,9 @@ */ package com.optimizely.ab.config.audience.match; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -23,8 +26,12 @@ import static com.optimizely.ab.internal.AttributesUtil.parseNumeric; import static com.optimizely.ab.internal.AttributesUtil.stringIsNullOrEmpty; +/** + * SemanticVersion implements the specification for the purpose of comparing two Versions. + */ public final class SemanticVersion { + private static final Logger logger = LoggerFactory.getLogger(SemanticVersion.class); private static final String BUILD_SEPERATOR = "\\+"; private static final String PRE_RELEASE_SEPERATOR = "-"; @@ -34,6 +41,24 @@ public SemanticVersion(String version) { this.version = version; } + /** + * compare takes object inputs and coerces them into SemanticVersion objects before performing the comparison. + * If the input values cannot be coerced then an {@link UnexpectedValueTypeException} is thrown. + */ + public static int compare(Object o1, Object o2) throws UnexpectedValueTypeException { + if (o1 instanceof String && o2 instanceof String) { + SemanticVersion v1 = new SemanticVersion((String) o1); + SemanticVersion v2 = new SemanticVersion((String) o2); + try { + return v1.compare(v2); + } catch (Exception e) { + logger.warn("Error comparing semantic versions", e); + } + } + + throw new UnexpectedValueTypeException(); + } + public int compare(SemanticVersion targetedVersion) throws Exception { if (targetedVersion == null || stringIsNullOrEmpty(targetedVersion.version)) { diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionEqualsMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionEqualsMatch.java index b727d88cf..ac0c8310b 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionEqualsMatch.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionEqualsMatch.java @@ -18,24 +18,12 @@ import javax.annotation.Nullable; +/** + * SemanticVersionEqualsMatch performs a equality comparison via {@link SemanticVersion#compare(Object, Object)}. + */ class SemanticVersionEqualsMatch implements Match { - String value; - - protected SemanticVersionEqualsMatch(String value) { - this.value = value; - } - @Nullable - public Boolean eval(Object attributeValue) { - try { - if (this.value != null && attributeValue instanceof String) { - SemanticVersion conditionalVersion = new SemanticVersion(value); - SemanticVersion userSemanticVersion = new SemanticVersion((String) attributeValue); - return userSemanticVersion.compare(conditionalVersion) == 0; - } - } catch (Exception e) { - return null; - } - return null; + public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + return SemanticVersion.compare(attributeValue, conditionValue) == 0; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGEMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGEMatch.java index fd31e1ab2..91f95d4cd 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGEMatch.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGEMatch.java @@ -18,24 +18,13 @@ import javax.annotation.Nullable; +/** + * SemanticVersionGEMatch performs a "greater than or equal to" comparison + * via {@link SemanticVersion#compare(Object, Object)}. + */ class SemanticVersionGEMatch implements Match { - String value; - - protected SemanticVersionGEMatch(String value) { - this.value = value; - } - @Nullable - public Boolean eval(Object attributeValue) { - try { - if (this.value != null && attributeValue instanceof String) { - SemanticVersion conditionalVersion = new SemanticVersion(value); - SemanticVersion userSemanticVersion = new SemanticVersion((String) attributeValue); - return userSemanticVersion.compare(conditionalVersion) >= 0; - } - } catch (Exception e) { - return null; - } - return null; + public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + return SemanticVersion.compare(attributeValue, conditionValue) >= 0; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGTMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGTMatch.java index 7ca0f31b1..52513024c 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGTMatch.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGTMatch.java @@ -18,24 +18,12 @@ import javax.annotation.Nullable; +/** + * SemanticVersionGTMatch performs a "greater than" comparison via {@link SemanticVersion#compare(Object, Object)}. + */ class SemanticVersionGTMatch implements Match { - String value; - - protected SemanticVersionGTMatch(String target) { - this.value = target; - } - @Nullable - public Boolean eval(Object attributeValue) { - try { - if (this.value != null && attributeValue instanceof String) { - SemanticVersion conditionalVersion = new SemanticVersion(value); - SemanticVersion userSemanticVersion = new SemanticVersion((String) attributeValue); - return userSemanticVersion.compare(conditionalVersion) > 0; - } - } catch (Exception e) { - return null; - } - return null; + public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + return SemanticVersion.compare(attributeValue, conditionValue) > 0; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLEMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLEMatch.java index 6c7629672..4297d4545 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLEMatch.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLEMatch.java @@ -18,24 +18,13 @@ import javax.annotation.Nullable; +/** + * SemanticVersionLEMatch performs a "less than or equal to" comparison + * via {@link SemanticVersion#compare(Object, Object)}. + */ class SemanticVersionLEMatch implements Match { - String value; - - protected SemanticVersionLEMatch(String target) { - this.value = target; - } - @Nullable - public Boolean eval(Object attributeValue) { - try { - if (this.value != null && attributeValue instanceof String) { - SemanticVersion conditionalVersion = new SemanticVersion(value); - SemanticVersion userSemanticVersion = new SemanticVersion((String) attributeValue); - return userSemanticVersion.compare(conditionalVersion) <= 0; - } - } catch (Exception e) { - return null; - } - return null; + public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + return SemanticVersion.compare(attributeValue, conditionValue) <= 0; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLTMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLTMatch.java index 6f67863a1..a35dcd2da 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLTMatch.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLTMatch.java @@ -18,24 +18,12 @@ import javax.annotation.Nullable; +/** + * SemanticVersionLTMatch performs a "less than" comparison via {@link SemanticVersion#compare(Object, Object)}. + */ class SemanticVersionLTMatch implements Match { - String value; - - protected SemanticVersionLTMatch(String target) { - this.value = target; - } - @Nullable - public Boolean eval(Object attributeValue) { - try { - if (this.value != null && attributeValue instanceof String) { - SemanticVersion conditionalVersion = new SemanticVersion(value); - SemanticVersion userSemanticVersion = new SemanticVersion((String) attributeValue); - return userSemanticVersion.compare(conditionalVersion) < 0; - } - } catch (Exception e) { - return null; - } - return null; + public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + return SemanticVersion.compare(attributeValue, conditionValue) < 0; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SubstringMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SubstringMatch.java index 946ebad99..5a573e495 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SubstringMatch.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SubstringMatch.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018-2019, Optimizely and contributors + * Copyright 2018-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,23 +18,23 @@ import javax.annotation.Nullable; -class SubstringMatch extends AttributeMatch { - String value; +/** + * SubstringMatch checks if the attribute value contains the condition value. + * This assumes both the condition and attribute values are provided as Strings. + */ +class SubstringMatch implements Match { + @Nullable + public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + if (!(conditionValue instanceof String)) { + throw new UnexpectedValueTypeException(); + } - protected SubstringMatch(String value) { - this.value = value; - } + if (!(attributeValue instanceof String)) { + return null; + } - /** - * This matches the same substring matching logic in the Web client. - * - * @param attributeValue - * @return true/false if the user attribute string value contains the condition string value - */ - @Nullable - public Boolean eval(Object attributeValue) { try { - return castToValueType(attributeValue, value).contains(value); + return attributeValue.toString().contains(conditionValue.toString()); } catch (Exception e) { return null; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnexpectedValueTypeException.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnexpectedValueTypeException.java index cf513bc7d..39cde7a21 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnexpectedValueTypeException.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnexpectedValueTypeException.java @@ -17,6 +17,10 @@ package com.optimizely.ab.config.audience.match; +/** + * UnexpectedValueTypeException is thrown when the condition value found in the datafile is + * not one of an expected type for this version of the SDK. + */ public class UnexpectedValueTypeException extends Exception { private static String message = "has an unsupported condition value. You may need to upgrade to a newer release of the Optimizely SDK."; diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnknownMatchTypeException.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnknownMatchTypeException.java index 0c5a972a7..1f371586b 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnknownMatchTypeException.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnknownMatchTypeException.java @@ -17,6 +17,9 @@ package com.optimizely.ab.config.audience.match; +/** + * UnknownMatchTypeException is thrown when the specified match type cannot be mapped via the MatchRegistry. + */ public class UnknownMatchTypeException extends Exception { private static String message = "uses an unknown match type. You may need to upgrade to a newer release of the Optimizely SDK."; diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/AttributeMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnknownValueTypeException.java similarity index 60% rename from core-api/src/main/java/com/optimizely/ab/config/audience/match/AttributeMatch.java rename to core-api/src/main/java/com/optimizely/ab/config/audience/match/UnknownValueTypeException.java index e2f413c4e..6df4ef1e1 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/AttributeMatch.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnknownValueTypeException.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018-2019, Optimizely and contributors + * Copyright 2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,20 +14,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.optimizely.ab.config.audience.match; -abstract class AttributeMatch implements Match { - T castToValueType(Object o, Object value) { - try { - if (!o.getClass().isInstance(value) && !(o instanceof Number && value instanceof Number)) { - return null; - } +package com.optimizely.ab.config.audience.match; - T rv = (T) o; +/** + * UnknownValueTypeException is thrown when the passed in value for a user attribute does + * not map to a known allowable type. + */ +public class UnknownValueTypeException extends Exception { + private static String message = "has an unsupported attribute value."; - return rv; - } catch (Exception e) { - return null; - } + public UnknownValueTypeException() { + super(message); } } diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java index 645025476..772d22ef7 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java @@ -55,11 +55,11 @@ public class AudienceConditionEvaluationTest { @Before public void initialize() { - testUserAttributes = new HashMap(); + testUserAttributes = new HashMap<>(); testUserAttributes.put("browser_type", "chrome"); testUserAttributes.put("device_type", "Android"); - testTypedUserAttributes = new HashMap(); + testTypedUserAttributes = new HashMap<>(); testTypedUserAttributes.put("is_firefox", true); testTypedUserAttributes.put("num_counts", 3.55); testTypedUserAttributes.put("num_size", 3); diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/match/ExactMatchTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/match/ExactMatchTest.java new file mode 100644 index 000000000..5f2d1d62e --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/match/ExactMatchTest.java @@ -0,0 +1,84 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import org.junit.Before; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.*; + +public class ExactMatchTest { + + private ExactMatch match; + private static final List INVALIDS = Collections.unmodifiableList(Arrays.asList(new byte[0], new Object(), null)); + + @Before + public void setUp() { + match = new ExactMatch(); + } + + @Test + public void testInvalidConditionValues() { + for (Object invalid : INVALIDS) { + try { + match.eval(invalid, "valid"); + fail("should have raised exception"); + } catch (UnexpectedValueTypeException e) { + //pass + } + } + } + + @Test + public void testMismatchClasses() throws Exception { + assertNull(match.eval(false, "false")); + assertNull(match.eval("false", null)); + } + + @Test + public void testStringMatch() throws Exception { + assertEquals(Boolean.TRUE, match.eval("", "")); + assertEquals(Boolean.TRUE, match.eval("true", "true")); + assertEquals(Boolean.FALSE, match.eval("true", "false")); + } + + @Test + public void testBooleanMatch() throws Exception { + assertEquals(Boolean.TRUE, match.eval(true, true)); + assertEquals(Boolean.TRUE, match.eval(false, false)); + assertEquals(Boolean.FALSE, match.eval(true, false)); + } + + @Test + public void testNumberMatch() throws UnexpectedValueTypeException { + assertEquals(Boolean.TRUE, match.eval(1, 1)); + assertEquals(Boolean.TRUE, match.eval(1L, 1L)); + assertEquals(Boolean.TRUE, match.eval(1.0, 1.0)); + assertEquals(Boolean.TRUE, match.eval(1, 1.0)); + assertEquals(Boolean.TRUE, match.eval(1L, 1.0)); + + assertEquals(Boolean.FALSE, match.eval(1, 2)); + assertEquals(Boolean.FALSE, match.eval(1L, 2L)); + assertEquals(Boolean.FALSE, match.eval(1.0, 2.0)); + assertEquals(Boolean.FALSE, match.eval(1, 1.1)); + assertEquals(Boolean.FALSE, match.eval(1L, 1.1)); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/match/MatchRegistryTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/match/MatchRegistryTest.java new file mode 100644 index 000000000..cb6f2059e --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/match/MatchRegistryTest.java @@ -0,0 +1,61 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import org.junit.Test; + +import static com.optimizely.ab.config.audience.match.MatchRegistry.*; +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.Assert.*; + +public class MatchRegistryTest { + + @Test + public void testDefaultMatchers() throws UnknownMatchTypeException { + assertThat(MatchRegistry.getMatch(EXACT), instanceOf(ExactMatch.class)); + assertThat(MatchRegistry.getMatch(EXISTS), instanceOf(ExistsMatch.class)); + assertThat(MatchRegistry.getMatch(GREATER_THAN), instanceOf(GTMatch.class)); + assertThat(MatchRegistry.getMatch(LESS_THAN), instanceOf(LTMatch.class)); + assertThat(MatchRegistry.getMatch(GREATER_THAN_EQ), instanceOf(GEMatch.class)); + assertThat(MatchRegistry.getMatch(LESS_THAN_EQ), instanceOf(LEMatch.class)); + assertThat(MatchRegistry.getMatch(LEGACY), instanceOf(DefaultMatchForLegacyAttributes.class)); + assertThat(MatchRegistry.getMatch(SEMVER_EQ), instanceOf(SemanticVersionEqualsMatch.class)); + assertThat(MatchRegistry.getMatch(SEMVER_GE), instanceOf(SemanticVersionGEMatch.class)); + assertThat(MatchRegistry.getMatch(SEMVER_GT), instanceOf(SemanticVersionGTMatch.class)); + assertThat(MatchRegistry.getMatch(SEMVER_LE), instanceOf(SemanticVersionLEMatch.class)); + assertThat(MatchRegistry.getMatch(SEMVER_LT), instanceOf(SemanticVersionLTMatch.class)); + assertThat(MatchRegistry.getMatch(SUBSTRING), instanceOf(SubstringMatch.class)); + } + + @Test(expected = UnknownMatchTypeException.class) + public void testUnknownMatcher() throws UnknownMatchTypeException { + MatchRegistry.getMatch("UNKNOWN"); + } + + @Test + public void testRegister() throws UnknownMatchTypeException { + class TestMatcher implements Match { + @Override + public Boolean eval(Object conditionValue, Object attributeValue) { + return null; + } + } + + MatchRegistry.register("test-matcher", new TestMatcher()); + assertThat(MatchRegistry.getMatch("test-matcher"), instanceOf(TestMatcher.class)); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/match/NumberComparatorTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/match/NumberComparatorTest.java new file mode 100644 index 000000000..19d67dd33 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/match/NumberComparatorTest.java @@ -0,0 +1,75 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.*; + +public class NumberComparatorTest { + + private static final List INVALIDS = Collections.unmodifiableList(Arrays.asList(null, "test", "", true)); + + @Test + public void testLessThan() throws UnknownValueTypeException { + assertTrue(NumberComparator.compare(0,1) < 0); + assertTrue(NumberComparator.compare(0,1.0) < 0); + assertTrue(NumberComparator.compare(0,1L) < 0); + } + + @Test + public void testGreaterThan() throws UnknownValueTypeException { + assertTrue(NumberComparator.compare(1,0) > 0); + assertTrue(NumberComparator.compare(1.0,0) > 0); + assertTrue(NumberComparator.compare(1L,0) > 0); + } + + @Test + public void testEquals() throws UnknownValueTypeException { + assertEquals(0, NumberComparator.compare(1, 1)); + assertEquals(0, NumberComparator.compare(1, 1.0)); + assertEquals(0, NumberComparator.compare(1L, 1)); + } + + @Test + public void testInvalidRight() { + for (Object invalid: INVALIDS) { + try { + NumberComparator.compare(0, invalid); + fail("should have failed for invalid object"); + } catch (UnknownValueTypeException e) { + // pass + } + } + } + + @Test + public void testInvalidLeft() { + for (Object invalid: INVALIDS) { + try { + NumberComparator.compare(invalid, 0); + fail("should have failed for invalid object"); + } catch (UnknownValueTypeException e) { + // pass + } + } + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/SemanticVersionTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/match/SemanticVersionTest.java similarity index 52% rename from core-api/src/test/java/com/optimizely/ab/config/audience/SemanticVersionTest.java rename to core-api/src/test/java/com/optimizely/ab/config/audience/match/SemanticVersionTest.java index 6d4605e54..1b819d418 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/audience/SemanticVersionTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/match/SemanticVersionTest.java @@ -14,9 +14,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.optimizely.ab.config.audience; +package com.optimizely.ab.config.audience.match; -import com.optimizely.ab.config.audience.match.SemanticVersion; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; @@ -141,108 +140,29 @@ public void semanticVersionInvalidShouldBeOfSizeLessThan3() throws Exception { } @Test - public void semanticVersionCompareTo() throws Exception { - SemanticVersion targetSV = new SemanticVersion("3.7.1"); - SemanticVersion actualSV = new SemanticVersion("3.7.1"); - assertTrue(actualSV.compare(targetSV) == 0); + public void testEquals() throws Exception { + assertEquals(0, SemanticVersion.compare("3.7.1", "3.7.1")); + assertEquals(0, SemanticVersion.compare("3.7.1", "3.7")); + assertEquals(0, SemanticVersion.compare("2.1.3+build", "2.1.3")); + assertEquals(0, SemanticVersion.compare("3.7.1-beta.1+2.3", "3.7.1-beta.1+2.3")); } @Test - public void semanticVersionCompareToActualLess() throws Exception { - SemanticVersion targetSV = new SemanticVersion("3.7.1"); - SemanticVersion actualSV = new SemanticVersion("3.7.0"); - assertTrue(actualSV.compare(targetSV) < 0); + public void testLessThan() throws Exception { + assertTrue(SemanticVersion.compare("3.7.0", "3.7.1") < 0); + assertTrue(SemanticVersion.compare("3.7", "3.7.1") < 0); + assertTrue(SemanticVersion.compare("2.1.3-beta+1", "2.1.3-beta+1.2.3") < 0); + assertTrue(SemanticVersion.compare("2.1.3-beta-1", "2.1.3-beta-1.2.3") < 0); } @Test - public void semanticVersionCompareToActualGreater() throws Exception { - SemanticVersion targetSV = new SemanticVersion("3.7.1"); - SemanticVersion actualSV = new SemanticVersion("3.7.2"); - assertTrue(actualSV.compare(targetSV) > 0); - } - - @Test - public void semanticVersionCompareToPatchMissing() throws Exception { - SemanticVersion targetSV = new SemanticVersion("3.7"); - SemanticVersion actualSV = new SemanticVersion("3.7.1"); - assertTrue(actualSV.compare(targetSV) == 0); - } - - @Test - public void semanticVersionCompareToActualPatchMissing() throws Exception { - SemanticVersion targetSV = new SemanticVersion("3.7.1"); - SemanticVersion actualSV = new SemanticVersion("3.7"); - assertTrue(actualSV.compare(targetSV) < 0); - } - - @Test - public void semanticVersionCompareToActualPreReleaseMissing() throws Exception { - SemanticVersion targetSV = new SemanticVersion("3.7.1-beta"); - SemanticVersion actualSV = new SemanticVersion("3.7.1"); - assertTrue(actualSV.compare(targetSV) > 0); - } - - @Test - public void semanticVersionCompareTargetBetaComplex() throws Exception { - SemanticVersion targetSV = new SemanticVersion("2.1.3-beta+1"); - SemanticVersion actualSV = new SemanticVersion("2.1.3-beta+1.2.3"); - assertTrue(actualSV.compare(targetSV) > 0); - } - - @Test - public void semanticVersionCompareTargetBuildIgnores() throws Exception { - SemanticVersion targetSV = new SemanticVersion("2.1.3"); - SemanticVersion actualSV = new SemanticVersion("2.1.3+build"); - assertTrue(actualSV.compare(targetSV) == 0); - } - - @Test - public void semanticVersionCompareTargetBuildComplex() throws Exception { - SemanticVersion targetSV = new SemanticVersion("2.1.3-beta+1.2.3"); - SemanticVersion actualSV = new SemanticVersion("2.1.3-beta+1"); - assertTrue(actualSV.compare(targetSV) < 0); - } - - @Test - public void semanticVersionCompareMultipleDash() throws Exception { - SemanticVersion targetSV = new SemanticVersion("2.1.3-beta-1.2.3"); - SemanticVersion actualSV = new SemanticVersion("2.1.3-beta-1"); - assertTrue(actualSV.compare(targetSV) < 0); - } - - @Test - public void semanticVersionCompareToAlphaBetaAsciiComparision() throws Exception { - SemanticVersion targetSV = new SemanticVersion("3.7.1-alpha"); - SemanticVersion actualSV = new SemanticVersion("3.7.1-beta"); - assertTrue(actualSV.compare(targetSV) > 0); - } - - @Test - public void semanticVersionComparePrereleaseSmallerThanBuild() throws Exception { - SemanticVersion targetSV = new SemanticVersion("3.7.1-prerelease"); - SemanticVersion actualSV = new SemanticVersion("3.7.1+build"); - assertTrue(actualSV.compare(targetSV) > 0); - } - - - @Test - public void semanticVersionCompareAgainstPreReleaseToPreRelease() throws Exception { - SemanticVersion targetSV = new SemanticVersion("3.7.1-prerelease+build"); - SemanticVersion actualSV = new SemanticVersion("3.7.1-prerelease-prerelease+rc"); - assertTrue(actualSV.compare(targetSV) > 0); - } - - @Test - public void semanticVersionCompareToIgnoreMetaComparision() throws Exception { - SemanticVersion targetSV = new SemanticVersion("3.7.1-beta.1+2.3"); - SemanticVersion actualSV = new SemanticVersion("3.7.1-beta.1+2.3"); - assertTrue(actualSV.compare(targetSV) == 0); - } - - @Test - public void semanticVersionCompareToPreReleaseComparision() throws Exception { - SemanticVersion targetSV = new SemanticVersion("3.7.1-beta.1"); - SemanticVersion actualSV = new SemanticVersion("3.7.1-beta.2"); - assertTrue(actualSV.compare(targetSV) > 0); + public void testGreaterThan() throws Exception { + assertTrue(SemanticVersion.compare("3.7.2", "3.7.1") > 0); + assertTrue(SemanticVersion.compare("3.7.1", "3.7.1-beta") > 0); + assertTrue(SemanticVersion.compare("2.1.3-beta+1.2.3", "2.1.3-beta+1") > 0); + assertTrue(SemanticVersion.compare("3.7.1-beta", "3.7.1-alpha") > 0); + assertTrue(SemanticVersion.compare("3.7.1+build", "3.7.1-prerelease") > 0); + assertTrue(SemanticVersion.compare("3.7.1-prerelease-prerelease+rc", "3.7.1-prerelease+build") > 0); + assertTrue(SemanticVersion.compare("3.7.1-beta.2", "3.7.1-beta.1") > 0); } } diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/match/SubstringMatchTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/match/SubstringMatchTest.java new file mode 100644 index 000000000..0d417eefe --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/match/SubstringMatchTest.java @@ -0,0 +1,65 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import org.junit.Before; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.*; + +public class SubstringMatchTest { + + private SubstringMatch match; + private static final List INVALIDS = Collections.unmodifiableList(Arrays.asList(new byte[0], new Object(), null)); + + @Before + public void setUp() { + match = new SubstringMatch(); + } + + @Test + public void testInvalidConditionValues() { + for (Object invalid : INVALIDS) { + try { + match.eval(invalid, "valid"); + fail("should have raised exception"); + } catch (UnexpectedValueTypeException e) { + //pass + } + } + } + + @Test + public void testInvalidAttributesValues() throws UnexpectedValueTypeException { + for (Object invalid : INVALIDS) { + assertNull(match.eval("valid", invalid)); + } + } + + @Test + public void testStringMatch() throws Exception { + assertEquals(Boolean.TRUE, match.eval("", "any")); + assertEquals(Boolean.TRUE, match.eval("same", "same")); + assertEquals(Boolean.TRUE, match.eval("a", "ab")); + assertEquals(Boolean.FALSE, match.eval("ab", "a")); + assertEquals(Boolean.FALSE, match.eval("a", "b")); + } +} From 2d88a3264a8ae520e73b8507d8d4cf33d1538c71 Mon Sep 17 00:00:00 2001 From: mnoman09 Date: Fri, 25 Sep 2020 02:04:03 +0500 Subject: [PATCH 025/147] Fix: logging issue in quick-start application (#402) --- java-quickstart/build.gradle | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/java-quickstart/build.gradle b/java-quickstart/build.gradle index 8f16c3100..28f9bd7f4 100644 --- a/java-quickstart/build.gradle +++ b/java-quickstart/build.gradle @@ -4,7 +4,8 @@ dependencies { compile group: 'com.google.code.gson', name: 'gson', version: '2.8.6' compile group: 'org.apache.httpcomponents', name: 'httpclient', version: '4.5.12' - compile group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: '2.13.3' + compile group: 'org.slf4j', name: 'slf4j-simple', version: '1.7.30' + testCompile group: 'junit', name: 'junit', version: '4.12' } From 02471e38bb2a06b49ab2e6445a8955aefe869920 Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Wed, 30 Sep 2020 11:21:32 -0700 Subject: [PATCH 026/147] chore: 3.6 release (#403) * update changelog * update changelog with all relevent PRs * move library update to fix --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93d14c7da..121b920b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Optimizely Java X SDK Changelog +## [3.6.0] +September 30th, 2020 + +### New Features +- Add support for version audience condition which follows the semantic version (http://semver.org)[#386](https://github.com/optimizely/java-sdk/pull/386). + +- Add support for datafile accessor [#392](https://github.com/optimizely/java-sdk/pull/392). + +- Audience logging refactor (move from info to debug) [#380](https://github.com/optimizely/java-sdk/pull/380). + +- Added SetDatafileAccessToken method in OptimizelyFactory [#384](https://github.com/optimizely/java-sdk/pull/384). + +- Add MatchRegistry for custom match implementations. [#390] (https://github.com/optimizely/java-sdk/pull/390). + +### Fixes: +- logging issue in quick-start application [#402] (https://github.com/optimizely/java-sdk/pull/402). + +- Update libraries to latest to avoid vulnerability issues [#397](https://github.com/optimizely/java-sdk/pull/397). + ## [3.5.0] July 7th, 2020 From d3ff6cd0059b4a523facd4029b7cad3ffb6d69d2 Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Wed, 30 Sep 2020 16:51:00 -0700 Subject: [PATCH 027/147] update the gradle plugin for bintray. The old plugin failed when we upgraded to gradle 6.5 (#404) --- build.gradle | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/build.gradle b/build.gradle index 91b68e235..1585e6885 100644 --- a/build.gradle +++ b/build.gradle @@ -1,16 +1,3 @@ -buildscript { - repositories { - jcenter() - maven { - url "https://oss.sonatype.org/content/repositories/snapshots/" - } - } - - dependencies { - classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.6' - } -} - plugins { id 'com.github.kt3k.coveralls' version '2.8.2' id 'jacoco' @@ -18,6 +5,7 @@ plugins { id 'nebula.optional-base' version '3.2.0' id 'com.github.hierynomus.license' version '0.15.0' id 'com.github.spotbugs' version "4.5.0" + id "com.jfrog.bintray" version "1.8.5" } allprojects { @@ -65,12 +53,12 @@ subprojects { } task sourcesJar(type: Jar, dependsOn: classes) { - classifier = 'sources' + archiveClassifier.set('sources') from sourceSets.main.allSource } task javadocJar(type: Jar, dependsOn: javadoc) { - classifier = 'javadoc' + archiveClassifier.set('javadoc') from javadoc.destinationDir } From dcbf9b0be6e04038658be1542c7232126394ab78 Mon Sep 17 00:00:00 2001 From: mnoman09 Date: Fri, 23 Oct 2020 22:46:04 +0500 Subject: [PATCH 028/147] feat(flag-decisions): Add support for sending flag decisions along with decision metadata. (#405) --- .../java/com/optimizely/ab/Optimizely.java | 72 ++++++++--- .../ab/config/DatafileProjectConfig.java | 7 ++ .../optimizely/ab/config/ProjectConfig.java | 2 + .../parser/DatafileGsonDeserializer.java | 7 +- .../parser/DatafileJacksonDeserializer.java | 7 +- .../ab/config/parser/JsonConfigParser.java | 4 + .../config/parser/JsonSimpleConfigParser.java | 4 + .../ab/event/internal/EventFactory.java | 3 +- .../ab/event/internal/ImpressionEvent.java | 19 ++- .../ab/event/internal/UserEventFactory.java | 51 ++++++-- .../ab/event/internal/payload/Decision.java | 18 ++- .../internal/payload/DecisionMetadata.java | 113 ++++++++++++++++++ .../com/optimizely/ab/OptimizelyTest.java | 60 +++++++++- .../ab/config/ValidProjectConfigV4.java | 4 +- .../ab/event/internal/EventFactoryTest.java | 18 ++- .../event/internal/UserEventFactoryTest.java | 25 +++- .../OptimizelyConfigServiceTest.java | 1 + .../config/valid-project-config-v4.json | 1 + 18 files changed, 369 insertions(+), 47 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index e32a39cb5..c3e035e2c 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -216,31 +216,65 @@ private Variation activate(@Nullable ProjectConfig projectConfig, return null; } - sendImpression(projectConfig, experiment, userId, copiedAttributes, variation); + sendImpression(projectConfig, experiment, userId, copiedAttributes, variation, "experiment"); return variation; } + /** + * Creates and sends impression event. + * + * @param projectConfig the current projectConfig + * @param experiment the experiment user bucketed into and dispatch an impression event + * @param userId the ID of the user + * @param filteredAttributes the attributes of the user + * @param variation the variation that was returned from activate. + * @param ruleType It can either be experiment in case impression event is sent from activate or it's feature-test or rollout + */ private void sendImpression(@Nonnull ProjectConfig projectConfig, @Nonnull Experiment experiment, @Nonnull String userId, @Nonnull Map filteredAttributes, - @Nonnull Variation variation) { - if (!experiment.isRunning()) { - logger.info("Experiment has \"Launched\" status so not dispatching event during activation."); - return; - } + @Nonnull Variation variation, + @Nonnull String ruleType) { + sendImpression(projectConfig, experiment, userId, filteredAttributes, variation, "", ruleType); + } + + /** + * Creates and sends impression event. + * + * @param projectConfig the current projectConfig + * @param experiment the experiment user bucketed into and dispatch an impression event + * @param userId the ID of the user + * @param filteredAttributes the attributes of the user + * @param variation the variation that was returned from activate. + * @param flagKey It can either be empty if ruleType is experiment or it's feature key in case ruleType is feature-test or rollout + * @param ruleType It can either be experiment in case impression event is sent from activate or it's feature-test or rollout + */ + private void sendImpression(@Nonnull ProjectConfig projectConfig, + @Nonnull Experiment experiment, + @Nonnull String userId, + @Nonnull Map filteredAttributes, + @Nonnull Variation variation, + @Nonnull String flagKey, + @Nonnull String ruleType) { UserEvent userEvent = UserEventFactory.createImpressionEvent( projectConfig, experiment, variation, userId, - filteredAttributes); + filteredAttributes, + flagKey, + ruleType); + if (userEvent == null) { + return; + } eventProcessor.process(userEvent); - logger.info("Activating user \"{}\" in experiment \"{}\".", userId, experiment.getKey()); - + if (experiment != null) { + logger.info("Activating user \"{}\" in experiment \"{}\".", userId, experiment.getKey()); + } // Kept For backwards compatibility. // This notification is deprecated and the new DecisionNotifications // are sent via their respective method calls. @@ -386,16 +420,22 @@ private Boolean isFeatureEnabled(@Nonnull ProjectConfig projectConfig, FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, copiedAttributes, projectConfig); Boolean featureEnabled = false; SourceInfo sourceInfo = new RolloutSourceInfo(); + if (featureDecision.decisionSource != null) { + decisionSource = featureDecision.decisionSource; + } + sendImpression( + projectConfig, + featureDecision.experiment, + userId, + copiedAttributes, + featureDecision.variation, + featureKey, + decisionSource.toString()); if (featureDecision.variation != null) { + // This information is only necessary for feature tests. + // For rollouts experiments and variations are an implementation detail only. if (featureDecision.decisionSource.equals(FeatureDecision.DecisionSource.FEATURE_TEST)) { - sendImpression( - projectConfig, - featureDecision.experiment, - userId, - copiedAttributes, - featureDecision.variation); - decisionSource = featureDecision.decisionSource; sourceInfo = new FeatureTestSourceInfo(featureDecision.experiment.getKey(), featureDecision.variation.getKey()); } else { logger.info("The user \"{}\" is not included in an experiment for feature \"{}\".", diff --git a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java index 0b188f064..786d13a2c 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java @@ -61,6 +61,7 @@ public class DatafileProjectConfig implements ProjectConfig { private final String revision; private final String version; private final boolean anonymizeIP; + private final boolean sendFlagDecisions; private final Boolean botFiltering; private final List attributes; private final List audiences; @@ -103,6 +104,7 @@ public DatafileProjectConfig(String accountId, String projectId, String version, this( accountId, anonymizeIP, + false, null, projectId, revision, @@ -121,6 +123,7 @@ public DatafileProjectConfig(String accountId, String projectId, String version, // v4 constructor public DatafileProjectConfig(String accountId, boolean anonymizeIP, + boolean sendFlagDecisions, Boolean botFiltering, String projectId, String revision, @@ -139,6 +142,7 @@ public DatafileProjectConfig(String accountId, this.version = version; this.revision = revision; this.anonymizeIP = anonymizeIP; + this.sendFlagDecisions = sendFlagDecisions; this.botFiltering = botFiltering; this.attributes = Collections.unmodifiableList(attributes); @@ -322,6 +326,9 @@ public String getRevision() { return revision; } + @Override + public boolean getSendFlagDecisions() { return sendFlagDecisions; } + @Override public boolean getAnonymizeIP() { return anonymizeIP; diff --git a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java index 7f83e30b8..5a85fbd4e 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java @@ -55,6 +55,8 @@ Experiment getExperimentForKey(@Nonnull String experimentKey, String getRevision(); + boolean getSendFlagDecisions(); + boolean getAnonymizeIP(); Boolean getBotFiltering(); diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileGsonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileGsonDeserializer.java index f0b0f50bd..99ab71b78 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileGsonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileGsonDeserializer.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2019, Optimizely and contributors + * Copyright 2016-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -83,9 +83,11 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa anonymizeIP = jsonObject.get("anonymizeIP").getAsBoolean(); } + List featureFlags = null; List rollouts = null; Boolean botFiltering = null; + boolean sendFlagDecisions = false; if (datafileVersion >= Integer.parseInt(DatafileProjectConfig.Version.V4.toString())) { Type featureFlagsType = new TypeToken>() { }.getType(); @@ -95,11 +97,14 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa rollouts = context.deserialize(jsonObject.get("rollouts").getAsJsonArray(), rolloutsType); if (jsonObject.has("botFiltering")) botFiltering = jsonObject.get("botFiltering").getAsBoolean(); + if (jsonObject.has("sendFlagDecisions")) + sendFlagDecisions = jsonObject.get("sendFlagDecisions").getAsBoolean(); } return new DatafileProjectConfig( accountId, anonymizeIP, + sendFlagDecisions, botFiltering, projectId, revision, diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileJacksonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileJacksonDeserializer.java index ab0455d9c..06ae5b1a9 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileJacksonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileJacksonDeserializer.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2019, Optimizely and contributors + * Copyright 2016-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -64,17 +64,22 @@ public DatafileProjectConfig deserialize(JsonParser parser, DeserializationConte List featureFlags = null; List rollouts = null; Boolean botFiltering = null; + boolean sendFlagDecisions = false; if (datafileVersion >= Integer.parseInt(DatafileProjectConfig.Version.V4.toString())) { featureFlags = JacksonHelpers.arrayNodeToList(node.get("featureFlags"), FeatureFlag.class, codec); rollouts = JacksonHelpers.arrayNodeToList(node.get("rollouts"), Rollout.class, codec); if (node.hasNonNull("botFiltering")) { botFiltering = node.get("botFiltering").asBoolean(); } + if (node.hasNonNull("sendFlagDecisions")) { + sendFlagDecisions = node.get("sendFlagDecisions").asBoolean(); + } } return new DatafileProjectConfig( accountId, anonymizeIP, + sendFlagDecisions, botFiltering, projectId, revision, diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java index ad0d971bd..5f707cb69 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java @@ -73,16 +73,20 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse List featureFlags = null; List rollouts = null; Boolean botFiltering = null; + boolean sendFlagDecisions = false; if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { featureFlags = parseFeatureFlags(rootObject.getJSONArray("featureFlags")); rollouts = parseRollouts(rootObject.getJSONArray("rollouts")); if (rootObject.has("botFiltering")) botFiltering = rootObject.getBoolean("botFiltering"); + if (rootObject.has("sendFlagDecisions")) + sendFlagDecisions = rootObject.getBoolean("sendFlagDecisions"); } return new DatafileProjectConfig( accountId, anonymizeIP, + sendFlagDecisions, botFiltering, projectId, revision, diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java index b6236ffa7..b5238a356 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java @@ -80,16 +80,20 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse List featureFlags = null; List rollouts = null; Boolean botFiltering = null; + boolean sendFlagDecisions = false; if (datafileVersion >= Integer.parseInt(DatafileProjectConfig.Version.V4.toString())) { featureFlags = parseFeatureFlags((JSONArray) rootObject.get("featureFlags")); rollouts = parseRollouts((JSONArray) rootObject.get("rollouts")); if (rootObject.containsKey("botFiltering")) botFiltering = (Boolean) rootObject.get("botFiltering"); + if (rootObject.containsKey("sendFlagDecisions")) + sendFlagDecisions = (Boolean) rootObject.get("sendFlagDecisions"); } return new DatafileProjectConfig( accountId, anonymizeIP, + sendFlagDecisions, botFiltering, projectId, revision, diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java b/core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java index 0aee045c5..5a881128d 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2019, Optimizely and contributors + * Copyright 2016-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -99,6 +99,7 @@ private static Visitor createVisitor(ImpressionEvent impressionEvent) { .setCampaignId(impressionEvent.getLayerId()) .setExperimentId(impressionEvent.getExperimentId()) .setVariationId(impressionEvent.getVariationId()) + .setMetadata(impressionEvent.getMetadata()) .setIsCampaignHoldback(false) .build(); diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/ImpressionEvent.java b/core-api/src/main/java/com/optimizely/ab/event/internal/ImpressionEvent.java index 510069fa2..38f9dc905 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/ImpressionEvent.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/ImpressionEvent.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019, Optimizely and contributors + * Copyright 2019-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,8 @@ */ package com.optimizely.ab.event.internal; +import com.optimizely.ab.event.internal.payload.DecisionMetadata; + import java.util.StringJoiner; /** @@ -28,19 +30,22 @@ public class ImpressionEvent extends BaseEvent implements UserEvent { private final String experimentKey; private final String variationKey; private final String variationId; + private final DecisionMetadata metadata; private ImpressionEvent(UserContext userContext, String layerId, String experimentId, String experimentKey, String variationKey, - String variationId) { + String variationId, + DecisionMetadata metadata) { this.userContext = userContext; this.layerId = layerId; this.experimentId = experimentId; this.experimentKey = experimentKey; this.variationKey = variationKey; this.variationId = variationId; + this.metadata = metadata; } @Override @@ -68,6 +73,8 @@ public String getVariationId() { return variationId; } + public DecisionMetadata getMetadata() { return metadata; } + public static class Builder { private UserContext userContext; @@ -76,6 +83,7 @@ public static class Builder { private String experimentKey; private String variationKey; private String variationId; + private DecisionMetadata metadata; public Builder withUserContext(UserContext userContext) { this.userContext = userContext; @@ -107,8 +115,13 @@ public Builder withVariationId(String variationId) { return this; } + public Builder withMetadata(DecisionMetadata metadata) { + this.metadata = metadata; + return this; + } + public ImpressionEvent build() { - return new ImpressionEvent(userContext, layerId, experimentId, experimentKey, variationKey, variationId); + return new ImpressionEvent(userContext, layerId, experimentId, experimentKey, variationKey, variationId, metadata); } } diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java b/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java index da741979c..74457922e 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2019, Optimizely and contributors + * Copyright 2016-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,24 +16,47 @@ */ package com.optimizely.ab.event.internal; +import com.optimizely.ab.bucketing.FeatureDecision; import com.optimizely.ab.config.Experiment; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.Variation; +import com.optimizely.ab.event.internal.payload.DecisionMetadata; import com.optimizely.ab.internal.EventTagUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import java.util.Map; -import java.util.UUID; public class UserEventFactory { private static final Logger logger = LoggerFactory.getLogger(UserEventFactory.class); public static ImpressionEvent createImpressionEvent(@Nonnull ProjectConfig projectConfig, - @Nonnull Experiment activatedExperiment, - @Nonnull Variation variation, + @Nullable Experiment activatedExperiment, + @Nullable Variation variation, @Nonnull String userId, - @Nonnull Map attributes) { + @Nonnull Map attributes, + @Nonnull String flagKey, + @Nonnull String ruleType) { + + if ((FeatureDecision.DecisionSource.ROLLOUT.toString().equals(ruleType) || variation == null) && !projectConfig.getSendFlagDecisions()) + { + return null; + } + + String variationKey = ""; + String variationID = ""; + String layerID = null; + String experimentId = null; + String experimentKey = ""; + + if (variation != null) { + variationKey = variation.getKey(); + variationID = variation.getId(); + layerID = activatedExperiment.getLayerId(); + experimentId = activatedExperiment.getId(); + experimentKey = activatedExperiment.getKey(); + } UserContext userContext = new UserContext.Builder() .withUserId(userId) @@ -41,13 +64,21 @@ public static ImpressionEvent createImpressionEvent(@Nonnull ProjectConfig proje .withProjectConfig(projectConfig) .build(); + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey(experimentKey) + .setRuleType(ruleType) + .setVariationKey(variationKey) + .build(); + return new ImpressionEvent.Builder() .withUserContext(userContext) - .withLayerId(activatedExperiment.getLayerId()) - .withExperimentId(activatedExperiment.getId()) - .withExperimentKey(activatedExperiment.getKey()) - .withVariationId(variation.getId()) - .withVariationKey(variation.getKey()) + .withLayerId(layerID) + .withExperimentId(experimentId) + .withExperimentKey(experimentKey) + .withVariationId(variationID) + .withVariationKey(variationKey) + .withMetadata(metadata) .build(); } diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Decision.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Decision.java index 47eb3a790..a9e571dd1 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Decision.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Decision.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018-2019, Optimizely and contributors + * Copyright 2018-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,16 +28,19 @@ public class Decision { String variationId; @JsonProperty("is_campaign_holdback") boolean isCampaignHoldback; + @JsonProperty("metadata") + DecisionMetadata metadata; @VisibleForTesting public Decision() { } - public Decision(String campaignId, String experimentId, String variationId, boolean isCampaignHoldback) { + public Decision(String campaignId, String experimentId, String variationId, boolean isCampaignHoldback, DecisionMetadata metadata) { this.campaignId = campaignId; this.experimentId = experimentId; this.variationId = variationId; this.isCampaignHoldback = isCampaignHoldback; + this.metadata = metadata; } public String getCampaignId() { @@ -56,6 +59,8 @@ public boolean getIsCampaignHoldback() { return isCampaignHoldback; } + public DecisionMetadata getMetadata() { return metadata; } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -74,6 +79,7 @@ public int hashCode() { int result = campaignId.hashCode(); result = 31 * result + experimentId.hashCode(); result = 31 * result + variationId.hashCode(); + result = 31 * result + metadata.hashCode(); result = 31 * result + (isCampaignHoldback ? 1 : 0); return result; } @@ -84,6 +90,7 @@ public static class Builder { private String experimentId; private String variationId; private boolean isCampaignHoldback; + private DecisionMetadata metadata; public Builder setCampaignId(String campaignId) { this.campaignId = campaignId; @@ -95,6 +102,11 @@ public Builder setExperimentId(String experimentId) { return this; } + public Builder setMetadata(DecisionMetadata metadata) { + this.metadata = metadata; + return this; + } + public Builder setVariationId(String variationId) { this.variationId = variationId; return this; @@ -106,7 +118,7 @@ public Builder setIsCampaignHoldback(boolean isCampaignHoldback) { } public Decision build() { - return new Decision(campaignId, experimentId, variationId, isCampaignHoldback); + return new Decision(campaignId, experimentId, variationId, isCampaignHoldback, metadata); } } } diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java new file mode 100644 index 000000000..f5120b230 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java @@ -0,0 +1,113 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event.internal.payload; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.optimizely.ab.annotations.VisibleForTesting; + +public class DecisionMetadata { + + @JsonProperty("flag_key") + String flagKey; + @JsonProperty("rule_key") + String ruleKey; + @JsonProperty("rule_type") + String ruleType; + @JsonProperty("variation_key") + String variationKey; + + @VisibleForTesting + public DecisionMetadata() { + } + + public DecisionMetadata(String flagKey, String ruleKey, String ruleType, String variationKey) { + this.flagKey = flagKey; + this.ruleKey = ruleKey; + this.ruleType = ruleType; + this.variationKey = variationKey; + } + + public String getRuleType() { + return ruleType; + } + + public String getRuleKey() { + return ruleKey; + } + + public String getFlagKey() { + return flagKey; + } + + public String getVariationKey() { + return variationKey; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + DecisionMetadata that = (DecisionMetadata) o; + + if (!ruleType.equals(that.ruleType)) return false; + if (!ruleKey.equals(that.ruleKey)) return false; + if (!flagKey.equals(that.flagKey)) return false; + return variationKey.equals(that.variationKey); + } + + @Override + public int hashCode() { + int result = ruleType.hashCode(); + result = 31 * result + flagKey.hashCode(); + result = 31 * result + ruleKey.hashCode(); + result = 31 * result + variationKey.hashCode(); + return result; + } + + public static class Builder { + + private String ruleType; + private String ruleKey; + private String flagKey; + private String variationKey; + + public Builder setRuleType(String ruleType) { + this.ruleType = ruleType; + return this; + } + + public Builder setRuleKey(String ruleKey) { + this.ruleKey = ruleKey; + return this; + } + + public Builder setFlagKey(String flagKey) { + this.flagKey = flagKey; + return this; + } + + public Builder setVariationKey(String variationKey) { + this.variationKey = variationKey; + return this; + } + + public DecisionMetadata build() { + return new DecisionMetadata(flagKey, ruleKey, ruleType, variationKey); + } + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java index 21dcd017e..cd4c926c8 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -491,6 +491,7 @@ public void isFeatureEnabledWithExperimentKeyForced() throws Exception { assertTrue(optimizely.setForcedVariation(activatedExperiment.getKey(), testUserId, null)); assertNull(optimizely.getForcedVariation(activatedExperiment.getKey(), testUserId)); assertFalse(optimizely.isFeatureEnabled(FEATURE_FLAG_MULTI_VARIATE_FEATURE.getKey(), testUserId)); + eventHandler.expectImpression(null, "", testUserId); } /** @@ -899,11 +900,11 @@ public void activateExperimentStatusPrecedesForcedVariation() throws Exception { } /** - * Verify that {@link Optimizely#activate(String, String)} doesn't dispatch an event for an experiment with a - * "Launched" status. + * Verify that {@link Optimizely#activate(String, String)} dispatches an event for an experiment with a + * "Launched" status when SendFlagDecisions is true. */ @Test - public void activateLaunchedExperimentDoesNotDispatchEvent() throws Exception { + public void activateLaunchedExperimentDispatchesEvent() throws Exception { Experiment launchedExperiment = datafileVersion == 4 ? noAudienceProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_LAUNCHED_EXPERIMENT_KEY) : noAudienceProjectConfig.getExperiments().get(2); @@ -914,11 +915,10 @@ public void activateLaunchedExperimentDoesNotDispatchEvent() throws Exception { // Force variation to launched experiment. optimizely.setForcedVariation(launchedExperiment.getKey(), testUserId, expectedVariation.getKey()); - logbackVerifier.expectMessage(Level.INFO, - "Experiment has \"Launched\" status so not dispatching event during activation."); Variation variation = optimizely.activate(launchedExperiment.getKey(), testUserId); assertNotNull(variation); assertThat(variation.getKey(), is(expectedVariation.getKey())); + eventHandler.expectImpression(launchedExperiment.getId(), expectedVariation.getId(), testUserId); } /** @@ -1684,7 +1684,13 @@ public void getEnabledFeaturesWithListenerMultipleFeatureEnabled() throws Except List featureFlags = optimizely.getEnabledFeatures(testUserId, Collections.emptyMap()); assertEquals(2, featureFlags.size()); - // Why is there only a single impression when there are 2 enabled features? + eventHandler.expectImpression(null, "", testUserId); + eventHandler.expectImpression(null, "", testUserId); + eventHandler.expectImpression("3794675122", "589640735", testUserId); + eventHandler.expectImpression(null, "", testUserId); + eventHandler.expectImpression(null, "", testUserId); + eventHandler.expectImpression(null, "", testUserId); + eventHandler.expectImpression(null, "", testUserId); eventHandler.expectImpression("1786133852", "1619235542", testUserId); // Verify that listener being called @@ -1720,6 +1726,16 @@ public void getEnabledFeaturesWithNoFeatureEnabled() throws Exception { // Verify that listener not being called assertFalse(isListenerCalled); + + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); } @@ -1838,6 +1854,7 @@ public void isFeatureEnabledWithListenerUserInExperimentFeatureOff() throws Exce /** * Verify that the {@link Optimizely#isFeatureEnabled(String, String, Map)} * notification listener of isFeatureEnabled is called when feature is not in experiment and not in rollout + * and it dispatch event * returns false */ @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @@ -1871,6 +1888,7 @@ public void isFeatureEnabledWithListenerUserNotInExperimentAndNotInRollOut() thr "Feature \"" + validFeatureKey + "\" is enabled for user \"" + genericUserId + "\"? false" ); + eventHandler.expectImpression(null, "", genericUserId); // Verify that listener being called assertTrue(isListenerCalled); @@ -1918,6 +1936,9 @@ public void isFeatureEnabledWithListenerUserInRollOut() throws Exception { // Verify that listener being called assertTrue(isListenerCalled); assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + + eventHandler.expectImpression("3794675122", "589640735", genericUserId, Collections.singletonMap("house", "Gryffindor")); + } //======GetFeatureVariable Notification TESTS======// @@ -3169,6 +3190,7 @@ public void isFeatureEnabledReturnsFalseWhenUserIsNotBucketedIntoAnyVariation() "Feature \"" + validFeatureKey + "\" is enabled for user \"" + genericUserId + "\"? false" ); + eventHandler.expectImpression(null, "", genericUserId); verify(mockDecisionService).getVariationForFeature( eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), @@ -3215,6 +3237,7 @@ public void isFeatureEnabledReturnsTrueButDoesNotSendWhenUserIsBucketedIntoVaria "Feature \"" + validFeatureKey + "\" is enabled for user \"" + genericUserId + "\"? true" ); + eventHandler.expectImpression("3421010877", "variationId", genericUserId); verify(mockDecisionService).getVariationForFeature( eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), @@ -3288,6 +3311,7 @@ public void isFeatureEnabledTrueWhenFeatureEnabledOfVariationIsTrue() throws Exc ); assertTrue(optimizely.isFeatureEnabled(validFeatureKey, genericUserId)); + eventHandler.expectImpression("3421010877", "variationId", genericUserId); } @@ -3317,6 +3341,7 @@ public void isFeatureEnabledFalseWhenFeatureEnabledOfVariationIsFalse() throws E ); assertFalse(spyOptimizely.isFeatureEnabled(FEATURE_MULTI_VARIATE_FEATURE_KEY, genericUserId)); + eventHandler.expectImpression("3421010877", "variationId", genericUserId); } @@ -3415,6 +3440,13 @@ public void getEnabledFeatureWithValidUserId() throws Exception { List featureFlags = optimizely.getEnabledFeatures(genericUserId, Collections.emptyMap()); assertFalse(featureFlags.isEmpty()); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression("3794675122", "589640735", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression("1785077004", "1566407342", genericUserId); + eventHandler.expectImpression("828245624", "3137445031", genericUserId); + eventHandler.expectImpression("828245624", "3137445031", genericUserId); eventHandler.expectImpression("1786133852", "1619235542", genericUserId); } @@ -3432,6 +3464,13 @@ public void getEnabledFeatureWithEmptyUserId() throws Exception { List featureFlags = optimizely.getEnabledFeatures("", Collections.emptyMap()); assertFalse(featureFlags.isEmpty()); + eventHandler.expectImpression(null, "", ""); + eventHandler.expectImpression(null, "", ""); + eventHandler.expectImpression("3794675122", "589640735", ""); + eventHandler.expectImpression(null, "", ""); + eventHandler.expectImpression("1785077004", "1566407342", ""); + eventHandler.expectImpression("828245624", "3137445031", ""); + eventHandler.expectImpression("828245624", "3137445031", ""); eventHandler.expectImpression("4138322202", "1394671166", ""); } @@ -3480,6 +3519,15 @@ public void getEnabledFeatureWithMockDecisionService() throws Exception { List featureFlags = optimizely.getEnabledFeatures(genericUserId, Collections.emptyMap()); assertTrue(featureFlags.isEmpty()); + + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); } /** diff --git a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java index f50a2780e..5a922452f 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java +++ b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java @@ -1,6 +1,6 @@ /** * - * Copyright 2017-2019, Optimizely and contributors + * Copyright 2017-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -37,6 +37,7 @@ public class ValidProjectConfigV4 { private static final String PROJECT_ID = "3918735994"; private static final String REVISION = "1480511547"; private static final String VERSION = "4"; + private static final Boolean SEND_FLAG_DECISIONS = true; // attributes private static final String ATTRIBUTE_HOUSE_ID = "553339214"; @@ -1429,6 +1430,7 @@ public static ProjectConfig generateValidProjectConfigV4() { return new DatafileProjectConfig( ACCOUNT_ID, ANONYMIZE_IP, + SEND_FLAG_DECISIONS, BOT_FILTERING, PROJECT_ID, REVISION, diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java index e823d13e5..f4be1b965 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2019, Optimizely and contributors + * Copyright 2016-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,6 +23,7 @@ import com.optimizely.ab.config.*; import com.optimizely.ab.event.LogEvent; import com.optimizely.ab.event.internal.payload.Decision; +import com.optimizely.ab.event.internal.payload.DecisionMetadata; import com.optimizely.ab.event.internal.payload.EventBatch; import com.optimizely.ab.internal.ControlAttribute; import com.optimizely.ab.internal.ReservedEventKey; @@ -98,14 +99,16 @@ public void createImpressionEventPassingUserAgentAttribute() throws Exception { Variation bucketedVariation = activatedExperiment.getVariations().get(0); Attribute attribute = validProjectConfig.getAttributes().get(0); String userId = "userId"; + String ruleType = "experiment"; Map attributeMap = new HashMap(); attributeMap.put(attribute.getKey(), "value"); attributeMap.put(ControlAttribute.USER_AGENT_ATTRIBUTE.toString(), "Chrome"); - + DecisionMetadata metadata = new DecisionMetadata(activatedExperiment.getKey(), activatedExperiment.getKey(), ruleType, "variationKey"); Decision expectedDecision = new Decision.Builder() .setCampaignId(activatedExperiment.getLayerId()) .setExperimentId(activatedExperiment.getId()) .setVariationId(bucketedVariation.getId()) + .setMetadata(metadata) .setIsCampaignHoldback(false) .build(); @@ -169,10 +172,17 @@ public void createImpressionEvent() throws Exception { String userId = "userId"; Map attributeMap = Collections.singletonMap(attribute.getKey(), "value"); + DecisionMetadata decisionMetadata = new DecisionMetadata.Builder() + .setFlagKey(activatedExperiment.getKey()) + .setRuleType("experiment") + .setVariationKey(bucketedVariation.getKey()) + .build(); + Decision expectedDecision = new Decision.Builder() .setCampaignId(activatedExperiment.getLayerId()) .setExperimentId(activatedExperiment.getId()) .setVariationId(bucketedVariation.getId()) + .setMetadata(decisionMetadata) .setIsCampaignHoldback(false) .build(); @@ -1050,7 +1060,9 @@ public static LogEvent createImpressionEvent(ProjectConfig projectConfig, activatedExperiment, variation, userId, - attributes); + attributes, + activatedExperiment.getKey(), + "experiment"); return EventFactory.createLogEvent(userEvent); diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java index 83c3c79d0..87b667658 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019, Optimizely and contributors + * Copyright 2019-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ import com.optimizely.ab.config.Experiment; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.Variation; +import com.optimizely.ab.event.internal.payload.DecisionMetadata; import com.optimizely.ab.internal.ReservedEventKey; import org.junit.Before; import org.junit.Test; @@ -60,11 +61,28 @@ public class UserEventFactoryTest { private Experiment experiment; private Variation variation; + private DecisionMetadata decisionMetadata; @Before public void setUp() { experiment = new Experiment(EXPERIMENT_ID, EXPERIMENT_KEY, LAYER_ID); variation = new Variation(VARIATION_ID, VARIATION_KEY); + decisionMetadata = new DecisionMetadata("", EXPERIMENT_KEY, "experiment", VARIATION_KEY); + } + + @Test + public void createImpressionEventNull() { + + ImpressionEvent actual = UserEventFactory.createImpressionEvent( + projectConfig, + experiment, + null, + USER_ID, + ATTRIBUTES, + EXPERIMENT_KEY, + "rollout" + ); + assertNull(actual); } @Test @@ -74,7 +92,9 @@ public void createImpressionEvent() { experiment, variation, USER_ID, - ATTRIBUTES + ATTRIBUTES, + "", + "experiment" ); assertTrue(actual.getTimestamp() > 0); @@ -90,6 +110,7 @@ public void createImpressionEvent() { assertEquals(EXPERIMENT_KEY, actual.getExperimentKey()); assertEquals(VARIATION_ID, actual.getVariationId()); assertEquals(VARIATION_KEY, actual.getVariationKey()); + assertEquals(decisionMetadata, actual.getMetadata()); } @Test diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java index 94058776b..4ceb0a67c 100644 --- a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java @@ -149,6 +149,7 @@ private ProjectConfig generateOptimizelyConfig() { "2360254204", true, true, + true, "3918735994", "1480511547", "4", diff --git a/core-api/src/test/resources/config/valid-project-config-v4.json b/core-api/src/test/resources/config/valid-project-config-v4.json index 42e965967..88b5f815b 100644 --- a/core-api/src/test/resources/config/valid-project-config-v4.json +++ b/core-api/src/test/resources/config/valid-project-config-v4.json @@ -2,6 +2,7 @@ "accountId": "2360254204", "anonymizeIP": true, "botFiltering": true, + "sendFlagDecisions": true, "projectId": "3918735994", "revision": "1480511547", "version": "4", From 288036f7f70d31035b614279fcf550a155118205 Mon Sep 17 00:00:00 2001 From: Umer Mansoor Date: Tue, 27 Oct 2020 22:52:16 -0700 Subject: [PATCH 029/147] docs: Updated CONTRIBUTING.md to remove duplicate text (#408) --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ac1ec3884..640239efa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ We welcome contributions and feedback! All contributors must sign our [Contribut 3. Make sure to add tests! 4. Run `./gradlew clean check` to automatically catch potential bugs. 5. `git push` your changes to GitHub. -6. Open a PR from your fork into the master branch of the original repoOpen a PR from your fork into the master branch of the original repo +6. Open a PR from your fork into the master branch of the original repo. 7. Make sure that all unit tests are passing and that there are no merge conflicts between your branch and `master`. 8. Open a pull request from `YOUR_NAME/branch_name` to `master`. 9. A repository maintainer will review your pull request and, if all goes well, squash and merge it! From b551357503638086efb43de7fbd7ccfafe3182ef Mon Sep 17 00:00:00 2001 From: mnoman09 Date: Fri, 13 Nov 2020 05:22:47 +0500 Subject: [PATCH 030/147] feat: added enabled field in metadata. (#409) --- .../java/com/optimizely/ab/Optimizely.java | 11 +++++++---- .../ab/event/internal/UserEventFactory.java | 4 +++- .../internal/payload/DecisionMetadata.java | 18 ++++++++++++++++-- .../ab/event/internal/EventFactoryTest.java | 5 +++-- .../event/internal/UserEventFactoryTest.java | 8 +++++--- 5 files changed, 34 insertions(+), 12 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index c3e035e2c..f06af8082 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -237,7 +237,7 @@ private void sendImpression(@Nonnull ProjectConfig projectConfig, @Nonnull Map filteredAttributes, @Nonnull Variation variation, @Nonnull String ruleType) { - sendImpression(projectConfig, experiment, userId, filteredAttributes, variation, "", ruleType); + sendImpression(projectConfig, experiment, userId, filteredAttributes, variation, "", ruleType, true); } /** @@ -257,7 +257,8 @@ private void sendImpression(@Nonnull ProjectConfig projectConfig, @Nonnull Map filteredAttributes, @Nonnull Variation variation, @Nonnull String flagKey, - @Nonnull String ruleType) { + @Nonnull String ruleType, + @Nonnull boolean enabled) { UserEvent userEvent = UserEventFactory.createImpressionEvent( projectConfig, @@ -266,7 +267,8 @@ private void sendImpression(@Nonnull ProjectConfig projectConfig, userId, filteredAttributes, flagKey, - ruleType); + ruleType, + enabled); if (userEvent == null) { return; @@ -430,7 +432,8 @@ private Boolean isFeatureEnabled(@Nonnull ProjectConfig projectConfig, copiedAttributes, featureDecision.variation, featureKey, - decisionSource.toString()); + decisionSource.toString(), + featureEnabled); if (featureDecision.variation != null) { // This information is only necessary for feature tests. diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java b/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java index 74457922e..20d771033 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java @@ -37,7 +37,8 @@ public static ImpressionEvent createImpressionEvent(@Nonnull ProjectConfig proje @Nonnull String userId, @Nonnull Map attributes, @Nonnull String flagKey, - @Nonnull String ruleType) { + @Nonnull String ruleType, + @Nonnull boolean enabled) { if ((FeatureDecision.DecisionSource.ROLLOUT.toString().equals(ruleType) || variation == null) && !projectConfig.getSendFlagDecisions()) { @@ -69,6 +70,7 @@ public static ImpressionEvent createImpressionEvent(@Nonnull ProjectConfig proje .setRuleKey(experimentKey) .setRuleType(ruleType) .setVariationKey(variationKey) + .setEnabled(enabled) .build(); return new ImpressionEvent.Builder() diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java index f5120b230..8189dae72 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java @@ -29,16 +29,19 @@ public class DecisionMetadata { String ruleType; @JsonProperty("variation_key") String variationKey; + @JsonProperty("enabled") + boolean enabled; @VisibleForTesting public DecisionMetadata() { } - public DecisionMetadata(String flagKey, String ruleKey, String ruleType, String variationKey) { + public DecisionMetadata(String flagKey, String ruleKey, String ruleType, String variationKey, boolean enabled) { this.flagKey = flagKey; this.ruleKey = ruleKey; this.ruleType = ruleType; this.variationKey = variationKey; + this.enabled = enabled; } public String getRuleType() { @@ -49,6 +52,10 @@ public String getRuleKey() { return ruleKey; } + public boolean getEnabled() { + return enabled; + } + public String getFlagKey() { return flagKey; } @@ -67,6 +74,7 @@ public boolean equals(Object o) { if (!ruleType.equals(that.ruleType)) return false; if (!ruleKey.equals(that.ruleKey)) return false; if (!flagKey.equals(that.flagKey)) return false; + if (enabled != that.enabled) return false; return variationKey.equals(that.variationKey); } @@ -85,6 +93,12 @@ public static class Builder { private String ruleKey; private String flagKey; private String variationKey; + private boolean enabled; + + public Builder setEnabled(boolean enabled) { + this.enabled = enabled; + return this; + } public Builder setRuleType(String ruleType) { this.ruleType = ruleType; @@ -107,7 +121,7 @@ public Builder setVariationKey(String variationKey) { } public DecisionMetadata build() { - return new DecisionMetadata(flagKey, ruleKey, ruleType, variationKey); + return new DecisionMetadata(flagKey, ruleKey, ruleType, variationKey, enabled); } } } diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java index f4be1b965..acb0cc5a4 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java @@ -103,7 +103,7 @@ public void createImpressionEventPassingUserAgentAttribute() throws Exception { Map attributeMap = new HashMap(); attributeMap.put(attribute.getKey(), "value"); attributeMap.put(ControlAttribute.USER_AGENT_ATTRIBUTE.toString(), "Chrome"); - DecisionMetadata metadata = new DecisionMetadata(activatedExperiment.getKey(), activatedExperiment.getKey(), ruleType, "variationKey"); + DecisionMetadata metadata = new DecisionMetadata(activatedExperiment.getKey(), activatedExperiment.getKey(), ruleType, "variationKey", true); Decision expectedDecision = new Decision.Builder() .setCampaignId(activatedExperiment.getLayerId()) .setExperimentId(activatedExperiment.getId()) @@ -1062,7 +1062,8 @@ public static LogEvent createImpressionEvent(ProjectConfig projectConfig, userId, attributes, activatedExperiment.getKey(), - "experiment"); + "experiment", + true); return EventFactory.createLogEvent(userEvent); diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java index 87b667658..a7739bb73 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java @@ -67,7 +67,7 @@ public class UserEventFactoryTest { public void setUp() { experiment = new Experiment(EXPERIMENT_ID, EXPERIMENT_KEY, LAYER_ID); variation = new Variation(VARIATION_ID, VARIATION_KEY); - decisionMetadata = new DecisionMetadata("", EXPERIMENT_KEY, "experiment", VARIATION_KEY); + decisionMetadata = new DecisionMetadata("", EXPERIMENT_KEY, "experiment", VARIATION_KEY, true); } @Test @@ -80,7 +80,8 @@ public void createImpressionEventNull() { USER_ID, ATTRIBUTES, EXPERIMENT_KEY, - "rollout" + "rollout", + false ); assertNull(actual); } @@ -94,7 +95,8 @@ public void createImpressionEvent() { USER_ID, ATTRIBUTES, "", - "experiment" + "experiment", + true ); assertTrue(actual.getTimestamp() > 0); From 3b3bb9aec89da12a5d3b4b9d1f4dd094c0ba70b6 Mon Sep 17 00:00:00 2001 From: msohailhussain Date: Wed, 18 Nov 2020 11:25:06 -0800 Subject: [PATCH 031/147] fix: decision metadata enabled fix. (#410) --- .../java/com/optimizely/ab/Optimizely.java | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index f06af8082..2f63ef535 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -252,10 +252,10 @@ private void sendImpression(@Nonnull ProjectConfig projectConfig, * @param ruleType It can either be experiment in case impression event is sent from activate or it's feature-test or rollout */ private void sendImpression(@Nonnull ProjectConfig projectConfig, - @Nonnull Experiment experiment, + @Nullable Experiment experiment, @Nonnull String userId, @Nonnull Map filteredAttributes, - @Nonnull Variation variation, + @Nullable Variation variation, @Nonnull String flagKey, @Nonnull String ruleType, @Nonnull boolean enabled) { @@ -425,15 +425,6 @@ private Boolean isFeatureEnabled(@Nonnull ProjectConfig projectConfig, if (featureDecision.decisionSource != null) { decisionSource = featureDecision.decisionSource; } - sendImpression( - projectConfig, - featureDecision.experiment, - userId, - copiedAttributes, - featureDecision.variation, - featureKey, - decisionSource.toString(), - featureEnabled); if (featureDecision.variation != null) { // This information is only necessary for feature tests. @@ -448,6 +439,15 @@ private Boolean isFeatureEnabled(@Nonnull ProjectConfig projectConfig, featureEnabled = true; } } + sendImpression( + projectConfig, + featureDecision.experiment, + userId, + copiedAttributes, + featureDecision.variation, + featureKey, + decisionSource.toString(), + featureEnabled); DecisionNotification decisionNotification = DecisionNotification.newFeatureDecisionNotificationBuilder() .withUserId(userId) From db5b0e05ed14a01c15548f8e415674fd5b8ca55b Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Fri, 20 Nov 2020 13:55:42 -0800 Subject: [PATCH 032/147] chore: prepare for 3.7.0 release (#411) --- CHANGELOG.md | 9 +++++++++ core-httpclient-impl/gradle.properties | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 121b920b1..d9ff2977b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Optimizely Java X SDK Changelog +## [3.7.0] +November 20th, 2020 + +### New Features +- Add support for upcoming application-controlled introduction of tracking for non-experiment Flag decisions. ([#405](https://github.com/optimizely/java-sdk/pull/405), [#409](https://github.com/optimizely/java-sdk/pull/409), [#410](https://github.com/optimizely/java-sdk/pull/410)) + +### Fixes: +- Upgrade httpclient to 4.5.13 + ## [3.6.0] September 30th, 2020 diff --git a/core-httpclient-impl/gradle.properties b/core-httpclient-impl/gradle.properties index e02f2c2d8..72bc00d4c 100644 --- a/core-httpclient-impl/gradle.properties +++ b/core-httpclient-impl/gradle.properties @@ -1 +1 @@ -httpClientVersion = 4.5.12 +httpClientVersion = 4.5.13 From 5cffdd26c8266b1026a5cd8f6140080130804c28 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Fri, 11 Dec 2020 14:35:55 -0800 Subject: [PATCH 033/147] feat(decide): add a new set of decide apis (#406) Add a new set of Decide APIs: - define OptimizelyUserContext/OptimizelyDecision/OptimizelyDecideOption - add createUserContext API to Optimizely - add defaultDecideOption to Optimizely constructor and builder - decide/decideAll/decideForKeys/trackEvent --- .../java/com/optimizely/ab/Optimizely.java | 223 +++- .../optimizely/ab/OptimizelyUserContext.java | 199 +++ .../com/optimizely/ab/bucketing/Bucketer.java | 45 +- .../ab/bucketing/DecisionService.java | 225 +++- .../ab/config/audience/AndCondition.java | 8 +- .../config/audience/AudienceIdCondition.java | 13 +- .../ab/config/audience/Condition.java | 6 +- .../ab/config/audience/EmptyCondition.java | 5 +- .../ab/config/audience/NotCondition.java | 10 +- .../ab/config/audience/NullCondition.java | 6 +- .../ab/config/audience/OrCondition.java | 7 +- .../ab/config/audience/UserAttribute.java | 27 +- .../internal/payload/DecisionMetadata.java | 14 + .../ab/internal/ExperimentUtils.java | 59 +- .../ab/notification/DecisionNotification.java | 101 +- .../ab/notification/NotificationCenter.java | 5 +- .../optimizelydecision/DecisionMessage.java | 34 + .../optimizelydecision/DecisionReasons.java | 29 + .../DefaultDecisionReasons.java | 54 + .../ErrorsDecisionReasons.java | 56 + .../OptimizelyDecideOption.java | 25 + .../OptimizelyDecision.java | 157 +++ .../ab/optimizelyjson/OptimizelyJSON.java | 19 + .../com/optimizely/ab/EventHandlerRule.java | 41 +- .../optimizely/ab/OptimizelyBuilderTest.java | 28 +- .../com/optimizely/ab/OptimizelyTest.java | 69 +- .../ab/OptimizelyUserContextTest.java | 1165 +++++++++++++++++ .../ab/bucketing/DecisionServiceTest.java | 148 +-- .../AudienceConditionEvaluationTest.java | 463 ++++--- .../OptimizelyDecisionTest.java | 79 ++ .../config/decide-project-config.json | 346 +++++ 31 files changed, 3198 insertions(+), 468 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java create mode 100644 core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionMessage.java create mode 100644 core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionReasons.java create mode 100644 core-api/src/main/java/com/optimizely/ab/optimizelydecision/DefaultDecisionReasons.java create mode 100644 core-api/src/main/java/com/optimizely/ab/optimizelydecision/ErrorsDecisionReasons.java create mode 100644 core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecideOption.java create mode 100644 core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecision.java create mode 100644 core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java create mode 100644 core-api/src/test/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionTest.java create mode 100644 core-api/src/test/resources/config/decide-project-config.json diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 2f63ef535..75979e335 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -34,6 +34,11 @@ import com.optimizely.ab.optimizelyconfig.OptimizelyConfig; import com.optimizely.ab.optimizelyconfig.OptimizelyConfigManager; import com.optimizely.ab.optimizelyconfig.OptimizelyConfigService; +import com.optimizely.ab.optimizelydecision.DecisionMessage; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; import com.optimizely.ab.optimizelyjson.OptimizelyJSON; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -76,7 +81,6 @@ public class Optimizely implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(Optimizely.class); - @VisibleForTesting final DecisionService decisionService; @VisibleForTesting @Deprecated @@ -86,6 +90,8 @@ public class Optimizely implements AutoCloseable { @VisibleForTesting final ErrorHandler errorHandler; + public final List defaultDecideOptions; + private final ProjectConfigManager projectConfigManager; @Nullable @@ -104,7 +110,8 @@ private Optimizely(@Nonnull EventHandler eventHandler, @Nullable UserProfileService userProfileService, @Nonnull ProjectConfigManager projectConfigManager, @Nullable OptimizelyConfigManager optimizelyConfigManager, - @Nonnull NotificationCenter notificationCenter + @Nonnull NotificationCenter notificationCenter, + @Nonnull List defaultDecideOptions ) { this.eventHandler = eventHandler; this.eventProcessor = eventProcessor; @@ -114,6 +121,7 @@ private Optimizely(@Nonnull EventHandler eventHandler, this.projectConfigManager = projectConfigManager; this.optimizelyConfigManager = optimizelyConfigManager; this.notificationCenter = notificationCenter; + this.defaultDecideOptions = defaultDecideOptions; } /** @@ -779,7 +787,6 @@ T getFeatureVariableValueForType(@Nonnull String featureKey, } // Helper method which takes type and variable value and convert it to object to use in Listener DecisionInfo object variable value - @VisibleForTesting Object convertStringToType(String variableValue, String type) { if (variableValue != null) { switch (type) { @@ -1129,6 +1136,202 @@ public OptimizelyConfig getOptimizelyConfig() { return new OptimizelyConfigService(projectConfig).getConfig(); } + //============ decide ============// + + /** + * Create a context of the user for which decision APIs will be called. + * + * A user context will be created successfully even when the SDK is not fully configured yet. + * + * @param userId The user ID to be used for bucketing. + * @param attributes: A map of attribute names to current user attribute values. + * @return An OptimizelyUserContext associated with this OptimizelyClient. + */ + public OptimizelyUserContext createUserContext(@Nonnull String userId, + @Nonnull Map attributes) { + if (userId == null) { + logger.warn("The userId parameter must be nonnull."); + return null; + } + + return new OptimizelyUserContext(this, userId, attributes); + } + + public OptimizelyUserContext createUserContext(@Nonnull String userId) { + return new OptimizelyUserContext(this, userId); + } + + OptimizelyDecision decide(@Nonnull OptimizelyUserContext user, + @Nonnull String key, + @Nonnull List options) { + + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + return OptimizelyDecision.newErrorDecision(key, user, DecisionMessage.SDK_NOT_READY.reason()); + } + + FeatureFlag flag = projectConfig.getFeatureKeyMapping().get(key); + if (flag == null) { + return OptimizelyDecision.newErrorDecision(key, user, DecisionMessage.FLAG_KEY_INVALID.reason(key)); + } + + String userId = user.getUserId(); + Map attributes = user.getAttributes(); + Boolean decisionEventDispatched = false; + List allOptions = getAllOptions(options); + DecisionReasons decisionReasons = DefaultDecisionReasons.newInstance(allOptions); + + Map copiedAttributes = new HashMap<>(attributes); + FeatureDecision flagDecision = decisionService.getVariationForFeature( + flag, + userId, + copiedAttributes, + projectConfig, + allOptions, + decisionReasons); + + Boolean flagEnabled = false; + if (flagDecision.variation != null) { + if (flagDecision.variation.getFeatureEnabled()) { + flagEnabled = true; + } + } + logger.info("Feature \"{}\" is enabled for user \"{}\"? {}", key, userId, flagEnabled); + + Map variableMap = new HashMap<>(); + if (!allOptions.contains(OptimizelyDecideOption.EXCLUDE_VARIABLES)) { + variableMap = getDecisionVariableMap( + flag, + flagDecision.variation, + flagEnabled, + decisionReasons); + } + OptimizelyJSON optimizelyJSON = new OptimizelyJSON(variableMap); + + FeatureDecision.DecisionSource decisionSource = FeatureDecision.DecisionSource.ROLLOUT; + if (flagDecision.decisionSource != null) { + decisionSource = flagDecision.decisionSource; + } + + List reasonsToReport = decisionReasons.toReport(); + String variationKey = flagDecision.variation != null ? flagDecision.variation.getKey() : null; + // TODO: add ruleKey values when available later. use a copy of experimentKey until then. + // add to event metadata as well (currently set to experimentKey) + String ruleKey = flagDecision.experiment != null ? flagDecision.experiment.getKey() : null; + + if (!allOptions.contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT)) { + sendImpression( + projectConfig, + flagDecision.experiment, + userId, + copiedAttributes, + flagDecision.variation, + key, + decisionSource.toString(), + flagEnabled); + decisionEventDispatched = true; + } + + DecisionNotification decisionNotification = DecisionNotification.newFlagDecisionNotificationBuilder() + .withUserId(userId) + .withAttributes(copiedAttributes) + .withFlagKey(key) + .withEnabled(flagEnabled) + .withVariables(variableMap) + .withVariationKey(variationKey) + .withRuleKey(ruleKey) + .withReasons(reasonsToReport) + .withDecisionEventDispatched(decisionEventDispatched) + .build(); + notificationCenter.send(decisionNotification); + + return new OptimizelyDecision( + variationKey, + flagEnabled, + optimizelyJSON, + ruleKey, + key, + user, + reasonsToReport); + } + + Map decideForKeys(@Nonnull OptimizelyUserContext user, + @Nonnull List keys, + @Nonnull List options) { + Map decisionMap = new HashMap<>(); + + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing isFeatureEnabled call."); + return decisionMap; + } + + if (keys.isEmpty()) return decisionMap; + + List allOptions = getAllOptions(options); + + for (String key : keys) { + OptimizelyDecision decision = decide(user, key, options); + if (!allOptions.contains(OptimizelyDecideOption.ENABLED_FLAGS_ONLY) || decision.getEnabled()) { + decisionMap.put(key, decision); + } + } + + return decisionMap; + } + + Map decideAll(@Nonnull OptimizelyUserContext user, + @Nonnull List options) { + Map decisionMap = new HashMap<>(); + + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing isFeatureEnabled call."); + return decisionMap; + } + + List allFlags = projectConfig.getFeatureFlags(); + List allFlagKeys = new ArrayList<>(); + for (int i = 0; i < allFlags.size(); i++) allFlagKeys.add(allFlags.get(i).getKey()); + + return decideForKeys(user, allFlagKeys, options); + } + + private List getAllOptions(List options) { + List copiedOptions = new ArrayList(defaultDecideOptions); + if (options != null) { + copiedOptions.addAll(options); + } + return copiedOptions; + } + + private Map getDecisionVariableMap(@Nonnull FeatureFlag flag, + @Nonnull Variation variation, + @Nonnull Boolean featureEnabled, + @Nonnull DecisionReasons decisionReasons) { + Map valuesMap = new HashMap(); + for (FeatureVariable variable : flag.getVariables()) { + String value = variable.getDefaultValue(); + if (featureEnabled) { + FeatureVariableUsageInstance instance = variation.getVariableIdToFeatureVariableUsageInstanceMap().get(variable.getId()); + if (instance != null) { + value = instance.getValue(); + } + } + + Object convertedValue = convertStringToType(value, variable.getType()); + if (convertedValue == null) { + decisionReasons.addError(DecisionMessage.VARIABLE_VALUE_INVALID.reason(variable.getKey())); + } else if (convertedValue instanceof OptimizelyJSON) { + convertedValue = ((OptimizelyJSON) convertedValue).toMap(); + } + + valuesMap.put(variable.getKey(), convertedValue); + } + + return valuesMap; + } + /** * Helper method which makes separate copy of attributesMap variable and returns it * @@ -1233,6 +1436,7 @@ public static class Builder { private OptimizelyConfigManager optimizelyConfigManager; private UserProfileService userProfileService; private NotificationCenter notificationCenter; + private List defaultDecideOptions; // For backwards compatibility private AtomicProjectConfigManager fallbackConfigManager = new AtomicProjectConfigManager(); @@ -1304,6 +1508,11 @@ public Builder withDatafile(String datafile) { return this; } + public Builder withDefaultDecideOptions(List defaultDecideOtions) { + this.defaultDecideOptions = defaultDecideOtions; + return this; + } + // Helper functions for making testing easier protected Builder withBucketing(Bucketer bucketer) { this.bucketer = bucketer; @@ -1372,7 +1581,13 @@ public Optimizely build() { eventProcessor = new ForwardingEventProcessor(eventHandler, notificationCenter); } - return new Optimizely(eventHandler, eventProcessor, errorHandler, decisionService, userProfileService, projectConfigManager, optimizelyConfigManager, notificationCenter); + if (defaultDecideOptions != null) { + defaultDecideOptions = Collections.unmodifiableList(defaultDecideOptions); + } else { + defaultDecideOptions = Collections.emptyList(); + } + + return new Optimizely(eventHandler, eventProcessor, errorHandler, decisionService, userProfileService, projectConfigManager, optimizelyConfigManager, notificationCenter, defaultDecideOptions); } } } diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java new file mode 100644 index 000000000..300d50d6f --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -0,0 +1,199 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class OptimizelyUserContext { + @Nonnull + private final String userId; + + @Nonnull + private final Map attributes; + + @Nonnull + private final Optimizely optimizely; + + private static final Logger logger = LoggerFactory.getLogger(OptimizelyUserContext.class); + + public OptimizelyUserContext(@Nonnull Optimizely optimizely, + @Nonnull String userId, + @Nonnull Map attributes) { + this.optimizely = optimizely; + this.userId = userId; + if (attributes != null) { + this.attributes = Collections.synchronizedMap(new HashMap<>(attributes)); + } else { + this.attributes = Collections.synchronizedMap(new HashMap<>()); + } + } + + public OptimizelyUserContext(@Nonnull Optimizely optimizely, @Nonnull String userId) { + this(optimizely, userId, Collections.EMPTY_MAP); + } + + public String getUserId() { + return userId; + } + + public Map getAttributes() { + return new HashMap(attributes); + } + + public Optimizely getOptimizely() { + return optimizely; + } + + /** + * Set an attribute for a given key. + * + * @param key An attribute key + * @param value An attribute value + */ + public void setAttribute(@Nonnull String key, @Nullable Object value) { + attributes.put(key, value); + } + + /** + * Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, which contains all data required to deliver the flag. + *
        + *
      • If the SDK finds an error, it’ll return a decision with null for variationKey. The decision will include an error message in reasons. + *
      + * @param key A flag key for which a decision will be made. + * @param options A list of options for decision-making. + * @return A decision result. + */ + public OptimizelyDecision decide(@Nonnull String key, + @Nonnull List options) { + return optimizely.decide(this, key, options); + } + + /** + * Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, which contains all data required to deliver the flag. + * + * @param key A flag key for which a decision will be made. + * @return A decision result. + */ + public OptimizelyDecision decide(@Nonnull String key) { + return decide(key, Collections.emptyList()); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for multiple flag keys and a user context. + *
        + *
      • If the SDK finds an error for a key, the response will include a decision for the key showing reasons for the error. + *
      • The SDK will always return key-mapped decisions. When it can not process requests, it’ll return an empty map after logging the errors. + *
      + * @param keys A list of flag keys for which decisions will be made. + * @param options A list of options for decision-making. + * @return All decision results mapped by flag keys. + */ + public Map decideForKeys(@Nonnull List keys, + @Nonnull List options) { + return optimizely.decideForKeys(this, keys, options); + } + + /** + * Returns a key-map of decision results for multiple flag keys and a user context. + * + * @param keys A list of flag keys for which decisions will be made. + * @return All decision results mapped by flag keys. + */ + public Map decideForKeys(@Nonnull List keys) { + return decideForKeys(keys, Collections.emptyList()); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for all active flag keys. + * + * @param options A list of options for decision-making. + * @return All decision results mapped by flag keys. + */ + public Map decideAll(@Nonnull List options) { + return optimizely.decideAll(this, options); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for all active flag keys. + * + * @return A dictionary of all decision results, mapped by flag keys. + */ + public Map decideAll() { + return decideAll(Collections.emptyList()); + } + + /** + * Track an event. + * + * @param eventName The event name. + * @param eventTags A map of event tag names to event tag values. + * @throws UnknownEventTypeException + */ + public void trackEvent(@Nonnull String eventName, + @Nonnull Map eventTags) throws UnknownEventTypeException { + optimizely.track(eventName, userId, attributes, eventTags); + } + + /** + * Track an event. + * + * @param eventName The event name. + * @throws UnknownEventTypeException + */ + public void trackEvent(@Nonnull String eventName) throws UnknownEventTypeException { + trackEvent(eventName, Collections.emptyMap()); + } + + // Utils + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) return false; + if (obj == this) return true; + OptimizelyUserContext userContext = (OptimizelyUserContext) obj; + return userId.equals(userContext.getUserId()) && + attributes.equals(userContext.getAttributes()) && + optimizely.equals(userContext.getOptimizely()); + } + + @Override + public int hashCode() { + int hash = userId.hashCode(); + hash = 31 * hash + attributes.hashCode(); + hash = 31 * hash + optimizely.hashCode(); + return hash; + } + + @Override + public String toString() { + return "OptimizelyUserContext {" + + "userId='" + userId + '\'' + + ", attributes='" + attributes + '\'' + + '}'; + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java b/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java index c9295c6bb..0429dd585 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java @@ -18,11 +18,9 @@ import com.optimizely.ab.annotations.VisibleForTesting; import com.optimizely.ab.bucketing.internal.MurmurHash3; -import com.optimizely.ab.config.Experiment; -import com.optimizely.ab.config.Group; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.TrafficAllocation; -import com.optimizely.ab.config.Variation; +import com.optimizely.ab.config.*; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -71,7 +69,8 @@ private String bucketToEntity(int bucketValue, List trafficAl private Experiment bucketToExperiment(@Nonnull Group group, @Nonnull String bucketingId, - @Nonnull ProjectConfig projectConfig) { + @Nonnull ProjectConfig projectConfig, + @Nonnull DecisionReasons reasons) { // "salt" the bucket id using the group id String bucketKey = bucketingId + group.getId(); @@ -91,7 +90,8 @@ private Experiment bucketToExperiment(@Nonnull Group group, } private Variation bucketToVariation(@Nonnull Experiment experiment, - @Nonnull String bucketingId) { + @Nonnull String bucketingId, + @Nonnull DecisionReasons reasons) { // "salt" the bucket id using the experiment id String experimentId = experiment.getId(); String experimentKey = experiment.getKey(); @@ -107,14 +107,16 @@ private Variation bucketToVariation(@Nonnull Experiment experiment, if (bucketedVariationId != null) { Variation bucketedVariation = experiment.getVariationIdToVariationMap().get(bucketedVariationId); String variationKey = bucketedVariation.getKey(); - logger.info("User with bucketingId \"{}\" is in variation \"{}\" of experiment \"{}\".", bucketingId, variationKey, + String message = reasons.addInfo("User with bucketingId \"%s\" is in variation \"%s\" of experiment \"%s\".", bucketingId, variationKey, experimentKey); + logger.info(message); return bucketedVariation; } // user was not bucketed to a variation - logger.info("User with bucketingId \"{}\" is not in any variation of experiment \"{}\".", bucketingId, experimentKey); + String message = reasons.addInfo("User with bucketingId \"%s\" is not in any variation of experiment \"%s\".", bucketingId, experimentKey); + logger.info(message); return null; } @@ -123,12 +125,15 @@ private Variation bucketToVariation(@Nonnull Experiment experiment, * * @param experiment The Experiment in which the user is to be bucketed. * @param bucketingId string A customer-assigned value used to create the key for the murmur hash. + * @param projectConfig The current projectConfig + * @param reasons Decision log messages * @return Variation the user is bucketed into or null. */ @Nullable public Variation bucket(@Nonnull Experiment experiment, @Nonnull String bucketingId, - @Nonnull ProjectConfig projectConfig) { + @Nonnull ProjectConfig projectConfig, + @Nonnull DecisionReasons reasons) { // ---------- Bucket User ---------- String groupId = experiment.getGroupId(); // check whether the experiment belongs to a group @@ -136,9 +141,10 @@ public Variation bucket(@Nonnull Experiment experiment, Group experimentGroup = projectConfig.getGroupIdMapping().get(groupId); // bucket to an experiment only if group entities are to be mutually exclusive if (experimentGroup.getPolicy().equals(Group.RANDOM_POLICY)) { - Experiment bucketedExperiment = bucketToExperiment(experimentGroup, bucketingId, projectConfig); + Experiment bucketedExperiment = bucketToExperiment(experimentGroup, bucketingId, projectConfig, reasons); if (bucketedExperiment == null) { - logger.info("User with bucketingId \"{}\" is not in any experiment of group {}.", bucketingId, experimentGroup.getId()); + String message = reasons.addInfo("User with bucketingId \"%s\" is not in any experiment of group %s.", bucketingId, experimentGroup.getId()); + logger.info(message); return null; } else { @@ -146,19 +152,27 @@ public Variation bucket(@Nonnull Experiment experiment, // if the experiment a user is bucketed in within a group isn't the same as the experiment provided, // don't perform further bucketing within the experiment if (!bucketedExperiment.getId().equals(experiment.getId())) { - logger.info("User with bucketingId \"{}\" is not in experiment \"{}\" of group {}.", bucketingId, experiment.getKey(), + String message = reasons.addInfo("User with bucketingId \"%s\" is not in experiment \"%s\" of group %s.", bucketingId, experiment.getKey(), experimentGroup.getId()); + logger.info(message); return null; } - logger.info("User with bucketingId \"{}\" is in experiment \"{}\" of group {}.", bucketingId, experiment.getKey(), + String message = reasons.addInfo("User with bucketingId \"%s\" is in experiment \"%s\" of group %s.", bucketingId, experiment.getKey(), experimentGroup.getId()); + logger.info(message); } } - return bucketToVariation(experiment, bucketingId); + return bucketToVariation(experiment, bucketingId, reasons); } + @Nullable + public Variation bucket(@Nonnull Experiment experiment, + @Nonnull String bucketingId, + @Nonnull ProjectConfig projectConfig) { + return bucket(experiment, bucketingId, projectConfig, DefaultDecisionReasons.newInstance()); + } //======== Helper methods ========// @@ -175,5 +189,4 @@ int generateBucketValue(int hashCode) { return (int) Math.floor(MAX_TRAFFIC_VALUE * ratio); } - } diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java index 13091472d..588f6eefb 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java @@ -18,19 +18,22 @@ import com.optimizely.ab.OptimizelyRuntimeException; import com.optimizely.ab.config.*; import com.optimizely.ab.error.ErrorHandler; -import com.optimizely.ab.internal.ExperimentUtils; import com.optimizely.ab.internal.ControlAttribute; - +import com.optimizely.ab.internal.ExperimentUtils; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; - import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.EXPERIMENT; import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.RULE; @@ -81,24 +84,28 @@ public DecisionService(@Nonnull Bucketer bucketer, * @param experiment The Experiment the user will be bucketed into. * @param userId The userId of the user. * @param filteredAttributes The user's attributes. This should be filtered to just attributes in the Datafile. + * @param projectConfig The current projectConfig + * @param options An array of decision options + * @param reasons Decision log messages * @return The {@link Variation} the user is allocated into. */ @Nullable public Variation getVariation(@Nonnull Experiment experiment, @Nonnull String userId, @Nonnull Map filteredAttributes, - @Nonnull ProjectConfig projectConfig) { - - if (!ExperimentUtils.isExperimentActive(experiment)) { + @Nonnull ProjectConfig projectConfig, + @Nonnull List options, + @Nonnull DecisionReasons reasons) { + if (!ExperimentUtils.isExperimentActive(experiment, reasons)) { return null; } // look for forced bucketing first. - Variation variation = getForcedVariation(experiment, userId); + Variation variation = getForcedVariation(experiment, userId, reasons); // check for whitelisting if (variation == null) { - variation = getWhitelistedVariation(experiment, userId); + variation = getWhitelistedVariation(experiment, userId, reasons); } if (variation != null) { @@ -106,42 +113,46 @@ public Variation getVariation(@Nonnull Experiment experiment, } // fetch the user profile map from the user profile service + boolean ignoreUPS = options.contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE); UserProfile userProfile = null; - if (userProfileService != null) { + if (userProfileService != null && !ignoreUPS) { try { Map userProfileMap = userProfileService.lookup(userId); if (userProfileMap == null) { - logger.info("We were unable to get a user profile map from the UserProfileService."); + String message = reasons.addInfo("We were unable to get a user profile map from the UserProfileService."); + logger.info(message); } else if (UserProfileUtils.isValidUserProfileMap(userProfileMap)) { userProfile = UserProfileUtils.convertMapToUserProfile(userProfileMap); } else { - logger.warn("The UserProfileService returned an invalid map."); + String message = reasons.addInfo("The UserProfileService returned an invalid map."); + logger.warn(message); } } catch (Exception exception) { - logger.error(exception.getMessage()); + String message = reasons.addInfo(exception.getMessage()); + logger.error(message); errorHandler.handleError(new OptimizelyRuntimeException(exception)); } - } - // check if user exists in user profile - if (userProfile != null) { - variation = getStoredVariation(experiment, userProfile, projectConfig); - // return the stored variation if it exists - if (variation != null) { - return variation; + // check if user exists in user profile + if (userProfile != null) { + variation = getStoredVariation(experiment, userProfile, projectConfig, reasons); + // return the stored variation if it exists + if (variation != null) { + return variation; + } + } else { // if we could not find a user profile, make a new one + userProfile = new UserProfile(userId, new HashMap()); } - } else { // if we could not find a user profile, make a new one - userProfile = new UserProfile(userId, new HashMap()); } - if (ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, experiment, filteredAttributes, EXPERIMENT, experiment.getKey())) { - String bucketingId = getBucketingId(userId, filteredAttributes); - variation = bucketer.bucket(experiment, bucketingId, projectConfig); + if (ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, experiment, filteredAttributes, EXPERIMENT, experiment.getKey(), reasons)) { + String bucketingId = getBucketingId(userId, filteredAttributes, reasons); + variation = bucketer.bucket(experiment, bucketingId, projectConfig, reasons); if (variation != null) { - if (userProfileService != null) { - saveVariation(experiment, variation, userProfile); + if (userProfileService != null && !ignoreUPS) { + saveVariation(experiment, variation, userProfile, reasons); } else { logger.debug("This decision will not be saved since the UserProfileService is null."); } @@ -150,46 +161,72 @@ public Variation getVariation(@Nonnull Experiment experiment, return variation; } - logger.info("User \"{}\" does not meet conditions to be in experiment \"{}\".", userId, experiment.getKey()); + String message = reasons.addInfo("User \"%s\" does not meet conditions to be in experiment \"%s\".", userId, experiment.getKey()); + logger.info(message); return null; } + @Nullable + public Variation getVariation(@Nonnull Experiment experiment, + @Nonnull String userId, + @Nonnull Map filteredAttributes, + @Nonnull ProjectConfig projectConfig) { + return getVariation(experiment, userId, filteredAttributes, projectConfig, Collections.emptyList(), DefaultDecisionReasons.newInstance()); + } + /** * Get the variation the user is bucketed into for the FeatureFlag * * @param featureFlag The feature flag the user wants to access. * @param userId User Identifier * @param filteredAttributes A map of filtered attributes. + * @param projectConfig The current projectConfig + * @param options An array of decision options + * @param reasons Decision log messages * @return {@link FeatureDecision} */ @Nonnull public FeatureDecision getVariationForFeature(@Nonnull FeatureFlag featureFlag, @Nonnull String userId, @Nonnull Map filteredAttributes, - @Nonnull ProjectConfig projectConfig) { + @Nonnull ProjectConfig projectConfig, + @Nonnull List options, + @Nonnull DecisionReasons reasons) { if (!featureFlag.getExperimentIds().isEmpty()) { for (String experimentId : featureFlag.getExperimentIds()) { Experiment experiment = projectConfig.getExperimentIdMapping().get(experimentId); - Variation variation = getVariation(experiment, userId, filteredAttributes, projectConfig); + Variation variation = getVariation(experiment, userId, filteredAttributes, projectConfig, options, reasons); if (variation != null) { return new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.FEATURE_TEST); } } } else { - logger.info("The feature flag \"{}\" is not used in any experiments.", featureFlag.getKey()); + String message = reasons.addInfo("The feature flag \"%s\" is not used in any experiments.", featureFlag.getKey()); + logger.info(message); } - FeatureDecision featureDecision = getVariationForFeatureInRollout(featureFlag, userId, filteredAttributes, projectConfig); + FeatureDecision featureDecision = getVariationForFeatureInRollout(featureFlag, userId, filteredAttributes, projectConfig, reasons); if (featureDecision.variation == null) { - logger.info("The user \"{}\" was not bucketed into a rollout for feature flag \"{}\".", + String message = reasons.addInfo("The user \"%s\" was not bucketed into a rollout for feature flag \"%s\".", userId, featureFlag.getKey()); + logger.info(message); } else { - logger.info("The user \"{}\" was bucketed into a rollout for feature flag \"{}\".", + String message = reasons.addInfo("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".", userId, featureFlag.getKey()); + logger.info(message); } return featureDecision; } + @Nonnull + public FeatureDecision getVariationForFeature(@Nonnull FeatureFlag featureFlag, + @Nonnull String userId, + @Nonnull Map filteredAttributes, + @Nonnull ProjectConfig projectConfig) { + + return getVariationForFeature(featureFlag, userId, filteredAttributes, projectConfig,Collections.emptyList(), DefaultDecisionReasons.newInstance()); + } + /** * Try to bucket the user into a rollout rule. * Evaluate the user for rules in priority order by seeing if the user satisfies the audience. @@ -198,49 +235,56 @@ public FeatureDecision getVariationForFeature(@Nonnull FeatureFlag featureFlag, * @param featureFlag The feature flag the user wants to access. * @param userId User Identifier * @param filteredAttributes A map of filtered attributes. + * @param projectConfig The current projectConfig + * @param reasons Decision log messages * @return {@link FeatureDecision} */ @Nonnull FeatureDecision getVariationForFeatureInRollout(@Nonnull FeatureFlag featureFlag, @Nonnull String userId, @Nonnull Map filteredAttributes, - @Nonnull ProjectConfig projectConfig) { + @Nonnull ProjectConfig projectConfig, + @Nonnull DecisionReasons reasons) { // use rollout to get variation for feature if (featureFlag.getRolloutId().isEmpty()) { - logger.info("The feature flag \"{}\" is not used in a rollout.", featureFlag.getKey()); + String message = reasons.addInfo("The feature flag \"%s\" is not used in a rollout.", featureFlag.getKey()); + logger.info(message); return new FeatureDecision(null, null, null); } Rollout rollout = projectConfig.getRolloutIdMapping().get(featureFlag.getRolloutId()); if (rollout == null) { - logger.error("The rollout with id \"{}\" was not found in the datafile for feature flag \"{}\".", + String message = reasons.addInfo("The rollout with id \"%s\" was not found in the datafile for feature flag \"%s\".", featureFlag.getRolloutId(), featureFlag.getKey()); + logger.error(message); return new FeatureDecision(null, null, null); } // for all rules before the everyone else rule int rolloutRulesLength = rollout.getExperiments().size(); - String bucketingId = getBucketingId(userId, filteredAttributes); + String bucketingId = getBucketingId(userId, filteredAttributes, reasons); Variation variation; for (int i = 0; i < rolloutRulesLength - 1; i++) { Experiment rolloutRule = rollout.getExperiments().get(i); - if (ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, rolloutRule, filteredAttributes, RULE, Integer.toString(i + 1))) { - variation = bucketer.bucket(rolloutRule, bucketingId, projectConfig); + if (ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, rolloutRule, filteredAttributes, RULE, Integer.toString(i + 1), reasons)) { + variation = bucketer.bucket(rolloutRule, bucketingId, projectConfig, reasons); if (variation == null) { break; } return new FeatureDecision(rolloutRule, variation, FeatureDecision.DecisionSource.ROLLOUT); } else { - logger.debug("User \"{}\" does not meet conditions for targeting rule \"{}\".", userId, i + 1); + String message = reasons.addInfo("User \"%s\" does not meet conditions for targeting rule \"%d\".", userId, i + 1); + logger.debug(message); } } // get last rule which is the fall back rule Experiment finalRule = rollout.getExperiments().get(rolloutRulesLength - 1); - if (ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, finalRule, filteredAttributes, RULE, "Everyone Else")) { - variation = bucketer.bucket(finalRule, bucketingId, projectConfig); + if (ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, finalRule, filteredAttributes, RULE, "Everyone Else", reasons)) { + variation = bucketer.bucket(finalRule, bucketingId, projectConfig, reasons); if (variation != null) { - logger.debug("User \"{}\" meets conditions for targeting rule \"Everyone Else\".", userId); + String message = reasons.addInfo("User \"%s\" meets conditions for targeting rule \"Everyone Else\".", userId); + logger.debug(message); return new FeatureDecision(finalRule, variation, FeatureDecision.DecisionSource.ROLLOUT); } @@ -248,44 +292,68 @@ FeatureDecision getVariationForFeatureInRollout(@Nonnull FeatureFlag featureFlag return new FeatureDecision(null, null, null); } + @Nonnull + FeatureDecision getVariationForFeatureInRollout(@Nonnull FeatureFlag featureFlag, + @Nonnull String userId, + @Nonnull Map filteredAttributes, + @Nonnull ProjectConfig projectConfig) { + return getVariationForFeatureInRollout(featureFlag, userId, filteredAttributes, projectConfig, DefaultDecisionReasons.newInstance()); + } + /** * Get the variation the user has been whitelisted into. * * @param experiment {@link Experiment} in which user is to be bucketed. * @param userId User Identifier + * @param reasons Decision log messages * @return null if the user is not whitelisted into any variation * {@link Variation} the user is bucketed into if the user has a specified whitelisted variation. */ @Nullable - Variation getWhitelistedVariation(@Nonnull Experiment experiment, @Nonnull String userId) { + Variation getWhitelistedVariation(@Nonnull Experiment experiment, + @Nonnull String userId, + @Nonnull DecisionReasons reasons) { // if a user has a forced variation mapping, return the respective variation Map userIdToVariationKeyMap = experiment.getUserIdToVariationKeyMap(); if (userIdToVariationKeyMap.containsKey(userId)) { String forcedVariationKey = userIdToVariationKeyMap.get(userId); Variation forcedVariation = experiment.getVariationKeyToVariationMap().get(forcedVariationKey); if (forcedVariation != null) { - logger.info("User \"{}\" is forced in variation \"{}\".", userId, forcedVariationKey); + String message = reasons.addInfo("User \"%s\" is forced in variation \"%s\".", userId, forcedVariationKey); + logger.info(message); } else { - logger.error("Variation \"{}\" is not in the datafile. Not activating user \"{}\".", + String message = reasons.addInfo("Variation \"%s\" is not in the datafile. Not activating user \"%s\".", forcedVariationKey, userId); + logger.error(message); } return forcedVariation; } return null; } + @Nullable + Variation getWhitelistedVariation(@Nonnull Experiment experiment, + @Nonnull String userId) { + + return getWhitelistedVariation(experiment, userId, DefaultDecisionReasons.newInstance()); + } + /** * Get the {@link Variation} that has been stored for the user in the {@link UserProfileService} implementation. * * @param experiment {@link Experiment} in which the user was bucketed. * @param userProfile {@link UserProfile} of the user. + * @param projectConfig The current projectConfig + * @param reasons Decision log messages * @return null if the {@link UserProfileService} implementation is null or the user was not previously bucketed. * else return the {@link Variation} the user was previously bucketed into. */ @Nullable Variation getStoredVariation(@Nonnull Experiment experiment, @Nonnull UserProfile userProfile, - @Nonnull ProjectConfig projectConfig) { + @Nonnull ProjectConfig projectConfig, + @Nonnull DecisionReasons reasons) { + // ---------- Check User Profile for Sticky Bucketing ---------- // If a user profile instance is present then check it for a saved variation String experimentId = experiment.getId(); @@ -299,35 +367,45 @@ Variation getStoredVariation(@Nonnull Experiment experiment, .getVariationIdToVariationMap() .get(variationId); if (savedVariation != null) { - logger.info("Returning previously activated variation \"{}\" of experiment \"{}\" " + - "for user \"{}\" from user profile.", + String message = reasons.addInfo("Returning previously activated variation \"%s\" of experiment \"%s\" for user \"%s\" from user profile.", savedVariation.getKey(), experimentKey, userProfile.userId); + logger.info(message); // A variation is stored for this combined bucket id return savedVariation; } else { - logger.info("User \"{}\" was previously bucketed into variation with ID \"{}\" for experiment \"{}\", " + - "but no matching variation was found for that user. We will re-bucket the user.", + String message = reasons.addInfo("User \"%s\" was previously bucketed into variation with ID \"%s\" for experiment \"%s\", but no matching variation was found for that user. We will re-bucket the user.", userProfile.userId, variationId, experimentKey); + logger.info(message); return null; } } else { - logger.info("No previously activated variation of experiment \"{}\" " + - "for user \"{}\" found in user profile.", + String message = reasons.addInfo("No previously activated variation of experiment \"%s\" for user \"%s\" found in user profile.", experimentKey, userProfile.userId); + logger.info(message); return null; } } + @Nullable + Variation getStoredVariation(@Nonnull Experiment experiment, + @Nonnull UserProfile userProfile, + @Nonnull ProjectConfig projectConfig) { + return getStoredVariation(experiment, userProfile, projectConfig, DefaultDecisionReasons.newInstance()); + } + /** * Save a {@link Variation} of an {@link Experiment} for a user in the {@link UserProfileService}. * * @param experiment The experiment the user was buck * @param variation The Variation to save. * @param userProfile A {@link UserProfile} instance of the user information. + * @param reasons Decision log messages */ void saveVariation(@Nonnull Experiment experiment, @Nonnull Variation variation, - @Nonnull UserProfile userProfile) { + @Nonnull UserProfile userProfile, + @Nonnull DecisionReasons reasons) { + // only save if the user has implemented a user profile service if (userProfileService != null) { String experimentId = experiment.getId(); @@ -353,28 +431,42 @@ void saveVariation(@Nonnull Experiment experiment, } } + void saveVariation(@Nonnull Experiment experiment, + @Nonnull Variation variation, + @Nonnull UserProfile userProfile) { + saveVariation(experiment, variation, userProfile, DefaultDecisionReasons.newInstance()); + } + /** * Get the bucketingId of a user if a bucketingId exists in attributes, or else default to userId. * * @param userId The userId of the user. * @param filteredAttributes The user's attributes. This should be filtered to just attributes in the Datafile. + * @param reasons Decision log messages * @return bucketingId if it is a String type in attributes. * else return userId */ String getBucketingId(@Nonnull String userId, - @Nonnull Map filteredAttributes) { + @Nonnull Map filteredAttributes, + @Nonnull DecisionReasons reasons) { String bucketingId = userId; if (filteredAttributes != null && filteredAttributes.containsKey(ControlAttribute.BUCKETING_ATTRIBUTE.toString())) { if (String.class.isInstance(filteredAttributes.get(ControlAttribute.BUCKETING_ATTRIBUTE.toString()))) { bucketingId = (String) filteredAttributes.get(ControlAttribute.BUCKETING_ATTRIBUTE.toString()); logger.debug("BucketingId is valid: \"{}\"", bucketingId); } else { - logger.warn("BucketingID attribute is not a string. Defaulted to userId"); + String message = reasons.addInfo("BucketingID attribute is not a string. Defaulted to userId"); + logger.warn(message); } } return bucketingId; } + String getBucketingId(@Nonnull String userId, + @Nonnull Map filteredAttributes) { + return getBucketingId(userId, filteredAttributes, DefaultDecisionReasons.newInstance()); + } + public ConcurrentHashMap> getForcedVariationMapping() { return forcedVariationMapping; } @@ -393,8 +485,6 @@ public ConcurrentHashMap> getForcedVar public boolean setForcedVariation(@Nonnull Experiment experiment, @Nonnull String userId, @Nullable String variationKey) { - - Variation variation = null; // keep in mind that you can pass in a variationKey that is null if you want to @@ -455,13 +545,14 @@ public boolean setForcedVariation(@Nonnull Experiment experiment, * * @param experiment The experiment forced. * @param userId The user ID to be used for bucketing. + * @param reasons Decision log messages * @return The variation the user was bucketed into. This value can be null if the * forced variation fails. */ @Nullable public Variation getForcedVariation(@Nonnull Experiment experiment, - @Nonnull String userId) { - + @Nonnull String userId, + @Nonnull DecisionReasons reasons) { // if the user id is invalid, return false. if (!validateUserId(userId)) { return null; @@ -473,8 +564,9 @@ public Variation getForcedVariation(@Nonnull Experiment experiment, if (variationId != null) { Variation variation = experiment.getVariationIdToVariationMap().get(variationId); if (variation != null) { - logger.debug("Variation \"{}\" is mapped to experiment \"{}\" and user \"{}\" in the forced variation map", + String message = reasons.addInfo("Variation \"%s\" is mapped to experiment \"%s\" and user \"%s\" in the forced variation map", variation.getKey(), experiment.getKey(), userId); + logger.debug(message); return variation; } } else { @@ -484,6 +576,12 @@ public Variation getForcedVariation(@Nonnull Experiment experiment, return null; } + @Nullable + public Variation getForcedVariation(@Nonnull Experiment experiment, + @Nonnull String userId) { + return getForcedVariation(experiment, userId, DefaultDecisionReasons.newInstance()); + } + /** * Helper function to check that the provided userId is valid * @@ -498,4 +596,5 @@ private boolean validateUserId(String userId) { return true; } + } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java index 8b458d059..20c15e95d 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java @@ -17,10 +17,10 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.optimizelydecision.DecisionReasons; import javax.annotation.Nonnull; import javax.annotation.Nullable; -import javax.annotation.concurrent.Immutable; import java.util.List; import java.util.Map; @@ -40,7 +40,9 @@ public List getConditions() { } @Nullable - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, + Map attributes, + DecisionReasons reasons) { if (conditions == null) return null; boolean foundNull = false; // According to the matrix where: @@ -51,7 +53,7 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { // true and true is true // null and null is null for (Condition condition : conditions) { - Boolean conditionEval = condition.evaluate(config, attributes); + Boolean conditionEval = condition.evaluate(config, attributes, reasons); if (conditionEval == null) { foundNull = true; } else if (!conditionEval) { // false with nulls or trues is false. diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java index 57a4e5bec..fb076a90d 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java @@ -18,14 +18,12 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.internal.InvalidAudienceCondition; +import com.optimizely.ab.optimizelydecision.DecisionReasons; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nullable; -import javax.annotation.concurrent.Immutable; import java.util.Map; import java.util.Objects; @@ -66,16 +64,19 @@ public String getAudienceId() { @Nullable @Override - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, + Map attributes, + DecisionReasons reasons) { if (config != null) { audience = config.getAudienceIdMapping().get(audienceId); } if (audience == null) { - logger.error("Audience {} could not be found.", audienceId); + String message = reasons.addInfo("Audience %s could not be found.", audienceId); + logger.error(message); return null; } logger.debug("Starting to evaluate audience \"{}\" with conditions: {}.", audience.getId(), audience.getConditions()); - Boolean result = audience.getConditions().evaluate(config, attributes); + Boolean result = audience.getConditions().evaluate(config, attributes, reasons); logger.debug("Audience \"{}\" evaluated to {}.", audience.getId(), result); return result; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java index 772d2b03e..4d108214c 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java @@ -17,6 +17,7 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.optimizelydecision.DecisionReasons; import javax.annotation.Nullable; import java.util.Map; @@ -27,5 +28,8 @@ public interface Condition { @Nullable - Boolean evaluate(ProjectConfig config, Map attributes); + Boolean evaluate(ProjectConfig config, + Map attributes, + DecisionReasons reasons); + } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java index 8f8aedeae..b5978d200 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java @@ -16,6 +16,7 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.optimizelydecision.DecisionReasons; import javax.annotation.Nullable; import java.util.Map; @@ -23,7 +24,9 @@ public class EmptyCondition implements Condition { @Nullable @Override - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, + Map attributes, + DecisionReasons reasons) { return true; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java index b7f45f2ac..8a523bb8d 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java @@ -17,11 +17,11 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; -import javax.annotation.Nonnull; - import java.util.Map; /** @@ -41,9 +41,11 @@ public Condition getCondition() { } @Nullable - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, + Map attributes, + DecisionReasons reasons) { - Boolean conditionEval = condition == null ? null : condition.evaluate(config, attributes); + Boolean conditionEval = condition == null ? null : condition.evaluate(config, attributes, reasons); return (conditionEval == null ? null : !conditionEval); } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java index fcf5100db..ef76d92ad 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java @@ -16,6 +16,7 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.optimizelydecision.DecisionReasons; import javax.annotation.Nullable; import java.util.Map; @@ -23,7 +24,10 @@ public class NullCondition implements Condition { @Nullable @Override - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, + Map attributes, + DecisionReasons reasons) { return null; } + } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java index 70572a9a9..b2c2f0afe 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java @@ -17,6 +17,7 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.optimizelydecision.DecisionReasons; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -45,11 +46,13 @@ public List getConditions() { // false or false is false // null or null is null @Nullable - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, + Map attributes, + DecisionReasons reasons) { if (conditions == null) return null; boolean foundNull = false; for (Condition condition : conditions) { - Boolean conditionEval = condition.evaluate(config, attributes); + Boolean conditionEval = condition.evaluate(config, attributes, reasons); if (conditionEval == null) { // true with falses and nulls is still true foundNull = true; } else if (conditionEval) { diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java b/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java index 277f2f184..152bb7048 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.audience.match.*; +import com.optimizely.ab.optimizelydecision.DecisionReasons; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -71,7 +72,9 @@ public Object getValue() { } @Nullable - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, + Map attributes, + DecisionReasons reasons) { if (attributes == null) { attributes = Collections.emptyMap(); } @@ -79,7 +82,8 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { Object userAttributeValue = attributes.get(name); if (!"custom_attribute".equals(type)) { - logger.warn("Audience condition \"{}\" uses an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK.", this); + String message = reasons.addInfo("Audience condition \"%s\" uses an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK.", this); + logger.warn(message); return null; // unknown type } // check user attribute value is equal @@ -94,26 +98,27 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { } catch(UnknownValueTypeException e) { if (!attributes.containsKey(name)) { //Missing attribute value - logger.debug("Audience condition \"{}\" evaluated to UNKNOWN because no value was passed for user attribute \"{}\"", this, name); + String message = reasons.addInfo("Audience condition \"%s\" evaluated to UNKNOWN because no value was passed for user attribute \"%s\"", this, name); + logger.debug(message); } else { //if attribute value is not valid if (userAttributeValue != null) { - logger.warn( - "Audience condition \"{}\" evaluated to UNKNOWN because a value of type \"{}\" was passed for user attribute \"{}\"", + String message = reasons.addInfo("Audience condition \"%s\" evaluated to UNKNOWN because a value of type \"%s\" was passed for user attribute \"%s\"", this, userAttributeValue.getClass().getCanonicalName(), name); + logger.warn(message); } else { - logger.debug( - "Audience condition \"{}\" evaluated to UNKNOWN because a null value was passed for user attribute \"{}\"", - this, - name); + String message = reasons.addInfo("Audience condition \"%s\" evaluated to UNKNOWN because a null value was passed for user attribute \"%s\"", this, name); + logger.debug(message); } } } catch (UnknownMatchTypeException | UnexpectedValueTypeException e) { - logger.warn("Audience condition \"{}\" " + e.getMessage(), this); + String message = reasons.addInfo("Audience condition \"%s\" " + e.getMessage(), this); + logger.warn(message); } catch (NullPointerException e) { - logger.error("attribute or value null for match {}", match != null ? match : "legacy condition", e); + String message = reasons.addInfo("attribute or value null for match %s", match != null ? match : "legacy condition"); + logger.error(message, e); } return null; } diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java index 8189dae72..aec6cdce2 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java @@ -19,6 +19,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.optimizely.ab.annotations.VisibleForTesting; +import java.util.StringJoiner; + public class DecisionMetadata { @JsonProperty("flag_key") @@ -87,6 +89,18 @@ public int hashCode() { return result; } + @Override + public String toString() { + return new StringJoiner(", ", DecisionMetadata.class.getSimpleName() + "[", "]") + .add("flagKey='" + flagKey + "'") + .add("ruleKey='" + ruleKey + "'") + .add("ruleType='" + ruleType + "'") + .add("variationKey='" + variationKey + "'") + .add("enabled=" + enabled) + .toString(); + } + + public static class Builder { private String ruleType; diff --git a/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java b/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java index f5109b624..e24cc1b6a 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java @@ -21,6 +21,8 @@ import com.optimizely.ab.config.audience.AudienceIdCondition; import com.optimizely.ab.config.audience.Condition; import com.optimizely.ab.config.audience.OrCondition; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,18 +43,25 @@ private ExperimentUtils() { * Helper method to validate all pre-conditions before bucketing a user. * * @param experiment the experiment we are validating pre-conditions for + * @param reasons Decision log messages * @return whether the pre-conditions are satisfied */ - public static boolean isExperimentActive(@Nonnull Experiment experiment) { + public static boolean isExperimentActive(@Nonnull Experiment experiment, + @Nonnull DecisionReasons reasons) { if (!experiment.isActive()) { - logger.info("Experiment \"{}\" is not running.", experiment.getKey()); + String message = reasons.addInfo("Experiment \"%s\" is not running.", experiment.getKey()); + logger.info(message); return false; } return true; } + public static boolean isExperimentActive(@Nonnull Experiment experiment) { + return isExperimentActive(experiment, DefaultDecisionReasons.newInstance()); + } + /** * Determines whether a user satisfies audience conditions for the experiment. * @@ -61,29 +70,50 @@ public static boolean isExperimentActive(@Nonnull Experiment experiment) { * @param attributes the attributes of the user * @param loggingEntityType It can be either experiment or rule. * @param loggingKey In case of loggingEntityType is experiment it will be experiment key or else it will be rule number. + * @param reasons Decision log messages * @return whether the user meets the criteria for the experiment */ public static boolean doesUserMeetAudienceConditions(@Nonnull ProjectConfig projectConfig, @Nonnull Experiment experiment, @Nonnull Map attributes, @Nonnull String loggingEntityType, - @Nonnull String loggingKey) { + @Nonnull String loggingKey, + @Nonnull DecisionReasons reasons) { if (experiment.getAudienceConditions() != null) { logger.debug("Evaluating audiences for {} \"{}\": {}.", loggingEntityType, loggingKey, experiment.getAudienceConditions()); - Boolean resolveReturn = evaluateAudienceConditions(projectConfig, experiment, attributes, loggingEntityType, loggingKey); + Boolean resolveReturn = evaluateAudienceConditions(projectConfig, experiment, attributes, loggingEntityType, loggingKey, reasons); return resolveReturn == null ? false : resolveReturn; } else { - Boolean resolveReturn = evaluateAudience(projectConfig, experiment, attributes, loggingEntityType, loggingKey); + Boolean resolveReturn = evaluateAudience(projectConfig, experiment, attributes, loggingEntityType, loggingKey, reasons); return Boolean.TRUE.equals(resolveReturn); } } + /** + * Determines whether a user satisfies audience conditions for the experiment. + * + * @param projectConfig the current projectConfig + * @param experiment the experiment we are evaluating audiences for + * @param attributes the attributes of the user + * @param loggingEntityType It can be either experiment or rule. + * @param loggingKey In case of loggingEntityType is experiment it will be experiment key or else it will be rule number. + * @return whether the user meets the criteria for the experiment + */ + public static boolean doesUserMeetAudienceConditions(@Nonnull ProjectConfig projectConfig, + @Nonnull Experiment experiment, + @Nonnull Map attributes, + @Nonnull String loggingEntityType, + @Nonnull String loggingKey) { + return doesUserMeetAudienceConditions(projectConfig, experiment, attributes, loggingEntityType, loggingKey, DefaultDecisionReasons.newInstance()); + } + @Nullable public static Boolean evaluateAudience(@Nonnull ProjectConfig projectConfig, @Nonnull Experiment experiment, @Nonnull Map attributes, @Nonnull String loggingEntityType, - @Nonnull String loggingKey) { + @Nonnull String loggingKey, + @Nonnull DecisionReasons reasons) { List experimentAudienceIds = experiment.getAudienceIds(); // if there are no audiences, ALL users should be part of the experiment @@ -101,9 +131,10 @@ public static Boolean evaluateAudience(@Nonnull ProjectConfig projectConfig, logger.debug("Evaluating audiences for {} \"{}\": {}.", loggingEntityType, loggingKey, conditions); - Boolean result = implicitOr.evaluate(projectConfig, attributes); + Boolean result = implicitOr.evaluate(projectConfig, attributes, reasons); - logger.info("Audiences for {} \"{}\" collectively evaluated to {}.", loggingEntityType, loggingKey, result); + String message = reasons.addInfo("Audiences for %s \"%s\" collectively evaluated to %s.", loggingEntityType, loggingKey, result); + logger.info(message); return result; } @@ -113,20 +144,22 @@ public static Boolean evaluateAudienceConditions(@Nonnull ProjectConfig projectC @Nonnull Experiment experiment, @Nonnull Map attributes, @Nonnull String loggingEntityType, - @Nonnull String loggingKey) { + @Nonnull String loggingKey, + @Nonnull DecisionReasons reasons) { Condition conditions = experiment.getAudienceConditions(); if (conditions == null) return null; try { - Boolean result = conditions.evaluate(projectConfig, attributes); - logger.info("Audiences for {} \"{}\" collectively evaluated to {}.", loggingEntityType, loggingKey, result); + Boolean result = conditions.evaluate(projectConfig, attributes, reasons); + String message = reasons.addInfo("Audiences for %s \"%s\" collectively evaluated to %s.", loggingEntityType, loggingKey, result); + logger.info(message); return result; } catch (Exception e) { - logger.error("Condition invalid", e); + String message = reasons.addInfo("Condition invalid: %s", e.getMessage()); + logger.error(message); return null; } } - } diff --git a/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java b/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java index 0a7ea6e3c..d97e5bf40 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019, Optimizely, Inc. and contributors * + * Copyright 2019-2020, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -24,6 +24,7 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -350,4 +351,102 @@ public DecisionNotification build() { decisionInfo); } } + + public static FlagDecisionNotificationBuilder newFlagDecisionNotificationBuilder() { + return new FlagDecisionNotificationBuilder(); + } + + public static class FlagDecisionNotificationBuilder { + public final static String FLAG_KEY = "flagKey"; + public final static String ENABLED = "enabled"; + public final static String VARIABLES = "variables"; + public final static String VARIATION_KEY = "variationKey"; + public final static String RULE_KEY = "ruleKey"; + public final static String REASONS = "reasons"; + public final static String DECISION_EVENT_DISPATCHED = "decisionEventDispatched"; + + private String flagKey; + private Boolean enabled; + private Object variables; + private String userId; + private Map attributes; + private String variationKey; + private String ruleKey; + private List reasons; + private Boolean decisionEventDispatched; + + private Map decisionInfo; + + public FlagDecisionNotificationBuilder withUserId(String userId) { + this.userId = userId; + return this; + } + + public FlagDecisionNotificationBuilder withAttributes(Map attributes) { + this.attributes = attributes; + return this; + } + + public FlagDecisionNotificationBuilder withFlagKey(String flagKey) { + this.flagKey = flagKey; + return this; + } + + public FlagDecisionNotificationBuilder withEnabled(Boolean enabled) { + this.enabled = enabled; + return this; + } + + public FlagDecisionNotificationBuilder withVariables(Object variables) { + this.variables = variables; + return this; + } + + public FlagDecisionNotificationBuilder withVariationKey(String key) { + this.variationKey = key; + return this; + } + + public FlagDecisionNotificationBuilder withRuleKey(String key) { + this.ruleKey = key; + return this; + } + + public FlagDecisionNotificationBuilder withReasons(List reasons) { + this.reasons = reasons; + return this; + } + + public FlagDecisionNotificationBuilder withDecisionEventDispatched(Boolean dispatched) { + this.decisionEventDispatched = dispatched; + return this; + } + + public DecisionNotification build() { + if (flagKey == null) { + throw new OptimizelyRuntimeException("flagKey not set"); + } + + if (enabled == null) { + throw new OptimizelyRuntimeException("enabled not set"); + } + + decisionInfo = new HashMap() {{ + put(FLAG_KEY, flagKey); + put(ENABLED, enabled); + put(VARIABLES, variables); + put(VARIATION_KEY, variationKey); + put(RULE_KEY, ruleKey); + put(REASONS, reasons); + put(DECISION_EVENT_DISPATCHED, decisionEventDispatched); + }}; + + return new DecisionNotification( + NotificationCenter.DecisionNotificationType.FLAG.toString(), + userId, + attributes, + decisionInfo); + } + } + } diff --git a/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java b/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java index 4b0b3e406..499f8ec43 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java @@ -1,6 +1,6 @@ /** * - * Copyright 2017-2019, Optimizely and contributors + * Copyright 2017-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -55,7 +55,8 @@ public enum DecisionNotificationType { FEATURE("feature"), FEATURE_TEST("feature-test"), FEATURE_VARIABLE("feature-variable"), - ALL_FEATURE_VARIABLES("all-feature-variables"); + ALL_FEATURE_VARIABLES("all-feature-variables"), + FLAG("flag"); private final String key; diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionMessage.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionMessage.java new file mode 100644 index 000000000..c66be6bee --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionMessage.java @@ -0,0 +1,34 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.optimizely.ab.optimizelydecision; + +public enum DecisionMessage { + SDK_NOT_READY("Optimizely SDK not configured properly yet."), + FLAG_KEY_INVALID("No flag was found for key \"%s\"."), + VARIABLE_VALUE_INVALID("Variable value for key \"%s\" is invalid or wrong type."); + + private String format; + + DecisionMessage(String format) { + this.format = format; + } + + public String reason(Object... args){ + return String.format(format, args); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionReasons.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionReasons.java new file mode 100644 index 000000000..0983ee4d2 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionReasons.java @@ -0,0 +1,29 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import java.util.List; + +public interface DecisionReasons { + + public void addError(String format, Object... args); + + public String addInfo(String format, Object... args); + + public List toReport(); + +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DefaultDecisionReasons.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DefaultDecisionReasons.java new file mode 100644 index 000000000..dd84d04fe --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DefaultDecisionReasons.java @@ -0,0 +1,54 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; + +public class DefaultDecisionReasons implements DecisionReasons { + + private final List errors = new ArrayList<>(); + private final List infos = new ArrayList<>(); + + public static DecisionReasons newInstance(@Nullable List options) { + if (options != null && options.contains(OptimizelyDecideOption.INCLUDE_REASONS)) return new DefaultDecisionReasons(); + else return new ErrorsDecisionReasons(); + } + + public static DecisionReasons newInstance() { + return newInstance(null); + } + + public void addError(String format, Object... args) { + String message = String.format(format, args); + errors.add(message); + } + + public String addInfo(String format, Object... args) { + String message = String.format(format, args); + infos.add(message); + return message; + } + + public List toReport() { + List reasons = new ArrayList<>(errors); + reasons.addAll(infos); + return reasons; + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/ErrorsDecisionReasons.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/ErrorsDecisionReasons.java new file mode 100644 index 000000000..91875eece --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/ErrorsDecisionReasons.java @@ -0,0 +1,56 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import java.util.ArrayList; +import java.util.List; + +public class ErrorsDecisionReasons implements DecisionReasons { + + private final List errors = new ArrayList<>(); + + public void addError(String format, Object... args) { + String message = String.format(format, args); + errors.add(message); + } + + public String addInfo(String format, Object... args) { + // skip tracking and pass-through reasons other than critical errors. + return String.format(format, args); + } + + public List toReport() { + return new ArrayList<>(errors); + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecideOption.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecideOption.java new file mode 100644 index 000000000..ccd08bb63 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecideOption.java @@ -0,0 +1,25 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +public enum OptimizelyDecideOption { + DISABLE_DECISION_EVENT, + ENABLED_FLAGS_ONLY, + IGNORE_USER_PROFILE_SERVICE, + INCLUDE_REASONS, + EXCLUDE_VARIABLES +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecision.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecision.java new file mode 100644 index 000000000..201324f20 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecision.java @@ -0,0 +1,157 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.optimizelyjson.OptimizelyJSON; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class OptimizelyDecision { + @Nullable + private final String variationKey; + + private final boolean enabled; + + @Nonnull + private final OptimizelyJSON variables; + + @Nullable + private final String ruleKey; + + @Nonnull + private final String flagKey; + + @Nonnull + private final OptimizelyUserContext userContext; + + @Nonnull + private List reasons; + + + public OptimizelyDecision(@Nullable String variationKey, + boolean enabled, + @Nonnull OptimizelyJSON variables, + @Nullable String ruleKey, + @Nonnull String flagKey, + @Nonnull OptimizelyUserContext userContext, + @Nonnull List reasons) { + this.variationKey = variationKey; + this.enabled = enabled; + this.variables = variables; + this.ruleKey = ruleKey; + this.flagKey = flagKey; + this.userContext = userContext; + this.reasons = reasons; + } + + @Nullable + public String getVariationKey() { + return variationKey; + } + + public boolean getEnabled() { + return enabled; + } + + @Nonnull + public OptimizelyJSON getVariables() { + return variables; + } + + @Nullable + public String getRuleKey() { + return ruleKey; + } + + @Nonnull + public String getFlagKey() { + return flagKey; + } + + @Nullable + public OptimizelyUserContext getUserContext() { + return userContext; + } + + @Nonnull + public List getReasons() { + return reasons; + } + + public static OptimizelyDecision newErrorDecision(@Nonnull String key, + @Nonnull OptimizelyUserContext user, + @Nonnull String error) { + return new OptimizelyDecision( + null, + false, + new OptimizelyJSON(Collections.emptyMap()), + null, + key, + user, + Arrays.asList(error)); + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) return false; + if (obj == this) return true; + OptimizelyDecision d = (OptimizelyDecision) obj; + return equals(variationKey, d.getVariationKey()) && + equals(enabled, d.getEnabled()) && + equals(variables, d.getVariables()) && + equals(ruleKey, d.getRuleKey()) && + equals(flagKey, d.getFlagKey()) && + equals(userContext, d.getUserContext()) && + equals(reasons, d.getReasons()); + } + + private static boolean equals(Object a, Object b) { + return a == b || (a != null && a.equals(b)); + } + + @Override + public int hashCode() { + int hash = variationKey != null ? variationKey.hashCode() : 0; + hash = 31 * hash + (enabled ? 1 : 0); + hash = 31 * hash + variables.hashCode(); + hash = 31 * hash + (ruleKey != null ? ruleKey.hashCode() : 0); + hash = 31 * hash + flagKey.hashCode(); + hash = 31 * hash + userContext.hashCode(); + hash = 31 * hash + reasons.hashCode(); + return hash; + } + + @Override + public String toString() { + return "OptimizelyDecision {" + + "variationKey='" + variationKey + '\'' + + ", enabled='" + enabled + '\'' + + ", variables='" + variables + '\'' + + ", ruleKey='" + ruleKey + '\'' + + ", flagKey='" + flagKey + '\'' + + ", userContext='" + userContext + '\'' + + ", enabled='" + enabled + '\'' + + ", reasons='" + reasons + '\'' + + '}'; + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyjson/OptimizelyJSON.java b/core-api/src/main/java/com/optimizely/ab/optimizelyjson/OptimizelyJSON.java index 811999e24..97bff838c 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelyjson/OptimizelyJSON.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyjson/OptimizelyJSON.java @@ -157,5 +157,24 @@ private T getValueInternal(@Nullable Object object, Class clazz) { return null; } + public boolean isEmpty() { + return map == null || map.isEmpty(); + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) return false; + if (obj == this) return true; + if (toMap() == null) return false; + + return toMap().equals(((OptimizelyJSON) obj).toMap()); + } + + @Override + public int hashCode() { + int hash = toMap() != null ? toMap().hashCode() : 0; + return hash; + } + } diff --git a/core-api/src/test/java/com/optimizely/ab/EventHandlerRule.java b/core-api/src/test/java/com/optimizely/ab/EventHandlerRule.java index 4245a8f8a..577a1891d 100644 --- a/core-api/src/test/java/com/optimizely/ab/EventHandlerRule.java +++ b/core-api/src/test/java/com/optimizely/ab/EventHandlerRule.java @@ -28,9 +28,7 @@ import java.util.stream.Collectors; import static com.optimizely.ab.config.ProjectConfig.RESERVED_ATTRIBUTE_PREFIX; -import static junit.framework.TestCase.assertTrue; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; /** * EventHandlerRule is a JUnit rule that implements an Optimizely {@link EventHandler}. @@ -108,9 +106,14 @@ public void expectImpression(String experientId, String variationId, String user } public void expectImpression(String experientId, String variationId, String userId, Map attributes) { - expect(experientId, variationId, IMPRESSION_EVENT_NAME, userId, attributes, null); + expectImpression(experientId, variationId, userId, attributes, null); } + public void expectImpression(String experientId, String variationId, String userId, Map attributes, DecisionMetadata metadata) { + expect(experientId, variationId, IMPRESSION_EVENT_NAME, userId, attributes, null, metadata); + } + + public void expectConversion(String eventName, String userId) { expectConversion(eventName, userId, Collections.emptyMap()); } @@ -124,11 +127,17 @@ public void expectConversion(String eventName, String userId, Map att } public void expect(String experientId, String variationId, String eventName, String userId, - Map attributes, Map tags) { - CanonicalEvent expectedEvent = new CanonicalEvent(experientId, variationId, eventName, userId, attributes, tags); + Map attributes, Map tags, DecisionMetadata metadata) { + CanonicalEvent expectedEvent = new CanonicalEvent(experientId, variationId, eventName, userId, attributes, tags, metadata); expectedEvents.add(expectedEvent); } + public void expect(String experientId, String variationId, String eventName, String userId, + Map attributes, Map tags) { + expect(experientId, variationId, eventName, userId, attributes, tags, null); + } + + @Override public void dispatchEvent(LogEvent logEvent) { logger.info("Receiving event: {}", logEvent); @@ -161,7 +170,8 @@ public void dispatchEvent(LogEvent logEvent) { visitor.getAttributes().stream() .filter(attribute -> !attribute.getKey().startsWith(RESERVED_ATTRIBUTE_PREFIX)) .collect(Collectors.toMap(Attribute::getKey, Attribute::getValue)), - event.getTags() + event.getTags(), + decision.getMetadata() ); logger.info("Adding dispatched, event: {}", actual); @@ -179,33 +189,45 @@ private static class CanonicalEvent { private String visitorId; private Map attributes; private Map tags; + private DecisionMetadata metadata; public CanonicalEvent(String experimentId, String variationId, String eventName, - String visitorId, Map attributes, Map tags) { + String visitorId, Map attributes, Map tags, + DecisionMetadata metadata) { this.experimentId = experimentId; this.variationId = variationId; this.eventName = eventName; this.visitorId = visitorId; this.attributes = attributes; this.tags = tags; + this.metadata = metadata; + } + + public CanonicalEvent(String experimentId, String variationId, String eventName, + String visitorId, Map attributes, Map tags) { + this(experimentId, variationId, eventName, visitorId, attributes, tags, null); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; + CanonicalEvent that = (CanonicalEvent) o; + + boolean isMetaDataEqual = (metadata == null) || Objects.equals(metadata, that.metadata); return Objects.equals(experimentId, that.experimentId) && Objects.equals(variationId, that.variationId) && Objects.equals(eventName, that.eventName) && Objects.equals(visitorId, that.visitorId) && Objects.equals(attributes, that.attributes) && - Objects.equals(tags, that.tags); + Objects.equals(tags, that.tags) && + isMetaDataEqual; } @Override public int hashCode() { - return Objects.hash(experimentId, variationId, eventName, visitorId, attributes, tags); + return Objects.hash(experimentId, variationId, eventName, visitorId, attributes, tags, metadata); } @Override @@ -217,6 +239,7 @@ public String toString() { .add("visitorId='" + visitorId + "'") .add("attributes=" + attributes) .add("tags=" + tags) + .add("metadata=" + metadata) .toString(); } } diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java index 8bd613481..932150337 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java @@ -21,6 +21,7 @@ import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.error.NoOpErrorHandler; import com.optimizely.ab.event.EventHandler; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Rule; import org.junit.Test; @@ -29,11 +30,15 @@ import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; +import java.util.Arrays; +import java.util.List; + import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.*; import static org.hamcrest.CoreMatchers.instanceOf; import static org.hamcrest.CoreMatchers.is; import static org.junit.Assert.*; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** * Tests for {@link Optimizely#builder(String, EventHandler)}. @@ -169,4 +174,25 @@ public void withProjectConfigManagerAndFallbackDatafile() throws Exception { // Project Config manager takes precedence. assertFalse(optimizelyClient.isValid()); } + + @Test + public void withDefaultDecideOptions() throws Exception { + List options = Arrays.asList( + OptimizelyDecideOption.DISABLE_DECISION_EVENT, + OptimizelyDecideOption.ENABLED_FLAGS_ONLY, + OptimizelyDecideOption.EXCLUDE_VARIABLES + ); + + Optimizely optimizelyClient = Optimizely.builder(validConfigJsonV4(), mockEventHandler) + .build(); + assertEquals(optimizelyClient.defaultDecideOptions.size(), 0); + + optimizelyClient = Optimizely.builder(validConfigJsonV4(), mockEventHandler) + .withDefaultDecideOptions(options) + .build(); + assertEquals(optimizelyClient.defaultDecideOptions.get(0), OptimizelyDecideOption.DISABLE_DECISION_EVENT); + assertEquals(optimizelyClient.defaultDecideOptions.get(1), OptimizelyDecideOption.ENABLED_FLAGS_ONLY); + assertEquals(optimizelyClient.defaultDecideOptions.get(2), OptimizelyDecideOption.EXCLUDE_VARIABLES); + } + } diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java index cd4c926c8..6cb7eb360 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -377,7 +377,7 @@ public void activateForNullVariation() throws Exception { Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); Map testUserAttributes = Collections.singletonMap("browser_type", "chromey"); - when(mockBucketer.bucket(activatedExperiment, testBucketingId, validProjectConfig)).thenReturn(null); + when(mockBucketer.bucket(eq(activatedExperiment), eq(testBucketingId), eq(validProjectConfig), anyObject())).thenReturn(null); logbackVerifier.expectMessage(Level.INFO, "Not activating user \"userId\" for experiment \"" + activatedExperiment.getKey() + "\"."); @@ -936,7 +936,7 @@ public void activateWithInvalidDatafile() throws Exception { assertNull(expectedVariation); // make sure we didn't even attempt to bucket the user - verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); } //======== track tests ========// @@ -1237,7 +1237,7 @@ public void trackWithInvalidDatafile() throws Exception { optimizely.track("event_with_launched_and_running_experiments", genericUserId); // make sure we didn't even attempt to bucket the user or fire any conversion events - verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); } @@ -1254,7 +1254,7 @@ public void getVariation() throws Exception { Optimizely optimizely = optimizelyBuilder.withBucketing(mockBucketer).build(); - when(mockBucketer.bucket(activatedExperiment, testUserId, validProjectConfig)).thenReturn(bucketedVariation); + when(mockBucketer.bucket(eq(activatedExperiment), eq(testUserId), eq(validProjectConfig), anyObject())).thenReturn(bucketedVariation); Map testUserAttributes = new HashMap<>(); testUserAttributes.put("browser_type", "chrome"); @@ -1264,7 +1264,7 @@ public void getVariation() throws Exception { testUserAttributes); // verify that the bucketing algorithm was called correctly - verify(mockBucketer).bucket(activatedExperiment, testUserId, validProjectConfig); + verify(mockBucketer).bucket(eq(activatedExperiment), eq(testUserId), eq(validProjectConfig), anyObject()); assertThat(actualVariation, is(bucketedVariation)); // verify that we didn't attempt to dispatch an event @@ -1285,13 +1285,13 @@ public void getVariationWithExperimentKey() throws Exception { .withConfig(noAudienceProjectConfig) .build(); - when(mockBucketer.bucket(activatedExperiment, testUserId, noAudienceProjectConfig)).thenReturn(bucketedVariation); + when(mockBucketer.bucket(eq(activatedExperiment), eq(testUserId), eq(noAudienceProjectConfig), anyObject())).thenReturn(bucketedVariation); // activate the experiment Variation actualVariation = optimizely.getVariation(activatedExperiment.getKey(), testUserId); // verify that the bucketing algorithm was called correctly - verify(mockBucketer).bucket(activatedExperiment, testUserId, noAudienceProjectConfig); + verify(mockBucketer).bucket(eq(activatedExperiment), eq(testUserId), eq(noAudienceProjectConfig), anyObject()); assertThat(actualVariation, is(bucketedVariation)); // verify that we didn't attempt to dispatch an event @@ -1346,7 +1346,7 @@ public void getVariationWithAudiences() throws Exception { Experiment experiment = validProjectConfig.getExperiments().get(0); Variation bucketedVariation = experiment.getVariations().get(0); - when(mockBucketer.bucket(experiment, testUserId, validProjectConfig)).thenReturn(bucketedVariation); + when(mockBucketer.bucket(eq(experiment), eq(testUserId), eq(validProjectConfig), anyObject())).thenReturn(bucketedVariation); Optimizely optimizely = optimizelyBuilder.withBucketing(mockBucketer).build(); @@ -1355,7 +1355,7 @@ public void getVariationWithAudiences() throws Exception { Variation actualVariation = optimizely.getVariation(experiment.getKey(), testUserId, testUserAttributes); - verify(mockBucketer).bucket(experiment, testUserId, validProjectConfig); + verify(mockBucketer).bucket(eq(experiment), eq(testUserId), eq(validProjectConfig), anyObject()); assertThat(actualVariation, is(bucketedVariation)); } @@ -1396,7 +1396,7 @@ public void getVariationNoAudiences() throws Exception { Experiment experiment = noAudienceProjectConfig.getExperiments().get(0); Variation bucketedVariation = experiment.getVariations().get(0); - when(mockBucketer.bucket(experiment, testUserId, noAudienceProjectConfig)).thenReturn(bucketedVariation); + when(mockBucketer.bucket(eq(experiment), eq(testUserId), eq(noAudienceProjectConfig), anyObject())).thenReturn(bucketedVariation); Optimizely optimizely = optimizelyBuilder .withConfig(noAudienceProjectConfig) @@ -1405,7 +1405,7 @@ public void getVariationNoAudiences() throws Exception { Variation actualVariation = optimizely.getVariation(experiment.getKey(), testUserId); - verify(mockBucketer).bucket(experiment, testUserId, noAudienceProjectConfig); + verify(mockBucketer).bucket(eq(experiment), eq(testUserId), eq(noAudienceProjectConfig), anyObject()); assertThat(actualVariation, is(bucketedVariation)); } @@ -1463,7 +1463,7 @@ public void getVariationForGroupExperimentWithMatchingAttributes() throws Except attributes.put("browser_type", "chrome"); } - when(mockBucketer.bucket(experiment, "user", validProjectConfig)).thenReturn(variation); + when(mockBucketer.bucket(eq(experiment), eq("user"), eq(validProjectConfig), anyObject())).thenReturn(variation); Optimizely optimizely = optimizelyBuilder.withBucketing(mockBucketer).build(); @@ -1521,7 +1521,7 @@ public void getVariationWithInvalidDatafile() throws Exception { assertNull(variation); // make sure we didn't even attempt to bucket the user - verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); } //======== Notification listeners ========// @@ -4627,4 +4627,47 @@ public void getOptimizelyConfigValidDatafile() { assertEquals(optimizely.getOptimizelyConfig().getDatafile(), validDatafile); } + // OptimizelyUserContext + + @Test + public void createUserContext_withAttributes() { + String userId = "testUser1"; + Map attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + + Optimizely optimizely = optimizelyBuilder.build(); + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + + assertEquals(user.getOptimizely(), optimizely); + assertEquals(user.getUserId(), userId); + assertEquals(user.getAttributes(), attributes); + } + + @Test + public void createUserContext_noAttributes() { + String userId = "testUser1"; + + Optimizely optimizely = optimizelyBuilder.build(); + OptimizelyUserContext user = optimizely.createUserContext(userId); + + assertEquals(user.getOptimizely(), optimizely); + assertEquals(user.getUserId(), userId); + assertTrue(user.getAttributes().isEmpty()); + } + + @Test + public void createUserContext_multiple() { + String userId1 = "testUser1"; + String userId2 = "testUser1"; + Map attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + + Optimizely optimizely = optimizelyBuilder.build(); + OptimizelyUserContext user1 = optimizely.createUserContext(userId1, attributes); + OptimizelyUserContext user2 = optimizely.createUserContext(userId2); + + assertEquals(user1.getUserId(), userId1); + assertEquals(user1.getAttributes(), attributes); + assertEquals(user2.getUserId(), userId2); + assertTrue(user2.getAttributes().isEmpty()); + } + } diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java new file mode 100644 index 000000000..50c62141d --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -0,0 +1,1165 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import com.google.common.base.Charsets; +import com.google.common.io.Resources; +import com.optimizely.ab.bucketing.FeatureDecision; +import com.optimizely.ab.bucketing.UserProfileService; +import com.optimizely.ab.config.*; +import com.optimizely.ab.config.parser.ConfigParseException; +import com.optimizely.ab.event.ForwardingEventProcessor; +import com.optimizely.ab.event.internal.payload.DecisionMetadata; +import com.optimizely.ab.notification.NotificationCenter; +import com.optimizely.ab.optimizelydecision.DecisionMessage; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; +import com.optimizely.ab.optimizelyjson.OptimizelyJSON; +import junit.framework.TestCase; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import java.util.*; + +import static com.optimizely.ab.config.ValidProjectConfigV4.ATTRIBUTE_HOUSE_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.AUDIENCE_GRYFFINDOR_VALUE; +import static com.optimizely.ab.notification.DecisionNotification.ExperimentDecisionNotificationBuilder.VARIATION_KEY; +import static com.optimizely.ab.notification.DecisionNotification.FlagDecisionNotificationBuilder.*; +import static junit.framework.TestCase.assertEquals; +import static junit.framework.TestCase.assertTrue; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +public class OptimizelyUserContextTest { + @Rule + public EventHandlerRule eventHandler = new EventHandlerRule(); + + String userId = "tester"; + boolean isListenerCalled = false; + + Optimizely optimizely; + String datafile; + ProjectConfig config; + Map experimentIdMapping; + Map featureKeyMapping; + Map groupIdMapping; + + @Before + public void setUp() throws Exception { + datafile = Resources.toString(Resources.getResource("config/decide-project-config.json"), Charsets.UTF_8); + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .build(); + } + + @Test + public void optimizelyUserContext_withAttributes() { + Map attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + OptimizelyUserContext user = new OptimizelyUserContext(optimizely, userId, attributes); + + assertEquals(user.getOptimizely(), optimizely); + assertEquals(user.getUserId(), userId); + assertEquals(user.getAttributes(), attributes); + } + + @Test + public void optimizelyUserContext_noAttributes() { + OptimizelyUserContext user = new OptimizelyUserContext(optimizely, userId); + + assertEquals(user.getOptimizely(), optimizely); + assertEquals(user.getUserId(), userId); + assertTrue(user.getAttributes().isEmpty()); + } + + @Test + public void setAttribute() { + Map attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + OptimizelyUserContext user = new OptimizelyUserContext(optimizely, userId, attributes); + + user.setAttribute("k1", "v1"); + user.setAttribute("k2", true); + user.setAttribute("k3", 100); + user.setAttribute("k4", 3.5); + + assertEquals(user.getOptimizely(), optimizely); + assertEquals(user.getUserId(), userId); + Map newAttributes = user.getAttributes(); + assertEquals(newAttributes.get(ATTRIBUTE_HOUSE_KEY), AUDIENCE_GRYFFINDOR_VALUE); + assertEquals(newAttributes.get("k1"), "v1"); + assertEquals(newAttributes.get("k2"), true); + assertEquals(newAttributes.get("k3"), 100); + assertEquals(newAttributes.get("k4"), 3.5); + } + + @Test + public void setAttribute_noAttribute() { + OptimizelyUserContext user = new OptimizelyUserContext(optimizely, userId); + + user.setAttribute("k1", "v1"); + user.setAttribute("k2", true); + + assertEquals(user.getOptimizely(), optimizely); + assertEquals(user.getUserId(), userId); + Map newAttributes = user.getAttributes(); + assertEquals(newAttributes.get("k1"), "v1"); + assertEquals(newAttributes.get("k2"), true); + } + + @Test + public void setAttribute_override() { + Map attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + OptimizelyUserContext user = new OptimizelyUserContext(optimizely, userId, attributes); + + user.setAttribute("k1", "v1"); + user.setAttribute(ATTRIBUTE_HOUSE_KEY, "v2"); + + Map newAttributes = user.getAttributes(); + assertEquals(newAttributes.get("k1"), "v1"); + assertEquals(newAttributes.get(ATTRIBUTE_HOUSE_KEY), "v2"); + } + + @Test + public void setAttribute_nullValue() { + Map attributes = Collections.singletonMap("k1", null); + OptimizelyUserContext user = new OptimizelyUserContext(optimizely, userId, attributes); + + Map newAttributes = user.getAttributes(); + assertEquals(newAttributes.get("k1"), null); + + user.setAttribute("k1", true); + newAttributes = user.getAttributes(); + assertEquals(newAttributes.get("k1"), true); + + user.setAttribute("k1", null); + newAttributes = user.getAttributes(); + assertEquals(newAttributes.get("k1"), null); + } + + // decide + + @Test + public void decide_featureTest() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_2"; + String experimentKey = "exp_no_audience"; + String variationKey = "variation_with_traffic"; + String experimentId = "10420810910"; + String variationId = "10418551353"; + OptimizelyJSON variablesExpected = optimizely.getAllFeatureVariables(flagKey, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getVariationKey(), variationKey); + assertTrue(decision.getEnabled()); + assertEquals(decision.getVariables().toMap(), variablesExpected.toMap()); + assertEquals(decision.getRuleKey(), experimentKey); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + assertTrue(decision.getReasons().isEmpty()); + + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey(experimentKey) + .setRuleType(FeatureDecision.DecisionSource.FEATURE_TEST.toString()) + .setVariationKey(variationKey) + .setEnabled(true) + .build(); + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap(), metadata); + } + + @Test + public void decide_rollout() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_1"; + String experimentKey = "18322080788"; + String variationKey = "18257766532"; + String experimentId = "18322080788"; + String variationId = "18257766532"; + OptimizelyJSON variablesExpected = optimizely.getAllFeatureVariables(flagKey, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getVariationKey(), variationKey); + assertTrue(decision.getEnabled()); + assertEquals(decision.getVariables().toMap(), variablesExpected.toMap()); + assertEquals(decision.getRuleKey(), experimentKey); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + assertTrue(decision.getReasons().isEmpty()); + + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey(experimentKey) + .setRuleType(FeatureDecision.DecisionSource.ROLLOUT.toString()) + .setVariationKey(variationKey) + .setEnabled(true) + .build(); + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap(), metadata); + } + + @Test + public void decide_nullVariation() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_3"; + OptimizelyJSON variablesExpected = new OptimizelyJSON(Collections.emptyMap()); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getVariationKey(), null); + assertFalse(decision.getEnabled()); + assertEquals(decision.getVariables().toMap(), variablesExpected.toMap()); + assertEquals(decision.getRuleKey(), null); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + assertTrue(decision.getReasons().isEmpty()); + + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey("") + .setRuleType(FeatureDecision.DecisionSource.ROLLOUT.toString()) + .setVariationKey("") + .setEnabled(false) + .build(); + eventHandler.expectImpression(null, "", userId, Collections.emptyMap(), metadata); + } + + // decideAll + + @Test + public void decideAll_oneFlag() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_2"; + String experimentKey = "exp_no_audience"; + String variationKey = "variation_with_traffic"; + String experimentId = "10420810910"; + String variationId = "10418551353"; + + List flagKeys = Arrays.asList(flagKey); + OptimizelyJSON variablesExpected = optimizely.getAllFeatureVariables(flagKey, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + Map decisions = user.decideForKeys(flagKeys); + + assertTrue(decisions.size() == 1); + OptimizelyDecision decision = decisions.get(flagKey); + + OptimizelyDecision expDecision = new OptimizelyDecision( + variationKey, + true, + variablesExpected, + experimentKey, + flagKey, + user, + Collections.emptyList()); + assertEquals(decision, expDecision); + + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey(experimentKey) + .setRuleType(FeatureDecision.DecisionSource.FEATURE_TEST.toString()) + .setVariationKey(variationKey) + .setEnabled(true) + .build(); + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap(), metadata); + } + + @Test + public void decideAll_twoFlags() { + String flagKey1 = "feature_1"; + String flagKey2 = "feature_2"; + + List flagKeys = Arrays.asList(flagKey1, flagKey2); + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + OptimizelyJSON variablesExpected2 = optimizely.getAllFeatureVariables(flagKey2, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); + Map decisions = user.decideForKeys(flagKeys); + + assertTrue(decisions.size() == 2); + + assertEquals( + decisions.get(flagKey1), + new OptimizelyDecision("a", + true, + variablesExpected1, + "exp_with_audience", + flagKey1, + user, + Collections.emptyList())); + assertEquals( + decisions.get(flagKey2), + new OptimizelyDecision("variation_with_traffic", + true, + variablesExpected2, + "exp_no_audience", + flagKey2, + user, + Collections.emptyList())); + } + + @Test + public void decideAll_allFlags() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey1 = "feature_1"; + String flagKey2 = "feature_2"; + String flagKey3 = "feature_3"; + Map attributes = Collections.singletonMap("gender", "f"); + + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + OptimizelyJSON variablesExpected2 = optimizely.getAllFeatureVariables(flagKey2, userId); + OptimizelyJSON variablesExpected3 = new OptimizelyJSON(Collections.emptyMap()); + + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + Map decisions = user.decideAll(); + + assertTrue(decisions.size() == 3); + + assertEquals( + decisions.get(flagKey1), + new OptimizelyDecision( + "a", + true, + variablesExpected1, + "exp_with_audience", + flagKey1, + user, + Collections.emptyList())); + assertEquals( + decisions.get(flagKey2), + new OptimizelyDecision( + "variation_with_traffic", + true, + variablesExpected2, + "exp_no_audience", + flagKey2, + user, + Collections.emptyList())); + assertEquals( + decisions.get(flagKey3), + new OptimizelyDecision( + null, + false, + variablesExpected3, + null, + flagKey3, + user, + Collections.emptyList())); + + eventHandler.expectImpression("10390977673", "10389729780", userId, attributes); + eventHandler.expectImpression("10420810910", "10418551353", userId, attributes); + eventHandler.expectImpression(null, "", userId, attributes); + } + + @Test + public void decideAll_allFlags_enabledFlagsOnly() { + String flagKey1 = "feature_1"; + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); + Map decisions = user.decideAll(Arrays.asList(OptimizelyDecideOption.ENABLED_FLAGS_ONLY)); + + assertTrue(decisions.size() == 2); + + assertEquals( + decisions.get(flagKey1), + new OptimizelyDecision( + "a", + true, + variablesExpected1, + "exp_with_audience", + flagKey1, + user, + Collections.emptyList())); + } + + // trackEvent + + @Test + public void trackEvent() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + Map attributes = Collections.singletonMap("gender", "f"); + String eventKey = "event1"; + Map eventTags = Collections.singletonMap("name", "carrot"); + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + user.trackEvent(eventKey, eventTags); + + eventHandler.expectConversion(eventKey, userId, attributes, eventTags); + } + + @Test + public void trackEvent_noEventTags() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + Map attributes = Collections.singletonMap("gender", "f"); + String eventKey = "event1"; + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + user.trackEvent(eventKey); + + eventHandler.expectConversion(eventKey, userId, attributes); + } + + @Test + public void trackEvent_emptyAttributes() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String eventKey = "event1"; + Map eventTags = Collections.singletonMap("name", "carrot"); + OptimizelyUserContext user = optimizely.createUserContext(userId); + user.trackEvent(eventKey, eventTags); + + eventHandler.expectConversion(eventKey, userId, Collections.emptyMap(), eventTags); + } + + // send events + + @Test + public void decide_sendEvent() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_2"; + String variationKey = "variation_with_traffic"; + String experimentId = "10420810910"; + String variationId = "10418551353"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getVariationKey(), variationKey); + + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap()); + } + + @Test + public void decide_doNotSendEvent_withOption() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_2"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.DISABLE_DECISION_EVENT)); + + assertEquals(decision.getVariationKey(), "variation_with_traffic"); + } + + // notifications + + @Test + public void decisionNotification() { + String flagKey = "feature_2"; + String variationKey = "variation_with_traffic"; + boolean enabled = true; + OptimizelyJSON variables = optimizely.getAllFeatureVariables(flagKey, userId); + String ruleKey = "exp_no_audience"; + List reasons = Collections.emptyList(); + + final Map testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(FLAG_KEY, flagKey); + testDecisionInfoMap.put(VARIATION_KEY, variationKey); + testDecisionInfoMap.put(ENABLED, enabled); + testDecisionInfoMap.put(VARIABLES, variables.toMap()); + testDecisionInfoMap.put(RULE_KEY, ruleKey); + testDecisionInfoMap.put(REASONS, reasons); + + Map attributes = Collections.singletonMap("gender", "f"); + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + + optimizely.addDecisionNotificationHandler( + decisionNotification -> { + Assert.assertEquals(decisionNotification.getType(), NotificationCenter.DecisionNotificationType.FLAG.toString()); + Assert.assertEquals(decisionNotification.getUserId(), userId); + Assert.assertEquals(decisionNotification.getAttributes(), attributes); + Assert.assertEquals(decisionNotification.getDecisionInfo(), testDecisionInfoMap); + isListenerCalled = true; + }); + + isListenerCalled = false; + testDecisionInfoMap.put(DECISION_EVENT_DISPATCHED, true); + user.decide(flagKey); + assertTrue(isListenerCalled); + + isListenerCalled = false; + testDecisionInfoMap.put(DECISION_EVENT_DISPATCHED, false); + user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.DISABLE_DECISION_EVENT)); + assertTrue(isListenerCalled); + } + + // options + + @Test + public void decideOptions_bypassUPS() throws Exception { + String flagKey = "feature_2"; // embedding experiment: "exp_no_audience" + String experimentId = "10420810910"; // "exp_no_audience" + String variationId1 = "10418551353"; + String variationId2 = "10418510624"; + String variationKey1 = "variation_with_traffic"; + String variationKey2 = "variation_no_traffic"; + + UserProfileService ups = mock(UserProfileService.class); + when(ups.lookup(userId)).thenReturn(createUserProfileMap(experimentId, variationId2)); + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withUserProfileService(ups) + .build(); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + // should return variationId2 set by UPS + assertEquals(decision.getVariationKey(), variationKey2); + + decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE)); + // should ignore variationId2 set by UPS and return variationId1 + assertEquals(decision.getVariationKey(), variationKey1); + // also should not save either + verify(ups, never()).save(anyObject()); + } + + @Test + public void decideOptions_excludeVariables() { + String flagKey = "feature_1"; + OptimizelyUserContext user = optimizely.createUserContext(userId); + + OptimizelyDecision decision = user.decide(flagKey); + assertTrue(decision.getVariables().toMap().size() > 0); + + decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.EXCLUDE_VARIABLES)); + assertTrue(decision.getVariables().toMap().size() == 0); + } + + @Test + public void decideOptions_includeReasons() { + OptimizelyUserContext user = optimizely.createUserContext(userId); + + String flagKey = "invalid_key"; + OptimizelyDecision decision = user.decide(flagKey); + assertEquals(decision.getReasons().size(), 1); + TestCase.assertEquals(decision.getReasons().get(0), DecisionMessage.FLAG_KEY_INVALID.reason(flagKey)); + + decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + assertEquals(decision.getReasons().size(), 1); + assertEquals(decision.getReasons().get(0), DecisionMessage.FLAG_KEY_INVALID.reason(flagKey)); + + flagKey = "feature_1"; + decision = user.decide(flagKey); + assertEquals(decision.getReasons().size(), 0); + + decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + assertTrue(decision.getReasons().size() > 0); + } + + public void decideOptions_disableDispatchEvent() { + // tested already with decide_doNotSendEvent() above + } + + public void decideOptions_enabledFlagsOnly() { + // tested already with decideAll_allFlags_enabledFlagsOnly() above + } + + @Test + public void decideOptions_defaultDecideOptions() { + List options = Arrays.asList( + OptimizelyDecideOption.EXCLUDE_VARIABLES + ); + + optimizely = Optimizely.builder() + .withDatafile(datafile) + .withDefaultDecideOptions(options) + .build(); + + String flagKey = "feature_1"; + OptimizelyUserContext user = optimizely.createUserContext(userId); + + // should be excluded by DefaultDecideOption + OptimizelyDecision decision = user.decide(flagKey); + assertTrue(decision.getVariables().toMap().size() == 0); + + decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS, OptimizelyDecideOption.EXCLUDE_VARIABLES)); + // other options should work as well + assertTrue(decision.getReasons().size() > 0); + // redundant setting ignored + assertTrue(decision.getVariables().toMap().size() == 0); + } + + // errors + + @Test + public void decide_sdkNotReady() { + String flagKey = "feature_1"; + + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertNull(decision.getVariationKey()); + assertFalse(decision.getEnabled()); + assertTrue(decision.getVariables().isEmpty()); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + + assertEquals(decision.getReasons().size(), 1); + assertEquals(decision.getReasons().get(0), DecisionMessage.SDK_NOT_READY.reason()); + } + + @Test + public void decide_invalidFeatureKey() { + String flagKey = "invalid_key"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertNull(decision.getVariationKey()); + assertFalse(decision.getEnabled()); + assertTrue(decision.getVariables().isEmpty()); + assertEquals(decision.getReasons().size(), 1); + assertEquals(decision.getReasons().get(0), DecisionMessage.FLAG_KEY_INVALID.reason(flagKey)); + } + + @Test + public void decideAll_sdkNotReady() { + List flagKeys = Arrays.asList("feature_1"); + + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext user = optimizely.createUserContext(userId); + Map decisions = user.decideForKeys(flagKeys); + + assertEquals(decisions.size(), 0); + } + + @Test + public void decideAll_errorDecisionIncluded() { + String flagKey1 = "feature_2"; + String flagKey2 = "invalid_key"; + + List flagKeys = Arrays.asList(flagKey1, flagKey2); + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + Map decisions = user.decideForKeys(flagKeys); + + assertEquals(decisions.size(), 2); + + assertEquals( + decisions.get(flagKey1), + new OptimizelyDecision( + "variation_with_traffic", + true, + variablesExpected1, + "exp_no_audience", + flagKey1, + user, + Collections.emptyList())); + assertEquals( + decisions.get(flagKey2), + OptimizelyDecision.newErrorDecision( + flagKey2, + user, + DecisionMessage.FLAG_KEY_INVALID.reason(flagKey2))); + } + + // reasons (errors) + + @Test + public void decideReasons_sdkNotReady() { + String flagKey = "feature_1"; + + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getReasons().size(), 1); + assertEquals(decision.getReasons().get(0), DecisionMessage.SDK_NOT_READY.reason()); + } + + @Test + public void decideReasons_featureKeyInvalid() { + String flagKey = "invalid_key"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getReasons().size(), 1); + assertEquals(decision.getReasons().get(0), DecisionMessage.FLAG_KEY_INVALID.reason(flagKey)); + } + + @Test + public void decideReasons_variableValueInvalid() { + String flagKey = "feature_1"; + + FeatureFlag flag = getSpyFeatureFlag(flagKey); + List variables = Arrays.asList(new FeatureVariable("any-id", "any-key", "invalid", null, "integer", null)); + when(flag.getVariables()).thenReturn(variables); + addSpyFeatureFlag(flag); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getReasons().get(0), DecisionMessage.VARIABLE_VALUE_INVALID.reason("any-key")); + } + + // reasons (logs with includeReasons) + + @Test + public void decideReasons_conditionNoMatchingAudience() throws ConfigParseException { + String flagKey = "feature_1"; + String audienceId = "invalid_id"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("Audience %s could not be found.", audienceId) + )); + } + + @Test + public void decideReasons_evaluateAttributeInvalidType() { + String flagKey = "feature_1"; + String audienceId = "13389130056"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("country", 25)); + + assertTrue(decision.getReasons().contains( + String.format("Audience condition \"{name='country', type='custom_attribute', match='exact', value='US'}\" evaluated to UNKNOWN because a value of type \"java.lang.Integer\" was passed for user attribute \"country\"") + )); + } + + @Test + public void decideReasons_evaluateAttributeValueOutOfRange() { + String flagKey = "feature_1"; + String audienceId = "age_18"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", (float)Math.pow(2, 54))); + + assertTrue(decision.getReasons().contains( + String.format("Audience condition \"{name='age', type='custom_attribute', match='gt', value=18.0}\" evaluated to UNKNOWN because a value of type \"java.lang.Float\" was passed for user attribute \"age\"") + )); + } + + @Test + public void decideReasons_userAttributeInvalidType() { + String flagKey = "feature_1"; + String audienceId = "invalid_type"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", 25)); + + assertTrue(decision.getReasons().contains( + String.format("Audience condition \"{name='age', type='invalid', match='gt', value=18.0}\" uses an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK.") + )); + } + + @Test + public void decideReasons_userAttributeInvalidMatch() { + String flagKey = "feature_1"; + String audienceId = "invalid_match"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", 25)); + + assertTrue(decision.getReasons().contains( + String.format("Audience condition \"{name='age', type='custom_attribute', match='invalid', value=18.0}\" uses an unknown match type. You may need to upgrade to a newer release of the Optimizely SDK.") + )); + } + + @Test + public void decideReasons_userAttributeNilValue() { + String flagKey = "feature_1"; + String audienceId = "nil_value"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", 25)); + + assertTrue(decision.getReasons().contains( + String.format("Audience condition \"{name='age', type='custom_attribute', match='gt', value=null}\" evaluated to UNKNOWN because a value of type \"java.lang.Integer\" was passed for user attribute \"age\"") + )); + } + + @Test + public void decideReasons_missingAttributeValue() { + String flagKey = "feature_1"; + String audienceId = "age_18"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("Audience condition \"{name='age', type='custom_attribute', match='gt', value=18.0}\" evaluated to UNKNOWN because no value was passed for user attribute \"age\"") + )); + } + + @Test + public void decideReasons_experimentNotRunning() { + String flagKey = "feature_1"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.isActive()).thenReturn(false); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("Experiment \"exp_with_audience\" is not running.") + )); + } + + @Test + public void decideReasons_gotVariationFromUserProfile() throws Exception { + String flagKey = "feature_2"; // embedding experiment: "exp_no_audience" + String experimentId = "10420810910"; // "exp_no_audience" + String experimentKey = "exp_no_audience"; + String variationId2 = "10418510624"; + String variationKey2 = "variation_no_traffic"; + + UserProfileService ups = mock(UserProfileService.class); + when(ups.lookup(userId)).thenReturn(createUserProfileMap(experimentId, variationId2)); + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withUserProfileService(ups) + .build(); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("Returning previously activated variation \"%s\" of experiment \"%s\" for user \"%s\" from user profile.", variationKey2, experimentKey, userId) + )); + } + + @Test + public void decideReasons_forcedVariationFound() { + String flagKey = "feature_1"; + String variationKey = "b"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getUserIdToVariationKeyMap()).thenReturn(Collections.singletonMap(userId, variationKey)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("User \"%s\" is forced in variation \"%s\".", userId, variationKey) + )); + } + + @Test + public void decideReasons_forcedVariationFoundButInvalid() { + String flagKey = "feature_1"; + String variationKey = "invalid-key"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getUserIdToVariationKeyMap()).thenReturn(Collections.singletonMap(userId, variationKey)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("Variation \"%s\" is not in the datafile. Not activating user \"%s\".", variationKey, userId) + )); + } + + @Test + public void decideReasons_userMeetsConditionsForTargetingRule() { + String flagKey = "feature_1"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + user.setAttribute("country", "US"); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".", userId, flagKey) + )); + } + + @Test + public void decideReasons_userDoesntMeetConditionsForTargetingRule() { + String flagKey = "feature_1"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + user.setAttribute("country", "CA"); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("User \"%s\" does not meet conditions for targeting rule \"%d\".", userId, 1) + )); + } + + @Test + public void decideReasons_userBucketedIntoTargetingRule() { + String flagKey = "feature_1"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + user.setAttribute("country", "US"); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".", userId, flagKey) + )); + } + + @Test + public void decideReasons_userBucketedIntoEveryoneTargetingRule() { + String flagKey = "feature_1"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + user.setAttribute("country", "KO"); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("User \"%s\" meets conditions for targeting rule \"Everyone Else\".", userId) + )); + } + + @Test + public void decideReasons_userNotBucketedIntoTargetingRule() { + String flagKey = "feature_1"; + String experimentKey = "3332020494"; // experimentKey of rollout[2] + + OptimizelyUserContext user = optimizely.createUserContext(userId); + user.setAttribute("browser", "safari"); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("User with bucketingId \"%s\" is not in any variation of experiment \"%s\".", userId, experimentKey) + )); + } + + @Test + public void decideReasons_userBucketedIntoVariationInExperiment() { + String flagKey = "feature_2"; + String experimentKey = "exp_no_audience"; + String variationKey = "variation_with_traffic"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("User with bucketingId \"%s\" is in variation \"%s\" of experiment \"%s\".", userId, variationKey, experimentKey) + )); + } + + @Test + public void decideReasons_userNotBucketedIntoVariation() { + String flagKey = "feature_2"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getTrafficAllocation()).thenReturn(Arrays.asList(new TrafficAllocation("any-id", 0))); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", 25)); + + assertTrue(decision.getReasons().contains( + String.format("User with bucketingId \"%s\" is not in any variation of experiment \"exp_no_audience\".", userId) + )); + } + + @Test + public void decideReasons_userBucketedIntoExperimentInGroup() { + String flagKey = "feature_3"; + String experimentId = "10390965532"; // "group_exp_1" + + FeatureFlag flag = getSpyFeatureFlag(flagKey); + when(flag.getExperimentIds()).thenReturn(Arrays.asList(experimentId)); + addSpyFeatureFlag(flag); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("User with bucketingId \"tester\" is in experiment \"group_exp_1\" of group 13142870430.") + )); + } + + @Test + public void decideReasons_userNotBucketedIntoExperimentInGroup() { + String flagKey = "feature_3"; + String experimentId = "10420843432"; // "group_exp_2" + + FeatureFlag flag = getSpyFeatureFlag(flagKey); + when(flag.getExperimentIds()).thenReturn(Arrays.asList(experimentId)); + addSpyFeatureFlag(flag); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("User with bucketingId \"tester\" is not in experiment \"group_exp_2\" of group 13142870430.") + )); + } + + @Test + public void decideReasons_userNotBucketedIntoAnyExperimentInGroup() { + String flagKey = "feature_3"; + String experimentId = "10390965532"; // "group_exp_1" + String groupId = "13142870430"; + + FeatureFlag flag = getSpyFeatureFlag(flagKey); + when(flag.getExperimentIds()).thenReturn(Arrays.asList(experimentId)); + addSpyFeatureFlag(flag); + + Group group = getSpyGroup(groupId); + when(group.getTrafficAllocation()).thenReturn(Collections.emptyList()); + addSpyGroup(group); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("User with bucketingId \"tester\" is not in any experiment of group 13142870430.") + )); + } + + @Test + public void decideReasons_userNotInExperiment() { + String flagKey = "feature_1"; + String experimentKey = "exp_with_audience"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("User \"%s\" does not meet conditions to be in experiment \"%s\".", userId, experimentKey) + )); + } + + // utils + + Map createUserProfileMap(String experimentId, String variationId) { + Map userProfileMap = new HashMap(); + userProfileMap.put(UserProfileService.userIdKey, userId); + + Map decisionMap = new HashMap(1); + decisionMap.put(UserProfileService.variationIdKey, variationId); + + Map> decisionsMap = new HashMap>(); + decisionsMap.put(experimentId, decisionMap); + userProfileMap.put(UserProfileService.experimentBucketMapKey, decisionsMap); + + return userProfileMap; + } + + void setAudienceForFeatureTest(String flagKey, String audienceId) throws ConfigParseException { + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + } + + Experiment getSpyExperiment(String flagKey) { + setMockConfig(); + String experimentId = config.getFeatureKeyMapping().get(flagKey).getExperimentIds().get(0); + return spy(experimentIdMapping.get(experimentId)); + } + + FeatureFlag getSpyFeatureFlag(String flagKey) { + setMockConfig(); + return spy(config.getFeatureKeyMapping().get(flagKey)); + } + + Group getSpyGroup(String groupId) { + setMockConfig(); + return spy(groupIdMapping.get(groupId)); + } + + void addSpyExperiment(Experiment experiment) { + experimentIdMapping.put(experiment.getId(), experiment); + when(config.getExperimentIdMapping()).thenReturn(experimentIdMapping); + } + + void addSpyFeatureFlag(FeatureFlag flag) { + featureKeyMapping.put(flag.getKey(), flag); + when(config.getFeatureKeyMapping()).thenReturn(featureKeyMapping); + } + + void addSpyGroup(Group group) { + groupIdMapping.put(group.getId(), group); + when(config.getGroupIdMapping()).thenReturn(groupIdMapping); + } + + void setMockConfig() { + if (config != null) return; + + ProjectConfig configReal = null; + try { + configReal = new DatafileProjectConfig.Builder().withDatafile(datafile).build(); + config = spy(configReal); + optimizely = Optimizely.builder().withConfig(config).build(); + experimentIdMapping = new HashMap<>(config.getExperimentIdMapping()); + groupIdMapping = new HashMap<>(config.getGroupIdMapping()); + featureKeyMapping = new HashMap<>(config.getFeatureKeyMapping()); + } catch (ConfigParseException e) { + fail("ProjectConfig build failed"); + } + } + + OptimizelyDecision callDecideWithIncludeReasons(String flagKey, Map attributes) { + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + return user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + } + + OptimizelyDecision callDecideWithIncludeReasons(String flagKey) { + return callDecideWithIncludeReasons(flagKey, Collections.emptyMap()); + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java index 2a3030314..d12cd9a56 100644 --- a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java @@ -15,18 +15,12 @@ ***************************************************************************/ package com.optimizely.ab.bucketing; -import com.optimizely.ab.config.Experiment; -import com.optimizely.ab.config.FeatureFlag; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.DatafileProjectConfigTestUtils; -import com.optimizely.ab.config.Rollout; -import com.optimizely.ab.config.TrafficAllocation; -import com.optimizely.ab.config.ValidProjectConfigV4; -import com.optimizely.ab.config.Variation; +import ch.qos.logback.classic.Level; +import com.optimizely.ab.config.*; import com.optimizely.ab.error.ErrorHandler; -import com.optimizely.ab.internal.LogbackVerifier; - import com.optimizely.ab.internal.ControlAttribute; +import com.optimizely.ab.internal.LogbackVerifier; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -39,42 +33,13 @@ import java.util.List; import java.util.Map; -import ch.qos.logback.classic.Level; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; - -import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.noAudienceProjectConfigV3; -import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; -import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; -import static com.optimizely.ab.config.ValidProjectConfigV4.ATTRIBUTE_HOUSE_KEY; -import static com.optimizely.ab.config.ValidProjectConfigV4.ATTRIBUTE_NATIONALITY_KEY; -import static com.optimizely.ab.config.ValidProjectConfigV4.AUDIENCE_ENGLISH_CITIZENS_VALUE; -import static com.optimizely.ab.config.ValidProjectConfigV4.AUDIENCE_GRYFFINDOR_VALUE; -import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_FLAG_MULTI_VARIATE_FEATURE; -import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_FLAG_SINGLE_VARIABLE_INTEGER; -import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_MULTI_VARIATE_FEATURE_KEY; -import static com.optimizely.ab.config.ValidProjectConfigV4.ROLLOUT_2; -import static com.optimizely.ab.config.ValidProjectConfigV4.ROLLOUT_3_EVERYONE_ELSE_RULE; -import static com.optimizely.ab.config.ValidProjectConfigV4.ROLLOUT_3_EVERYONE_ELSE_RULE_ENABLED_VARIATION; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.*; +import static com.optimizely.ab.config.ValidProjectConfigV4.*; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.anyMapOf; -import static org.mockito.Matchers.anyString; -import static org.mockito.Matchers.eq; -import static org.mockito.Mockito.atMost; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.junit.Assert.*; +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; public class DecisionServiceTest { @@ -130,8 +95,8 @@ public void getVariationWhitelistedPrecedesAudienceEval() throws Exception { // no attributes provided for a experiment that has an audience assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.emptyMap(), validProjectConfig), is(expectedVariation)); - verify(decisionService).getWhitelistedVariation(experiment, whitelistedUserId); - verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), any(ProjectConfig.class)); + verify(decisionService).getWhitelistedVariation(eq(experiment), eq(whitelistedUserId), anyObject()); + verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), any(ProjectConfig.class), anyObject()); } /** @@ -153,7 +118,7 @@ public void getForcedVariationBeforeWhitelisting() throws Exception { assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.emptyMap(), validProjectConfig), is(expectedVariation)); //verify(decisionService).getForcedVariation(experiment.getKey(), whitelistedUserId); - verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), any(ProjectConfig.class)); + verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), any(ProjectConfig.class), anyObject()); assertEquals(decisionService.getWhitelistedVariation(experiment, whitelistedUserId), whitelistVariation); assertTrue(decisionService.setForcedVariation(experiment, whitelistedUserId, null)); assertNull(decisionService.getForcedVariation(experiment, whitelistedUserId)); @@ -177,7 +142,7 @@ public void getVariationForcedPrecedesAudienceEval() throws Exception { // no attributes provided for a experiment that has an audience assertThat(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig), is(expectedVariation)); - verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), eq(validProjectConfig)); + verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), eq(validProjectConfig), anyObject()); assertEquals(decisionService.setForcedVariation(experiment, genericUserId, null), true); assertNull(decisionService.getForcedVariation(experiment, genericUserId)); } @@ -322,14 +287,17 @@ public void getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperiment any(Experiment.class), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject(), + anyObject() ); // do not bucket to any rollouts doReturn(new FeatureDecision(null, null, null)).when(decisionService).getVariationForFeatureInRollout( any(FeatureFlag.class), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject() ); // try to get a variation back from the decision service for the feature flag @@ -363,14 +331,18 @@ public void getVariationForFeatureReturnsVariationReturnedFromGetVariation() { eq(ValidProjectConfigV4.EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject(), + anyObject() ); doReturn(ValidProjectConfigV4.VARIATION_MUTEX_GROUP_EXP_2_VAR_1).when(decisionService).getVariation( eq(ValidProjectConfigV4.EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject(), + anyObject() ); FeatureDecision featureDecision = decisionService.getVariationForFeature( @@ -409,7 +381,9 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() eq(featureExperiment), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject(), + anyObject() ); // return variation for rollout @@ -418,7 +392,8 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() eq(featureFlag), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject() ); // make sure we get the right variation back @@ -436,7 +411,8 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() any(FeatureFlag.class), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject() ); // make sure we ask for experiment bucketing once @@ -444,7 +420,9 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() any(Experiment.class), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject(), + anyObject() ); } @@ -469,7 +447,9 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails eq(featureExperiment), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject(), + anyObject() ); // return variation for rollout @@ -478,7 +458,8 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails eq(featureFlag), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject() ); // make sure we get the right variation back @@ -496,7 +477,8 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails any(FeatureFlag.class), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject() ); // make sure we ask for experiment bucketing once @@ -504,7 +486,9 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails any(Experiment.class), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject(), + anyObject() ); logbackVerifier.expectMessage( @@ -550,7 +534,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenFeatureIsNotAttachedTo @Test public void getVariationForFeatureInRolloutReturnsNullWhenUserIsExcludedFromAllTraffic() { Bucketer mockBucketer = mock(Bucketer.class); - when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(null); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(null); DecisionService decisionService = new DecisionService( mockBucketer, @@ -572,7 +556,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserIsExcludedFromAllT // with fall back bucketing, the user has at most 2 chances to get bucketed with traffic allocation // one chance with the audience rollout rule // one chance with the everyone else rule - verify(mockBucketer, atMost(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + verify(mockBucketer, atMost(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); } /** @@ -583,7 +567,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserIsExcludedFromAllT @Test public void getVariationForFeatureInRolloutReturnsNullWhenUserFailsAllAudiencesAndTraffic() { Bucketer mockBucketer = mock(Bucketer.class); - when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(null); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(null); DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); @@ -597,7 +581,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserFailsAllAudiencesA assertNull(featureDecision.decisionSource); // user is only bucketed once for the everyone else rule - verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); } /** @@ -611,7 +595,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsAllAudie Rollout rollout = ROLLOUT_2; Experiment everyoneElseRule = rollout.getExperiments().get(rollout.getExperiments().size() - 1); Variation expectedVariation = everyoneElseRule.getVariations().get(0); - when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class))).thenReturn(expectedVariation); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(expectedVariation); DecisionService decisionService = new DecisionService( mockBucketer, @@ -637,7 +621,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsAllAudie assertEquals(FeatureDecision.DecisionSource.ROLLOUT, featureDecision.decisionSource); // verify user is only bucketed once for everyone else rule - verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); } /** @@ -652,8 +636,8 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI Rollout rollout = ROLLOUT_2; Experiment everyoneElseRule = rollout.getExperiments().get(rollout.getExperiments().size() - 1); Variation expectedVariation = everyoneElseRule.getVariations().get(0); - when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(null); - when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class))).thenReturn(expectedVariation); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(null); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(expectedVariation); DecisionService decisionService = new DecisionService( mockBucketer, @@ -675,7 +659,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI logbackVerifier.expectMessage(Level.DEBUG, "User \"genericUserId\" meets conditions for targeting rule \"Everyone Else\"."); // verify user is only bucketed once for everyone else rule - verify(mockBucketer, times(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + verify(mockBucketer, times(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); } /** @@ -694,9 +678,9 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI Variation englishCitizenVariation = englishCitizensRule.getVariations().get(0); Experiment everyoneElseRule = rollout.getExperiments().get(rollout.getExperiments().size() - 1); Variation expectedVariation = everyoneElseRule.getVariations().get(0); - when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(null); - when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class))).thenReturn(expectedVariation); - when(mockBucketer.bucket(eq(englishCitizensRule), anyString(), any(ProjectConfig.class))).thenReturn(englishCitizenVariation); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(null); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(expectedVariation); + when(mockBucketer.bucket(eq(englishCitizensRule), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(englishCitizenVariation); DecisionService decisionService = new DecisionService( mockBucketer, @@ -721,7 +705,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI assertEquals(FeatureDecision.DecisionSource.ROLLOUT, featureDecision.decisionSource); // verify user is only bucketed once for everyone else rule - verify(mockBucketer, times(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + verify(mockBucketer, times(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); } /** @@ -737,9 +721,9 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTargetin Variation englishCitizenVariation = englishCitizensRule.getVariations().get(0); Experiment everyoneElseRule = rollout.getExperiments().get(rollout.getExperiments().size() - 1); Variation everyoneElseVariation = everyoneElseRule.getVariations().get(0); - when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(null); - when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class))).thenReturn(everyoneElseVariation); - when(mockBucketer.bucket(eq(englishCitizensRule), anyString(), any(ProjectConfig.class))).thenReturn(englishCitizenVariation); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(null); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(everyoneElseVariation); + when(mockBucketer.bucket(eq(englishCitizensRule), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(englishCitizenVariation); DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); @@ -759,7 +743,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTargetin logbackVerifier.expectMessage(Level.DEBUG, "Audience \"4194404272\" evaluated to true."); logbackVerifier.expectMessage(Level.INFO, "Audiences for rule \"3\" collectively evaluated to true"); // verify user is only bucketed once for everyone else rule - verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); } //========= white list tests ==========/ @@ -912,7 +896,7 @@ public void getVariationSavesBucketedVariationIntoUserProfile() throws Exception Collections.singletonMap(experiment.getId(), decision)); Bucketer mockBucketer = mock(Bucketer.class); - when(mockBucketer.bucket(experiment, userProfileId, noAudienceProjectConfig)).thenReturn(variation); + when(mockBucketer.bucket(eq(experiment), eq(userProfileId), eq(noAudienceProjectConfig), anyObject())).thenReturn(variation); DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, userProfileService); @@ -974,7 +958,7 @@ public void getVariationSavesANewUserProfile() throws Exception { UserProfileService userProfileService = mock(UserProfileService.class); DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService); - when(bucketer.bucket(experiment, userProfileId, noAudienceProjectConfig)).thenReturn(variation); + when(bucketer.bucket(eq(experiment), eq(userProfileId), eq(noAudienceProjectConfig), anyObject())).thenReturn(variation); when(userProfileService.lookup(userProfileId)).thenReturn(null); assertEquals(variation, decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), noAudienceProjectConfig)); @@ -988,7 +972,7 @@ public void getVariationBucketingId() throws Exception { Experiment experiment = validProjectConfig.getExperiments().get(0); Variation expectedVariation = experiment.getVariations().get(0); - when(bucketer.bucket(experiment, "bucketId", validProjectConfig)).thenReturn(expectedVariation); + when(bucketer.bucket(eq(experiment), eq("bucketId"), eq(validProjectConfig), anyObject())).thenReturn(expectedVariation); Map attr = new HashMap(); attr.put(ControlAttribute.BUCKETING_ATTRIBUTE.toString(), "bucketId"); @@ -1012,8 +996,8 @@ public void getVariationForRolloutWithBucketingId() { attributes.put(ControlAttribute.BUCKETING_ATTRIBUTE.toString(), bucketingId); Bucketer bucketer = mock(Bucketer.class); - when(bucketer.bucket(rolloutRuleExperiment, userId, v4ProjectConfig)).thenReturn(null); - when(bucketer.bucket(rolloutRuleExperiment, bucketingId, v4ProjectConfig)).thenReturn(rolloutVariation); + when(bucketer.bucket(eq(rolloutRuleExperiment), eq(userId), eq(v4ProjectConfig), anyObject())).thenReturn(null); + when(bucketer.bucket(eq(rolloutRuleExperiment), eq(bucketingId), eq(v4ProjectConfig), anyObject())).thenReturn(rolloutVariation); DecisionService decisionService = spy(new DecisionService( bucketer, diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java index 772d22ef7..216099298 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java @@ -18,27 +18,18 @@ import ch.qos.logback.classic.Level; import com.optimizely.ab.internal.LogbackVerifier; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import java.math.BigInteger; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.*; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.assertNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; /** * Tests for the evaluation of different audience condition types (And, Or, Not, and UserAttribute) @@ -53,6 +44,8 @@ public class AudienceConditionEvaluationTest { Map testUserAttributes; Map testTypedUserAttributes; + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + @Before public void initialize() { testUserAttributes = new HashMap<>(); @@ -77,7 +70,7 @@ public void userAttributeEvaluateTrue() throws Exception { assertNull(testInstance.getMatch()); assertEquals(testInstance.getName(), "browser_type"); assertEquals(testInstance.getType(), "custom_attribute"); - assertTrue(testInstance.evaluate(null, testUserAttributes)); + assertTrue(testInstance.evaluate(null, testUserAttributes, reasons)); } /** @@ -86,7 +79,7 @@ public void userAttributeEvaluateTrue() throws Exception { @Test public void userAttributeEvaluateFalse() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", null, "firefox"); - assertFalse(testInstance.evaluate(null, testUserAttributes)); + assertFalse(testInstance.evaluate(null, testUserAttributes, reasons)); } /** @@ -95,7 +88,7 @@ public void userAttributeEvaluateFalse() throws Exception { @Test public void userAttributeUnknownAttribute() throws Exception { UserAttribute testInstance = new UserAttribute("unknown_dim", "custom_attribute", null, "unknown"); - assertFalse(testInstance.evaluate(null, testUserAttributes)); + assertFalse(testInstance.evaluate(null, testUserAttributes, reasons)); } /** @@ -104,7 +97,7 @@ public void userAttributeUnknownAttribute() throws Exception { @Test public void invalidMatchCondition() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "unknown_dimension", null, "chrome"); - assertNull(testInstance.evaluate(null, testUserAttributes)); + assertNull(testInstance.evaluate(null, testUserAttributes, reasons)); } /** @@ -113,7 +106,7 @@ public void invalidMatchCondition() throws Exception { @Test public void invalidMatch() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "blah", "chrome"); - assertNull(testInstance.evaluate(null, testUserAttributes)); + assertNull(testInstance.evaluate(null, testUserAttributes, reasons)); logbackVerifier.expectMessage(Level.WARN, "Audience condition \"{name='browser_type', type='custom_attribute', match='blah', value='chrome'}\" uses an unknown match type. You may need to upgrade to a newer release of the Optimizely SDK"); } @@ -124,7 +117,7 @@ public void invalidMatch() throws Exception { @Test public void unexpectedAttributeType() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "gt", 20); - assertNull(testInstance.evaluate(null, testUserAttributes)); + assertNull(testInstance.evaluate(null, testUserAttributes, reasons)); logbackVerifier.expectMessage(Level.WARN, "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because a value of type \"java.lang.String\" was passed for user attribute \"browser_type\""); } @@ -135,7 +128,7 @@ public void unexpectedAttributeType() throws Exception { @Test public void unexpectedAttributeTypeNull() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "gt", 20); - assertNull(testInstance.evaluate(null, Collections.singletonMap("browser_type", null))); + assertNull(testInstance.evaluate(null, Collections.singletonMap("browser_type", null), reasons)); logbackVerifier.expectMessage(Level.DEBUG, "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because a null value was passed for user attribute \"browser_type\""); } @@ -147,7 +140,7 @@ public void unexpectedAttributeTypeNull() throws Exception { @Test public void missingAttribute() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "gt", 20); - assertNull(testInstance.evaluate(null, Collections.EMPTY_MAP)); + assertNull(testInstance.evaluate(null, Collections.EMPTY_MAP, reasons)); logbackVerifier.expectMessage(Level.DEBUG, "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because no value was passed for user attribute \"browser_type\""); } @@ -158,7 +151,7 @@ public void missingAttribute() throws Exception { @Test public void nullAttribute() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "gt", 20); - assertNull(testInstance.evaluate(null, null)); + assertNull(testInstance.evaluate(null, null, reasons)); logbackVerifier.expectMessage(Level.DEBUG, "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because no value was passed for user attribute \"browser_type\""); } @@ -169,7 +162,7 @@ public void nullAttribute() throws Exception { @Test public void unknownConditionType() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "blah", "exists", "firefox"); - assertNull(testInstance.evaluate(null, testUserAttributes)); + assertNull(testInstance.evaluate(null, testUserAttributes, reasons)); logbackVerifier.expectMessage(Level.WARN, "Audience condition \"{name='browser_type', type='blah', match='exists', value='firefox'}\" uses an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK."); } @@ -183,9 +176,9 @@ public void existsMatchConditionEmptyStringEvaluatesTrue() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "exists", "firefox"); Map attributes = new HashMap<>(); attributes.put("browser_type", ""); - assertTrue(testInstance.evaluate(null, attributes)); + assertTrue(testInstance.evaluate(null, attributes, reasons)); attributes.put("browser_type", null); - assertFalse(testInstance.evaluate(null, attributes)); + assertFalse(testInstance.evaluate(null, attributes, reasons)); } /** @@ -195,16 +188,16 @@ public void existsMatchConditionEmptyStringEvaluatesTrue() throws Exception { @Test public void existsMatchConditionEvaluatesTrue() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "exists", "firefox"); - assertTrue(testInstance.evaluate(null, testUserAttributes)); + assertTrue(testInstance.evaluate(null, testUserAttributes, reasons)); UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute", "exists", false); UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "exists", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "exists", 4.55); UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "exists", testUserAttributes); - assertTrue(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceObject.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -215,8 +208,8 @@ public void existsMatchConditionEvaluatesTrue() throws Exception { public void existsMatchConditionEvaluatesFalse() throws Exception { UserAttribute testInstance = new UserAttribute("bad_var", "custom_attribute", "exists", "chrome"); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "exists", "chrome"); - assertFalse(testInstance.evaluate(null, testTypedUserAttributes)); - assertFalse(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstance.evaluate(null, testTypedUserAttributes, reasons)); + assertFalse(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -231,11 +224,11 @@ public void exactMatchConditionEvaluatesTrue() { UserAttribute testInstanceFloat = new UserAttribute("num_size", "custom_attribute", "exact", (float) 3); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "exact", 3.55); - assertTrue(testInstanceString.evaluate(null, testUserAttributes)); - assertTrue(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 3))); - assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceString.evaluate(null, testUserAttributes, reasons)); + assertTrue(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 3), reasons)); + assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -268,22 +261,22 @@ public void exactMatchConditionEvaluatesNullWithInvalidUserAttr() { assertNull(testInstanceInteger.evaluate( null, - Collections.singletonMap("num_size", bigInteger))); + Collections.singletonMap("num_size", bigInteger), reasons)); assertNull(testInstanceFloat.evaluate( null, - Collections.singletonMap("num_size", invalidFloatValue))); + Collections.singletonMap("num_size", invalidFloatValue), reasons)); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); + Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble), reasons)); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); + Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble), reasons)); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", infiniteNANDouble)))); + Collections.singletonMap("num_counts", infiniteNANDouble)), reasons)); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", largeDouble)))); + Collections.singletonMap("num_counts", largeDouble)), reasons)); } /** @@ -301,10 +294,10 @@ public void invalidExactMatchConditionEvaluatesNull() { UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "exact", infiniteNegativeInfiniteDouble); UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "exact", infiniteNANDouble); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -318,10 +311,10 @@ public void exactMatchConditionEvaluatesFalse() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "exact", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "exact", 5.55); - assertFalse(testInstanceString.evaluate(null, testUserAttributes)); - assertFalse(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceString.evaluate(null, testUserAttributes, reasons)); + assertFalse(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); + assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -337,15 +330,15 @@ public void exactMatchConditionEvaluatesNull() { UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "exact", "3.55"); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "exact", "null_val"); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceString.evaluate(null, testUserAttributes)); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceString.evaluate(null, testUserAttributes, reasons)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); Map attr = new HashMap<>(); attr.put("browser_type", "true"); - assertNull(testInstanceString.evaluate(null, attr)); + assertNull(testInstanceString.evaluate(null, attr, reasons)); } /** @@ -359,13 +352,13 @@ public void gtMatchConditionEvaluatesTrue() { UserAttribute testInstanceFloat = new UserAttribute("num_size", "custom_attribute", "gt", (float) 2); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "gt", 2.55); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 3))); - assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 3), reasons)); + assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); Map badAttributes = new HashMap<>(); badAttributes.put("num_size", "bobs burgers"); - assertNull(testInstanceInteger.evaluate(null, badAttributes)); + assertNull(testInstanceInteger.evaluate(null, badAttributes, reasons)); } /** @@ -399,22 +392,22 @@ public void gtMatchConditionEvaluatesNullWithInvalidUserAttr() { assertNull(testInstanceInteger.evaluate( null, - Collections.singletonMap("num_size", bigInteger))); + Collections.singletonMap("num_size", bigInteger), reasons)); assertNull(testInstanceFloat.evaluate( null, - Collections.singletonMap("num_size", invalidFloatValue))); + Collections.singletonMap("num_size", invalidFloatValue), reasons)); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); + Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble), reasons)); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); + Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble), reasons)); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", infiniteNANDouble)))); + Collections.singletonMap("num_counts", infiniteNANDouble)), reasons)); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", largeDouble)))); + Collections.singletonMap("num_counts", largeDouble)), reasons)); } /** @@ -432,10 +425,10 @@ public void gtMatchConditionEvaluatesNullWithInvalidAttr() { UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "gt", infiniteNegativeInfiniteDouble); UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "gt", infiniteNANDouble); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -448,8 +441,8 @@ public void gtMatchConditionEvaluatesFalse() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "gt", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "gt", 5.55); - assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -463,10 +456,10 @@ public void gtMatchConditionEvaluatesNull() { UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "gt", 3.5); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "gt", 3.5); - assertNull(testInstanceString.evaluate(null, testUserAttributes)); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceString.evaluate(null, testUserAttributes, reasons)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); } @@ -481,13 +474,13 @@ public void geMatchConditionEvaluatesTrue() { UserAttribute testInstanceFloat = new UserAttribute("num_size", "custom_attribute", "ge", (float) 2); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "ge", 2.55); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 2))); - assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 2), reasons)); + assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); Map badAttributes = new HashMap<>(); badAttributes.put("num_size", "bobs burgers"); - assertNull(testInstanceInteger.evaluate(null, badAttributes)); + assertNull(testInstanceInteger.evaluate(null, badAttributes, reasons)); } /** @@ -521,22 +514,22 @@ public void geMatchConditionEvaluatesNullWithInvalidUserAttr() { assertNull(testInstanceInteger.evaluate( null, - Collections.singletonMap("num_size", bigInteger))); + Collections.singletonMap("num_size", bigInteger), reasons)); assertNull(testInstanceFloat.evaluate( null, - Collections.singletonMap("num_size", invalidFloatValue))); + Collections.singletonMap("num_size", invalidFloatValue), reasons)); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); + Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble), reasons)); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); + Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble), reasons)); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", infiniteNANDouble)))); + Collections.singletonMap("num_counts", infiniteNANDouble)), reasons)); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", largeDouble)))); + Collections.singletonMap("num_counts", largeDouble)), reasons)); } /** @@ -554,10 +547,10 @@ public void geMatchConditionEvaluatesNullWithInvalidAttr() { UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "ge", infiniteNegativeInfiniteDouble); UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "ge", infiniteNANDouble); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -570,8 +563,8 @@ public void geMatchConditionEvaluatesFalse() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "ge", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "ge", 5.55); - assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -585,10 +578,10 @@ public void geMatchConditionEvaluatesNull() { UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "ge", 3.5); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "ge", 3.5); - assertNull(testInstanceString.evaluate(null, testUserAttributes)); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceString.evaluate(null, testUserAttributes, reasons)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); } @@ -602,8 +595,8 @@ public void ltMatchConditionEvaluatesTrue() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "lt", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "lt", 5.55); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -616,8 +609,8 @@ public void ltMatchConditionEvaluatesFalse() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "lt", 2); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "lt", 2.55); - assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -631,10 +624,10 @@ public void ltMatchConditionEvaluatesNull() { UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "lt", 3.5); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "lt", 3.5); - assertNull(testInstanceString.evaluate(null, testUserAttributes)); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceString.evaluate(null, testUserAttributes, reasons)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -668,22 +661,22 @@ public void ltMatchConditionEvaluatesNullWithInvalidUserAttr() { assertNull(testInstanceInteger.evaluate( null, - Collections.singletonMap("num_size", bigInteger))); + Collections.singletonMap("num_size", bigInteger), reasons)); assertNull(testInstanceFloat.evaluate( null, - Collections.singletonMap("num_size", invalidFloatValue))); + Collections.singletonMap("num_size", invalidFloatValue), reasons)); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); + Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble), reasons)); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); + Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble), reasons)); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", infiniteNANDouble)))); + Collections.singletonMap("num_counts", infiniteNANDouble)), reasons)); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", largeDouble)))); + Collections.singletonMap("num_counts", largeDouble)), reasons)); } /** @@ -701,10 +694,10 @@ public void ltMatchConditionEvaluatesNullWithInvalidAttributes() { UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "lt", infiniteNegativeInfiniteDouble); UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "lt", infiniteNANDouble); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes, reasons)); } @@ -718,8 +711,8 @@ public void leMatchConditionEvaluatesTrue() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "le", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "le", 5.55); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceDouble.evaluate(null, Collections.singletonMap("num_counts", 5.55))); + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceDouble.evaluate(null, Collections.singletonMap("num_counts", 5.55), reasons)); } /** @@ -732,8 +725,8 @@ public void leMatchConditionEvaluatesFalse() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "le", 2); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "le", 2.55); - assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -747,10 +740,10 @@ public void leMatchConditionEvaluatesNull() { UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "le", 3.5); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "le", 3.5); - assertNull(testInstanceString.evaluate(null, testUserAttributes)); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceString.evaluate(null, testUserAttributes, reasons)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -784,22 +777,22 @@ public void leMatchConditionEvaluatesNullWithInvalidUserAttr() { assertNull(testInstanceInteger.evaluate( null, - Collections.singletonMap("num_size", bigInteger))); + Collections.singletonMap("num_size", bigInteger), reasons)); assertNull(testInstanceFloat.evaluate( null, - Collections.singletonMap("num_size", invalidFloatValue))); + Collections.singletonMap("num_size", invalidFloatValue), reasons)); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); + Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble), reasons)); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); + Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble), reasons)); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", infiniteNANDouble)))); + Collections.singletonMap("num_counts", infiniteNANDouble)), reasons)); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", largeDouble)))); + Collections.singletonMap("num_counts", largeDouble)), reasons)); } /** @@ -817,10 +810,10 @@ public void leMatchConditionEvaluatesNullWithInvalidAttributes() { UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "le", infiniteNegativeInfiniteDouble); UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "le", infiniteNANDouble); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -830,7 +823,7 @@ public void leMatchConditionEvaluatesNullWithInvalidAttributes() { @Test public void substringMatchConditionEvaluatesTrue() { UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "substring", "chrome"); - assertTrue(testInstanceString.evaluate(null, testUserAttributes)); + assertTrue(testInstanceString.evaluate(null, testUserAttributes, reasons)); } /** @@ -840,7 +833,7 @@ public void substringMatchConditionEvaluatesTrue() { @Test public void substringMatchConditionPartialMatchEvaluatesTrue() { UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "substring", "chro"); - assertTrue(testInstanceString.evaluate(null, testUserAttributes)); + assertTrue(testInstanceString.evaluate(null, testUserAttributes, reasons)); } /** @@ -850,7 +843,7 @@ public void substringMatchConditionPartialMatchEvaluatesTrue() { @Test public void substringMatchConditionEvaluatesFalse() { UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "substring", "chr0me"); - assertFalse(testInstanceString.evaluate(null, testUserAttributes)); + assertFalse(testInstanceString.evaluate(null, testUserAttributes, reasons)); } /** @@ -865,11 +858,11 @@ public void substringMatchConditionEvaluatesNull() { UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "substring", "chrome1"); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "substring", "chrome1"); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); } //======== Semantic version evaluation tests ========// @@ -880,7 +873,7 @@ public void testSemanticVersionEqualsMatchInvalidInput() { Map testAttributes = new HashMap(); testAttributes.put("version", 2.0); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); } @Test @@ -888,7 +881,7 @@ public void semanticVersionInvalidMajorShouldBeNumberOnly() { Map testAttributes = new HashMap(); testAttributes.put("version", "a.1.2"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); } @Test @@ -896,7 +889,7 @@ public void semanticVersionInvalidMinorShouldBeNumberOnly() { Map testAttributes = new HashMap(); testAttributes.put("version", "1.b.2"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); } @Test @@ -904,7 +897,7 @@ public void semanticVersionInvalidPatchShouldBeNumberOnly() { Map testAttributes = new HashMap(); testAttributes.put("version", "1.2.c"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test SemanticVersionEqualsMatch returns null if given invalid UserCondition Variable type @@ -913,7 +906,7 @@ public void testSemanticVersionEqualsMatchInvalidUserConditionVariable() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.0"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", 2.0); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test SemanticVersionGTMatch returns null if given invalid value type @@ -922,7 +915,7 @@ public void testSemanticVersionGTMatchInvalidInput() { Map testAttributes = new HashMap(); testAttributes.put("version", false); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test SemanticVersionGEMatch returns null if given invalid value type @@ -931,7 +924,7 @@ public void testSemanticVersionGEMatchInvalidInput() { Map testAttributes = new HashMap(); testAttributes.put("version", 2); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test SemanticVersionLTMatch returns null if given invalid value type @@ -940,7 +933,7 @@ public void testSemanticVersionLTMatchInvalidInput() { Map testAttributes = new HashMap(); testAttributes.put("version", 2); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test SemanticVersionLEMatch returns null if given invalid value type @@ -949,7 +942,7 @@ public void testSemanticVersionLEMatchInvalidInput() { Map testAttributes = new HashMap(); testAttributes.put("version", 2); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test if not same when targetVersion is only major.minor.patch and version is major.minor @@ -958,7 +951,7 @@ public void testIsSemanticNotSameConditionValueMajorMinorPatch() { Map testAttributes = new HashMap(); testAttributes.put("version", "1.2"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "1.2.0"); - assertFalse(testInstanceString.evaluate(null, testAttributes)); + assertFalse(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test if same when target is only major but user condition checks only major.minor,patch @@ -967,7 +960,7 @@ public void testIsSemanticSameSingleDigit() { Map testAttributes = new HashMap(); testAttributes.put("version", "3.0.0"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test if greater when User value patch is greater even when its beta @@ -976,7 +969,7 @@ public void testIsSemanticGreaterWhenUserConditionComparesMajorMinorAndPatchVers Map testAttributes = new HashMap(); testAttributes.put("version", "3.1.1-beta"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.0"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test if greater when preRelease is greater alphabetically @@ -985,7 +978,7 @@ public void testIsSemanticGreaterWhenMajorMinorPatchReleaseVersionCharacter() { Map testAttributes = new HashMap(); testAttributes.put("version", "3.1.1-beta.y.1+1.1"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.1-beta.x.1+1.1"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test if greater when preRelease version number is greater @@ -994,7 +987,7 @@ public void testIsSemanticGreaterWhenMajorMinorPatchPreReleaseVersionNum() { Map testAttributes = new HashMap(); testAttributes.put("version", "3.1.1-beta.x.2+1.1"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.1-beta.x.1+1.1"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test if equals semantic version even when only same preRelease is passed in user attribute and no build meta @@ -1003,7 +996,7 @@ public void testIsSemanticEqualWhenMajorMinorPatchPreReleaseVersionNum() { Map testAttributes = new HashMap(); testAttributes.put("version", "3.1.1-beta.x.1"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3.1.1-beta.x.1"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test if not same @@ -1012,7 +1005,7 @@ public void testIsSemanticNotSameReturnsFalse() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.2"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.1.1"); - assertFalse(testInstanceString.evaluate(null, testAttributes)); + assertFalse(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test when target is full semantic version major.minor.patch @@ -1021,7 +1014,7 @@ public void testIsSemanticSameFull() { Map testAttributes = new HashMap(); testAttributes.put("version", "3.0.1"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3.0.1"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare less when user condition checks only major.minor @@ -1030,7 +1023,7 @@ public void testIsSemanticLess() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.6"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.2"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // When user condition checks major.minor but target is major.minor.patch then its equals @@ -1039,7 +1032,7 @@ public void testIsSemanticLessFalse() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.0"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.1"); - assertFalse(testInstanceString.evaluate(null, testAttributes)); + assertFalse(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare less when target is full major.minor.patch @@ -1048,7 +1041,7 @@ public void testIsSemanticFullLess() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.6"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.1.9"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare greater when user condition checks only major.minor @@ -1057,7 +1050,7 @@ public void testIsSemanticMore() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.3.6"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.2"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare greater when both are major.minor.patch-beta but target is greater than user condition @@ -1066,7 +1059,7 @@ public void testIsSemanticMoreWhenBeta() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.3.6-beta"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.3.5-beta"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare greater when target is major.minor.patch @@ -1075,7 +1068,7 @@ public void testIsSemanticFullMore() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.7"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.6"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare greater when target is major.minor.patch is smaller then it returns false @@ -1084,7 +1077,7 @@ public void testSemanticVersionGTFullMoreReturnsFalse() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.10"); - assertFalse(testInstanceString.evaluate(null, testAttributes)); + assertFalse(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare equal when both are exactly same - major.minor.patch-beta @@ -1093,7 +1086,7 @@ public void testIsSemanticFullEqual() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9-beta"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.1.9-beta"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare equal when both major.minor.patch is same, but due to beta user condition is smaller @@ -1102,7 +1095,7 @@ public void testIsSemanticLessWhenBeta() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.9-beta"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare greater when target is major.minor.patch-beta and user condition only compares major.minor.patch @@ -1111,7 +1104,7 @@ public void testIsSemanticGreaterBeta() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.9-beta"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare equal when target is major.minor.patch @@ -1120,7 +1113,7 @@ public void testIsSemanticLessEqualsWhenEqualsReturnsTrue() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.1.9"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare less when target is major.minor.patch @@ -1129,7 +1122,7 @@ public void testIsSemanticLessEqualsWhenLessReturnsTrue() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.132.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.233.91"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare less when target is major.minor.patch @@ -1138,7 +1131,7 @@ public void testIsSemanticLessEqualsWhenGreaterReturnsFalse() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.233.91"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.132.009"); - assertFalse(testInstanceString.evaluate(null, testAttributes)); + assertFalse(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare equal when target is major.minor.patch @@ -1147,7 +1140,7 @@ public void testIsSemanticGreaterEqualsWhenEqualsReturnsTrue() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.1.9"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare less when target is major.minor.patch @@ -1156,7 +1149,7 @@ public void testIsSemanticGreaterEqualsWhenLessReturnsTrue() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.233.91"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.132.9"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare less when target is major.minor.patch @@ -1165,7 +1158,7 @@ public void testIsSemanticGreaterEqualsWhenLessReturnsFalse() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.132.009"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.233.91"); - assertFalse(testInstanceString.evaluate(null, testAttributes)); + assertFalse(testInstanceString.evaluate(null, testAttributes, reasons)); } /** @@ -1174,7 +1167,7 @@ public void testIsSemanticGreaterEqualsWhenLessReturnsFalse() { @Test public void notConditionEvaluateNull() { NotCondition notCondition = new NotCondition(new NullCondition()); - assertNull(notCondition.evaluate(null, testUserAttributes)); + assertNull(notCondition.evaluate(null, testUserAttributes, reasons)); } /** @@ -1183,11 +1176,11 @@ public void notConditionEvaluateNull() { @Test public void notConditionEvaluateTrue() { UserAttribute userAttribute = mock(UserAttribute.class); - when(userAttribute.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); NotCondition notCondition = new NotCondition(userAttribute); - assertTrue(notCondition.evaluate(null, testUserAttributes)); - verify(userAttribute, times(1)).evaluate(null, testUserAttributes); + assertTrue(notCondition.evaluate(null, testUserAttributes, reasons)); + verify(userAttribute, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); } /** @@ -1196,11 +1189,11 @@ public void notConditionEvaluateTrue() { @Test public void notConditionEvaluateFalse() { UserAttribute userAttribute = mock(UserAttribute.class); - when(userAttribute.evaluate(null, testUserAttributes)).thenReturn(true); + when(userAttribute.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); NotCondition notCondition = new NotCondition(userAttribute); - assertFalse(notCondition.evaluate(null, testUserAttributes)); - verify(userAttribute, times(1)).evaluate(null, testUserAttributes); + assertFalse(notCondition.evaluate(null, testUserAttributes, reasons)); + verify(userAttribute, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); } /** @@ -1209,20 +1202,20 @@ public void notConditionEvaluateFalse() { @Test public void orConditionEvaluateTrue() { UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(null, testUserAttributes)).thenReturn(true); + when(userAttribute1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); List conditions = new ArrayList(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertTrue(orCondition.evaluate(null, testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); + assertTrue(orCondition.evaluate(null, testUserAttributes, reasons)); + verify(userAttribute1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); // shouldn't be called due to short-circuiting in 'Or' evaluation - verify(userAttribute2, times(0)).evaluate(null, testUserAttributes); + verify(userAttribute2, times(0)).evaluate(eq(null), eq(testUserAttributes), anyObject()); } /** @@ -1231,20 +1224,20 @@ public void orConditionEvaluateTrue() { @Test public void orConditionEvaluateTrueWithNullAndTrue() { UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(null, testUserAttributes)).thenReturn(null); + when(userAttribute1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(null); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(true); + when(userAttribute2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); List conditions = new ArrayList(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertTrue(orCondition.evaluate(null, testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); + assertTrue(orCondition.evaluate(null, testUserAttributes, reasons)); + verify(userAttribute1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); // shouldn't be called due to short-circuiting in 'Or' evaluation - verify(userAttribute2, times(1)).evaluate(null, testUserAttributes); + verify(userAttribute2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); } /** @@ -1253,20 +1246,20 @@ public void orConditionEvaluateTrueWithNullAndTrue() { @Test public void orConditionEvaluateNullWithNullAndFalse() { UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(null, testUserAttributes)).thenReturn(null); + when(userAttribute1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(null); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); List conditions = new ArrayList(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertNull(orCondition.evaluate(null, testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); + assertNull(orCondition.evaluate(null, testUserAttributes, reasons)); + verify(userAttribute1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); // shouldn't be called due to short-circuiting in 'Or' evaluation - verify(userAttribute2, times(1)).evaluate(null, testUserAttributes); + verify(userAttribute2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); } /** @@ -1275,20 +1268,20 @@ public void orConditionEvaluateNullWithNullAndFalse() { @Test public void orConditionEvaluateFalseWithFalseAndFalse() { UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); List conditions = new ArrayList(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertFalse(orCondition.evaluate(null, testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); + assertFalse(orCondition.evaluate(null, testUserAttributes, reasons)); + verify(userAttribute1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); // shouldn't be called due to short-circuiting in 'Or' evaluation - verify(userAttribute2, times(1)).evaluate(null, testUserAttributes); + verify(userAttribute2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); } /** @@ -1297,19 +1290,19 @@ public void orConditionEvaluateFalseWithFalseAndFalse() { @Test public void orConditionEvaluateFalse() { UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); List conditions = new ArrayList(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertFalse(orCondition.evaluate(null, testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); - verify(userAttribute2, times(1)).evaluate(null, testUserAttributes); + assertFalse(orCondition.evaluate(null, testUserAttributes, reasons)); + verify(userAttribute1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + verify(userAttribute2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); } /** @@ -1318,19 +1311,19 @@ public void orConditionEvaluateFalse() { @Test public void andConditionEvaluateTrue() { OrCondition orCondition1 = mock(OrCondition.class); - when(orCondition1.evaluate(null, testUserAttributes)).thenReturn(true); + when(orCondition1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(null, testUserAttributes)).thenReturn(true); + when(orCondition2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); List conditions = new ArrayList(); conditions.add(orCondition1); conditions.add(orCondition2); AndCondition andCondition = new AndCondition(conditions); - assertTrue(andCondition.evaluate(null, testUserAttributes)); - verify(orCondition1, times(1)).evaluate(null, testUserAttributes); - verify(orCondition2, times(1)).evaluate(null, testUserAttributes); + assertTrue(andCondition.evaluate(null, testUserAttributes, reasons)); + verify(orCondition1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + verify(orCondition2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); } /** @@ -1339,19 +1332,19 @@ public void andConditionEvaluateTrue() { @Test public void andConditionEvaluateFalseWithNullAndFalse() { OrCondition orCondition1 = mock(OrCondition.class); - when(orCondition1.evaluate(null, testUserAttributes)).thenReturn(null); + when(orCondition1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(null); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(null, testUserAttributes)).thenReturn(false); + when(orCondition2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); List conditions = new ArrayList(); conditions.add(orCondition1); conditions.add(orCondition2); AndCondition andCondition = new AndCondition(conditions); - assertFalse(andCondition.evaluate(null, testUserAttributes)); - verify(orCondition1, times(1)).evaluate(null, testUserAttributes); - verify(orCondition2, times(1)).evaluate(null, testUserAttributes); + assertFalse(andCondition.evaluate(null, testUserAttributes, reasons)); + verify(orCondition1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + verify(orCondition2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); } /** @@ -1360,19 +1353,19 @@ public void andConditionEvaluateFalseWithNullAndFalse() { @Test public void andConditionEvaluateNullWithNullAndTrue() { OrCondition orCondition1 = mock(OrCondition.class); - when(orCondition1.evaluate(null, testUserAttributes)).thenReturn(null); + when(orCondition1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(null); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(null, testUserAttributes)).thenReturn(true); + when(orCondition2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); List conditions = new ArrayList(); conditions.add(orCondition1); conditions.add(orCondition2); AndCondition andCondition = new AndCondition(conditions); - assertNull(andCondition.evaluate(null, testUserAttributes)); - verify(orCondition1, times(1)).evaluate(null, testUserAttributes); - verify(orCondition2, times(1)).evaluate(null, testUserAttributes); + assertNull(andCondition.evaluate(null, testUserAttributes, reasons)); + verify(orCondition1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + verify(orCondition2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); } /** @@ -1381,10 +1374,10 @@ public void andConditionEvaluateNullWithNullAndTrue() { @Test public void andConditionEvaluateFalse() { OrCondition orCondition1 = mock(OrCondition.class); - when(orCondition1.evaluate(null, testUserAttributes)).thenReturn(false); + when(orCondition1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(null, testUserAttributes)).thenReturn(true); + when(orCondition2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); // and[false, true] List conditions = new ArrayList(); @@ -1392,13 +1385,13 @@ public void andConditionEvaluateFalse() { conditions.add(orCondition2); AndCondition andCondition = new AndCondition(conditions); - assertFalse(andCondition.evaluate(null, testUserAttributes)); - verify(orCondition1, times(1)).evaluate(null, testUserAttributes); + assertFalse(andCondition.evaluate(null, testUserAttributes, reasons)); + verify(orCondition1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); // shouldn't be called due to short-circuiting in 'And' evaluation - verify(orCondition2, times(0)).evaluate(null, testUserAttributes); + verify(orCondition2, times(0)).evaluate(eq(null), eq(testUserAttributes), anyObject()); OrCondition orCondition3 = mock(OrCondition.class); - when(orCondition3.evaluate(null, testUserAttributes)).thenReturn(null); + when(orCondition3.evaluate(null, testUserAttributes, reasons)).thenReturn(null); // and[null, false] List conditions2 = new ArrayList(); @@ -1406,7 +1399,7 @@ public void andConditionEvaluateFalse() { conditions2.add(orCondition1); AndCondition andCondition2 = new AndCondition(conditions2); - assertFalse(andCondition2.evaluate(null, testUserAttributes)); + assertFalse(andCondition2.evaluate(null, testUserAttributes, reasons)); // and[true, false, null] List conditions3 = new ArrayList(); @@ -1415,7 +1408,7 @@ public void andConditionEvaluateFalse() { conditions3.add(orCondition1); AndCondition andCondition3 = new AndCondition(conditions3); - assertFalse(andCondition3.evaluate(null, testUserAttributes)); + assertFalse(andCondition3.evaluate(null, testUserAttributes, reasons)); } /** @@ -1447,8 +1440,8 @@ public void nullValueEvaluate() { attributeValue ); - assertNull(nullValueAttribute.evaluate(null, Collections.emptyMap())); - assertNull(nullValueAttribute.evaluate(null, Collections.singletonMap(attributeName, attributeValue))); - assertNull(nullValueAttribute.evaluate(null, (Collections.singletonMap(attributeName, "")))); + assertNull(nullValueAttribute.evaluate(null, Collections.emptyMap(), reasons)); + assertNull(nullValueAttribute.evaluate(null, Collections.singletonMap(attributeName, attributeValue), reasons)); + assertNull(nullValueAttribute.evaluate(null, (Collections.singletonMap(attributeName, "")), reasons)); } } diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionTest.java new file mode 100644 index 000000000..dd2c476af --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionTest.java @@ -0,0 +1,79 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import com.optimizely.ab.Optimizely; +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.optimizelyjson.OptimizelyJSON; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static junit.framework.TestCase.assertEquals; +import static org.junit.Assert.assertTrue; + +public class OptimizelyDecisionTest { + + @Test + public void testOptimizelyDecision() { + String variationKey = "var1"; + boolean enabled = true; + OptimizelyJSON variables = new OptimizelyJSON("{\"k1\":\"v1\"}"); + String ruleKey = null; + String flagKey = "flag1"; + OptimizelyUserContext userContext = new OptimizelyUserContext(Optimizely.builder().build(), "tester"); + List reasons = new ArrayList<>(); + + OptimizelyDecision decision = new OptimizelyDecision( + variationKey, + enabled, + variables, + ruleKey, + flagKey, + userContext, + reasons + ); + + assertEquals(decision.getVariationKey(), variationKey); + assertEquals(decision.getEnabled(), enabled); + assertEquals(decision.getVariables(), variables); + assertEquals(decision.getRuleKey(), ruleKey); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), userContext); + assertEquals(decision.getReasons(), reasons); + } + + @Test + public void testNewErrorDecision() { + String flagKey = "flag1"; + OptimizelyUserContext userContext = new OptimizelyUserContext(Optimizely.builder().build(), "tester"); + String error = "SDK has an error"; + + OptimizelyDecision decision = OptimizelyDecision.newErrorDecision(flagKey, userContext, error); + + assertEquals(decision.getVariationKey(), null); + assertEquals(decision.getEnabled(), false); + assertTrue(decision.getVariables().isEmpty()); + assertEquals(decision.getRuleKey(), null); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), userContext); + assertEquals(decision.getReasons().size(), 1); + assertEquals(decision.getReasons().get(0), error); + } + +} diff --git a/core-api/src/test/resources/config/decide-project-config.json b/core-api/src/test/resources/config/decide-project-config.json new file mode 100644 index 000000000..eb7b0f802 --- /dev/null +++ b/core-api/src/test/resources/config/decide-project-config.json @@ -0,0 +1,346 @@ +{ + "version": "4", + "sendFlagDecisions": true, + "rollouts": [ + { + "experiments": [ + { + "audienceIds": ["13389130056"], + "forcedVariations": {}, + "id": "3332020515", + "key": "3332020515", + "layerId": "3319450668", + "status": "Running", + "trafficAllocation": [ + { + "endOfRange": 10000, + "entityId": "3324490633" + } + ], + "variations": [ + { + "featureEnabled": true, + "id": "3324490633", + "key": "3324490633", + "variables": [] + } + ] + }, + { + "audienceIds": ["12208130097"], + "forcedVariations": {}, + "id": "3332020494", + "key": "3332020494", + "layerId": "3319450668", + "status": "Running", + "trafficAllocation": [ + { + "endOfRange": 0, + "entityId": "3324490562" + } + ], + "variations": [ + { + "featureEnabled": true, + "id": "3324490562", + "key": "3324490562", + "variables": [] + } + ] + }, + { + "status": "Running", + "audienceIds": [], + "variations": [ + { + "variables": [], + "id": "18257766532", + "key": "18257766532", + "featureEnabled": true + } + ], + "id": "18322080788", + "key": "18322080788", + "layerId": "18263344648", + "trafficAllocation": [ + { + "entityId": "18257766532", + "endOfRange": 10000 + } + ], + "forcedVariations": { } + } + ], + "id": "3319450668" + } + ], + "anonymizeIP": true, + "botFiltering": true, + "projectId": "10431130345", + "variables": [], + "featureFlags": [ + { + "experimentIds": ["10390977673"], + "id": "4482920077", + "key": "feature_1", + "rolloutId": "3319450668", + "variables": [ + { + "defaultValue": "42", + "id": "2687470095", + "key": "i_42", + "type": "integer" + }, + { + "defaultValue": "4.2", + "id": "2689280165", + "key": "d_4_2", + "type": "double" + }, + { + "defaultValue": "true", + "id": "2689660112", + "key": "b_true", + "type": "boolean" + }, + { + "defaultValue": "foo", + "id": "2696150066", + "key": "s_foo", + "type": "string" + }, + { + "defaultValue": "{\"value\":1}", + "id": "2696150067", + "key": "j_1", + "type": "string", + "subType": "json" + }, + { + "defaultValue": "invalid", + "id": "2696150068", + "key": "i_1", + "type": "invalid", + "subType": "" + } + ] + }, + { + "experimentIds": ["10420810910"], + "id": "4482920078", + "key": "feature_2", + "rolloutId": "", + "variables": [ + { + "defaultValue": "42", + "id": "2687470095", + "key": "i_42", + "type": "integer" + } + ] + }, + { + "experimentIds": [], + "id": "44829230000", + "key": "feature_3", + "rolloutId": "", + "variables": [] + } + ], + "experiments": [ + { + "status": "Running", + "key": "exp_with_audience", + "layerId": "10420273888", + "trafficAllocation": [ + { + "entityId": "10389729780", + "endOfRange": 10000 + } + ], + "audienceIds": ["13389141123"], + "variations": [ + { + "variables": [], + "featureEnabled": true, + "id": "10389729780", + "key": "a" + }, + { + "variables": [], + "id": "10416523121", + "key": "b" + } + ], + "forcedVariations": {}, + "id": "10390977673" + }, + { + "status": "Running", + "key": "exp_no_audience", + "layerId": "10417730432", + "trafficAllocation": [ + { + "entityId": "10418551353", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "variations": [ + { + "variables": [], + "featureEnabled": true, + "id": "10418551353", + "key": "variation_with_traffic" + }, + { + "variables": [], + "featureEnabled": false, + "id": "10418510624", + "key": "variation_no_traffic" + } + ], + "forcedVariations": {}, + "id": "10420810910" + } + ], + "audiences": [ + { + "id": "13389141123", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"exact\", \"name\": \"gender\", \"type\": \"custom_attribute\", \"value\": \"f\"}]]]", + "name": "gender" + }, + { + "id": "13389130056", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"exact\", \"name\": \"country\", \"type\": \"custom_attribute\", \"value\": \"US\"}]]]", + "name": "US" + }, + { + "id": "12208130097", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"exact\", \"name\": \"browser\", \"type\": \"custom_attribute\", \"value\": \"safari\"}]]]", + "name": "safari" + }, + { + "id": "age_18", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"gt\", \"name\": \"age\", \"type\": \"custom_attribute\", \"value\": 18}]]]", + "name": "age_18" + }, + { + "id": "invalid_format", + "conditions": "[]", + "name": "invalid_format" + }, + { + "id": "invalid_condition", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"gt\", \"name\": \"age\", \"type\": \"custom_attribute\", \"value\": \"US\"}]]]", + "name": "invalid_condition" + }, + { + "id": "invalid_type", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"gt\", \"name\": \"age\", \"type\": \"invalid\", \"value\": 18}]]]", + "name": "invalid_type" + }, + { + "id": "invalid_match", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"invalid\", \"name\": \"age\", \"type\": \"custom_attribute\", \"value\": 18}]]]", + "name": "invalid_match" + }, + { + "id": "nil_value", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"gt\", \"name\": \"age\", \"type\": \"custom_attribute\"}]]]", + "name": "nil_value" + }, + { + "id": "invalid_name", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"gt\", \"type\": \"custom_attribute\", \"value\": 18}]]]", + "name": "invalid_name" + } + ], + "groups": [ + { + "policy": "random", + "trafficAllocation": [ + { + "entityId": "10390965532", + "endOfRange": 10000 + } + ], + "experiments": [ + { + "status": "Running", + "key": "group_exp_1", + "layerId": "10420222423", + "trafficAllocation": [ + { + "entityId": "10389752311", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "variations": [ + { + "variables": [], + "featureEnabled": false, + "id": "10389752311", + "key": "a" + } + ], + "forcedVariations": {}, + "id": "10390965532" + }, + { + "status": "Running", + "key": "group_exp_2", + "layerId": "10417730432", + "trafficAllocation": [ + { + "entityId": "10418524243", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "variations": [ + { + "variables": [], + "featureEnabled": false, + "id": "10418524243", + "key": "a" + } + ], + "forcedVariations": {}, + "id": "10420843432" + } + ], + "id": "13142870430" + } + ], + "attributes": [ + { + "id": "10401066117", + "key": "gender" + }, + { + "id": "10401066170", + "key": "testvar" + } + ], + "accountId": "10367498574", + "events": [ + { + "experimentIds": [ + "10420810910" + ], + "id": "10404198134", + "key": "event1" + }, + { + "experimentIds": [ + "10420810910", + "10390977673" + ], + "id": "10404198135", + "key": "event_multiple_running_exp_attached" + } + ], + "revision": "241" +} From 08e5ae815ac7b1a304771d008047cfdb230b5d58 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Tue, 15 Dec 2020 10:40:04 -0800 Subject: [PATCH 034/147] chore: fix sample codes to use decide apis (#414) --- .../src/main/java/com/optimizely/Example.java | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/java-quickstart/src/main/java/com/optimizely/Example.java b/java-quickstart/src/main/java/com/optimizely/Example.java index ade4ed679..04d7f78da 100644 --- a/java-quickstart/src/main/java/com/optimizely/Example.java +++ b/java-quickstart/src/main/java/com/optimizely/Example.java @@ -17,11 +17,12 @@ import com.optimizely.ab.Optimizely; import com.optimizely.ab.OptimizelyFactory; -import com.optimizely.ab.config.Variation; +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; +import com.optimizely.ab.optimizelyjson.OptimizelyJSON; import java.util.Collections; import java.util.Map; - import java.util.Random; import java.util.concurrent.TimeUnit; @@ -33,20 +34,23 @@ private Example(Optimizely optimizely) { this.optimizely = optimizely; } - private void processVisitor(String userId, Map attributes) { - Variation variation = optimizely.activate("background_experiment", userId, attributes); + private void processVisitor(String userId, Map attributes) { + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + + OptimizelyDecision decision = user.decide("eet_feature"); + String variationKey = decision.getVariationKey(); + + if (variationKey != null) { + boolean enabled = decision.getEnabled(); + System.out.println("[Example] feature enabled: " + enabled); - if (variation != null) { - optimizely.track("sample_conversion", userId, attributes); - System.out.println(String.format("Found variation %s", variation.getKey())); + OptimizelyJSON variables = decision.getVariables(); + System.out.println("[Example] feature variables: " + variables.toString()); + + user.trackEvent("eet_conversion"); } else { - System.out.println("didn't get a variation"); - } - - if (optimizely.isFeatureEnabled("eet_feature", userId, attributes)) { - optimizely.track("eet_conversion", userId, attributes); - System.out.println("feature enabled"); + System.out.println("[Example] decision failed: " + decision.getReasons().toString()); } } @@ -57,7 +61,7 @@ public static void main(String[] args) throws InterruptedException { Random random = new Random(); for (int i = 0; i < 10; i++) { - String userId = String.valueOf(random.nextInt()); + String userId = String.valueOf(random.nextInt(Integer.MAX_VALUE)); example.processVisitor(userId, Collections.emptyMap()); TimeUnit.MILLISECONDS.sleep(500); } From f46dfe4cc6061f410a142befe0b624a924e9c6c1 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Tue, 15 Dec 2020 10:59:28 -0800 Subject: [PATCH 035/147] chore: prepare for 3.8.0-beta release (#413) --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9ff2977b..97ac46949 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Optimizely Java X SDK Changelog +## [3.8.0-beta] +December 14th, 2020 + +### New Features + +- Introducing a new primary interface for retrieving feature flag status, configuration and associated experiment decisions for users. The new `OptimizelyUserContext` class is instantiated with `createUserContext` and exposes the following APIs ([#406](https://github.com/optimizely/java-sdk/pull/406)): + + - setAttribute + - decide + - decideAll + - decideForKeys + - trackEvent + +- For details, refer to our documentation page: [https://docs.developers.optimizely.com/full-stack/v4.0/docs/java-sdk](https://docs.developers.optimizely.com/full-stack/v4.0/docs/java-sdk). + ## [3.7.0] November 20th, 2020 From 651ccdc32d3890a6afdfe006da6b2f457144660e Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Mon, 11 Jan 2021 13:55:03 -0800 Subject: [PATCH 036/147] feat(decide): clone user-context before calling optimizely decide (#417) --- .../optimizely/ab/OptimizelyUserContext.java | 14 +++++++---- .../OptimizelyDecision.java | 23 ++++++++++++++++++- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java index 300d50d6f..55f380753 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -1,6 +1,6 @@ /** * - * Copyright 2020, Optimizely and contributors + * Copyright 2020-2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -61,13 +61,17 @@ public String getUserId() { } public Map getAttributes() { - return new HashMap(attributes); + return attributes; } public Optimizely getOptimizely() { return optimizely; } + public OptimizelyUserContext copy() { + return new OptimizelyUserContext(optimizely, userId, attributes); + } + /** * Set an attribute for a given key. * @@ -89,7 +93,7 @@ public void setAttribute(@Nonnull String key, @Nullable Object value) { */ public OptimizelyDecision decide(@Nonnull String key, @Nonnull List options) { - return optimizely.decide(this, key, options); + return optimizely.decide(copy(), key, options); } /** @@ -114,7 +118,7 @@ public OptimizelyDecision decide(@Nonnull String key) { */ public Map decideForKeys(@Nonnull List keys, @Nonnull List options) { - return optimizely.decideForKeys(this, keys, options); + return optimizely.decideForKeys(copy(), keys, options); } /** @@ -134,7 +138,7 @@ public Map decideForKeys(@Nonnull List keys) * @return All decision results mapped by flag keys. */ public Map decideAll(@Nonnull List options) { - return optimizely.decideAll(this, options); + return optimizely.decideAll(copy(), options); } /** diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecision.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecision.java index 201324f20..1741afbcd 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecision.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecision.java @@ -1,6 +1,6 @@ /** * - * Copyright 2020, Optimizely and contributors + * Copyright 2020-2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,23 +26,44 @@ import java.util.List; public class OptimizelyDecision { + /** + * The variation key of the decision. This value will be null when decision making fails. + */ @Nullable private final String variationKey; + /** + * The boolean value indicating if the flag is enabled or not. + */ private final boolean enabled; + /** + * The collection of variables associated with the decision. + */ @Nonnull private final OptimizelyJSON variables; + /** + * The rule key of the decision. + */ @Nullable private final String ruleKey; + /** + * The flag key for which the decision has been made for. + */ @Nonnull private final String flagKey; + /** + * A copy of the user context for which the decision has been made for. + */ @Nonnull private final OptimizelyUserContext userContext; + /** + * An array of error/info messages describing why the decision has been made. + */ @Nonnull private List reasons; From 5bb2588c2316b02ce1c123b7c3e4abac621eb99f Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Thu, 14 Jan 2021 09:20:43 -0800 Subject: [PATCH 037/147] fix(decide): change to return reasons as a part of tuple in decision hierarchy (#415) --- .../java/com/optimizely/ab/Optimizely.java | 46 +- .../com/optimizely/ab/bucketing/Bucketer.java | 50 +- .../ab/bucketing/DecisionService.java | 291 ++++++------ .../ab/config/audience/AndCondition.java | 8 +- .../config/audience/AudienceIdCondition.java | 13 +- .../ab/config/audience/Condition.java | 6 +- .../ab/config/audience/EmptyCondition.java | 5 +- .../ab/config/audience/NotCondition.java | 10 +- .../ab/config/audience/NullCondition.java | 6 +- .../ab/config/audience/OrCondition.java | 7 +- .../ab/config/audience/UserAttribute.java | 27 +- .../ab/internal/ExperimentUtils.java | 109 ++--- .../optimizelydecision/DecisionReasons.java | 28 +- .../optimizelydecision/DecisionResponse.java | 48 ++ .../DefaultDecisionReasons.java | 45 +- .../ErrorsDecisionReasons.java | 56 --- .../com/optimizely/ab/OptimizelyTest.java | 45 +- .../ab/OptimizelyUserContextTest.java | 212 ++++----- .../optimizely/ab/bucketing/BucketerTest.java | 32 +- .../ab/bucketing/DecisionServiceTest.java | 213 ++++----- .../AudienceConditionEvaluationTest.java | 444 +++++++++--------- .../ab/event/internal/EventFactoryTest.java | 7 +- .../ab/internal/ExperimentUtilsTest.java | 20 +- 23 files changed, 837 insertions(+), 891 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionResponse.java delete mode 100644 core-api/src/main/java/com/optimizely/ab/optimizelydecision/ErrorsDecisionReasons.java diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 75979e335..4440608b3 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2016-2020, Optimizely, Inc. and contributors * + * Copyright 2016-2021, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -34,11 +34,7 @@ import com.optimizely.ab.optimizelyconfig.OptimizelyConfig; import com.optimizely.ab.optimizelyconfig.OptimizelyConfigManager; import com.optimizely.ab.optimizelyconfig.OptimizelyConfigService; -import com.optimizely.ab.optimizelydecision.DecisionMessage; -import com.optimizely.ab.optimizelydecision.DecisionReasons; -import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; -import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; -import com.optimizely.ab.optimizelydecision.OptimizelyDecision; +import com.optimizely.ab.optimizelydecision.*; import com.optimizely.ab.optimizelyjson.OptimizelyJSON; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -427,7 +423,7 @@ private Boolean isFeatureEnabled(@Nonnull ProjectConfig projectConfig, Map copiedAttributes = copyAttributes(attributes); FeatureDecision.DecisionSource decisionSource = FeatureDecision.DecisionSource.ROLLOUT; - FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, copiedAttributes, projectConfig); + FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, copiedAttributes, projectConfig).getResult(); Boolean featureEnabled = false; SourceInfo sourceInfo = new RolloutSourceInfo(); if (featureDecision.decisionSource != null) { @@ -736,7 +732,7 @@ T getFeatureVariableValueForType(@Nonnull String featureKey, String variableValue = variable.getDefaultValue(); Map copiedAttributes = copyAttributes(attributes); - FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, copiedAttributes, projectConfig); + FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, copiedAttributes, projectConfig).getResult(); Boolean featureEnabled = false; if (featureDecision.variation != null) { if (featureDecision.variation.getFeatureEnabled()) { @@ -869,7 +865,7 @@ public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, } Map copiedAttributes = copyAttributes(attributes); - FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, copiedAttributes, projectConfig); + FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, copiedAttributes, projectConfig).getResult(); Boolean featureEnabled = false; Variation variation = featureDecision.variation; @@ -970,7 +966,7 @@ private Variation getVariation(@Nonnull ProjectConfig projectConfig, @Nonnull String userId, @Nonnull Map attributes) throws UnknownExperimentException { Map copiedAttributes = copyAttributes(attributes); - Variation variation = decisionService.getVariation(experiment, userId, copiedAttributes, projectConfig); + Variation variation = decisionService.getVariation(experiment, userId, copiedAttributes, projectConfig).getResult(); String notificationType = NotificationCenter.DecisionNotificationType.AB_TEST.toString(); @@ -1084,7 +1080,7 @@ public Variation getForcedVariation(@Nonnull String experimentKey, return null; } - return decisionService.getForcedVariation(experiment, userId); + return decisionService.getForcedVariation(experiment, userId).getResult(); } /** @@ -1182,13 +1178,14 @@ OptimizelyDecision decide(@Nonnull OptimizelyUserContext user, DecisionReasons decisionReasons = DefaultDecisionReasons.newInstance(allOptions); Map copiedAttributes = new HashMap<>(attributes); - FeatureDecision flagDecision = decisionService.getVariationForFeature( + DecisionResponse decisionVariation = decisionService.getVariationForFeature( flag, userId, copiedAttributes, projectConfig, - allOptions, - decisionReasons); + allOptions); + FeatureDecision flagDecision = decisionVariation.getResult(); + decisionReasons.merge(decisionVariation.getReasons()); Boolean flagEnabled = false; if (flagDecision.variation != null) { @@ -1200,11 +1197,12 @@ OptimizelyDecision decide(@Nonnull OptimizelyUserContext user, Map variableMap = new HashMap<>(); if (!allOptions.contains(OptimizelyDecideOption.EXCLUDE_VARIABLES)) { - variableMap = getDecisionVariableMap( + DecisionResponse> decisionVariables = getDecisionVariableMap( flag, flagDecision.variation, - flagEnabled, - decisionReasons); + flagEnabled); + variableMap = decisionVariables.getResult(); + decisionReasons.merge(decisionVariables.getReasons()); } OptimizelyJSON optimizelyJSON = new OptimizelyJSON(variableMap); @@ -1305,10 +1303,12 @@ private List getAllOptions(List return copiedOptions; } - private Map getDecisionVariableMap(@Nonnull FeatureFlag flag, - @Nonnull Variation variation, - @Nonnull Boolean featureEnabled, - @Nonnull DecisionReasons decisionReasons) { + @Nonnull + private DecisionResponse> getDecisionVariableMap(@Nonnull FeatureFlag flag, + @Nonnull Variation variation, + @Nonnull Boolean featureEnabled) { + DecisionReasons reasons = new DecisionReasons(); + Map valuesMap = new HashMap(); for (FeatureVariable variable : flag.getVariables()) { String value = variable.getDefaultValue(); @@ -1321,7 +1321,7 @@ private Map getDecisionVariableMap(@Nonnull FeatureFlag flag, Object convertedValue = convertStringToType(value, variable.getType()); if (convertedValue == null) { - decisionReasons.addError(DecisionMessage.VARIABLE_VALUE_INVALID.reason(variable.getKey())); + reasons.addError(DecisionMessage.VARIABLE_VALUE_INVALID.reason(variable.getKey())); } else if (convertedValue instanceof OptimizelyJSON) { convertedValue = ((OptimizelyJSON) convertedValue).toMap(); } @@ -1329,7 +1329,7 @@ private Map getDecisionVariableMap(@Nonnull FeatureFlag flag, valuesMap.put(variable.getKey(), convertedValue); } - return valuesMap; + return new DecisionResponse(valuesMap, reasons); } /** diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java b/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java index 0429dd585..b92d2cf15 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, 2019, Optimizely and contributors + * Copyright 2016-2017, 2019-2021 Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,12 +20,12 @@ import com.optimizely.ab.bucketing.internal.MurmurHash3; import com.optimizely.ab.config.*; import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.DecisionResponse; import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; -import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import java.util.List; @@ -69,8 +69,7 @@ private String bucketToEntity(int bucketValue, List trafficAl private Experiment bucketToExperiment(@Nonnull Group group, @Nonnull String bucketingId, - @Nonnull ProjectConfig projectConfig, - @Nonnull DecisionReasons reasons) { + @Nonnull ProjectConfig projectConfig) { // "salt" the bucket id using the group id String bucketKey = bucketingId + group.getId(); @@ -89,9 +88,11 @@ private Experiment bucketToExperiment(@Nonnull Group group, return null; } - private Variation bucketToVariation(@Nonnull Experiment experiment, - @Nonnull String bucketingId, - @Nonnull DecisionReasons reasons) { + @Nonnull + private DecisionResponse bucketToVariation(@Nonnull Experiment experiment, + @Nonnull String bucketingId) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + // "salt" the bucket id using the experiment id String experimentId = experiment.getId(); String experimentKey = experiment.getKey(); @@ -111,13 +112,13 @@ private Variation bucketToVariation(@Nonnull Experiment experiment, experimentKey); logger.info(message); - return bucketedVariation; + return new DecisionResponse(bucketedVariation, reasons); } // user was not bucketed to a variation String message = reasons.addInfo("User with bucketingId \"%s\" is not in any variation of experiment \"%s\".", bucketingId, experimentKey); logger.info(message); - return null; + return new DecisionResponse(null, reasons); } /** @@ -126,14 +127,14 @@ private Variation bucketToVariation(@Nonnull Experiment experiment, * @param experiment The Experiment in which the user is to be bucketed. * @param bucketingId string A customer-assigned value used to create the key for the murmur hash. * @param projectConfig The current projectConfig - * @param reasons Decision log messages - * @return Variation the user is bucketed into or null. + * @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) and the decision reasons */ - @Nullable - public Variation bucket(@Nonnull Experiment experiment, - @Nonnull String bucketingId, - @Nonnull ProjectConfig projectConfig, - @Nonnull DecisionReasons reasons) { + @Nonnull + public DecisionResponse bucket(@Nonnull Experiment experiment, + @Nonnull String bucketingId, + @Nonnull ProjectConfig projectConfig) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + // ---------- Bucket User ---------- String groupId = experiment.getGroupId(); // check whether the experiment belongs to a group @@ -141,11 +142,11 @@ public Variation bucket(@Nonnull Experiment experiment, Group experimentGroup = projectConfig.getGroupIdMapping().get(groupId); // bucket to an experiment only if group entities are to be mutually exclusive if (experimentGroup.getPolicy().equals(Group.RANDOM_POLICY)) { - Experiment bucketedExperiment = bucketToExperiment(experimentGroup, bucketingId, projectConfig, reasons); + Experiment bucketedExperiment = bucketToExperiment(experimentGroup, bucketingId, projectConfig); if (bucketedExperiment == null) { String message = reasons.addInfo("User with bucketingId \"%s\" is not in any experiment of group %s.", bucketingId, experimentGroup.getId()); logger.info(message); - return null; + return new DecisionResponse(null, reasons); } else { } @@ -155,7 +156,7 @@ public Variation bucket(@Nonnull Experiment experiment, String message = reasons.addInfo("User with bucketingId \"%s\" is not in experiment \"%s\" of group %s.", bucketingId, experiment.getKey(), experimentGroup.getId()); logger.info(message); - return null; + return new DecisionResponse(null, reasons); } String message = reasons.addInfo("User with bucketingId \"%s\" is in experiment \"%s\" of group %s.", bucketingId, experiment.getKey(), @@ -164,14 +165,9 @@ public Variation bucket(@Nonnull Experiment experiment, } } - return bucketToVariation(experiment, bucketingId, reasons); - } - - @Nullable - public Variation bucket(@Nonnull Experiment experiment, - @Nonnull String bucketingId, - @Nonnull ProjectConfig projectConfig) { - return bucket(experiment, bucketingId, projectConfig, DefaultDecisionReasons.newInstance()); + DecisionResponse decisionResponse = bucketToVariation(experiment, bucketingId); + reasons.merge(decisionResponse.getReasons()); + return new DecisionResponse<>(decisionResponse.getResult(), reasons); } //======== Helper methods ========// diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java index 588f6eefb..8f7eeaca5 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2017-2020, Optimizely, Inc. and contributors * + * Copyright 2017-2021, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -21,6 +21,7 @@ import com.optimizely.ab.internal.ControlAttribute; import com.optimizely.ab.internal.ExperimentUtils; import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.DecisionResponse; import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import org.slf4j.Logger; @@ -86,30 +87,36 @@ public DecisionService(@Nonnull Bucketer bucketer, * @param filteredAttributes The user's attributes. This should be filtered to just attributes in the Datafile. * @param projectConfig The current projectConfig * @param options An array of decision options - * @param reasons Decision log messages - * @return The {@link Variation} the user is allocated into. + * @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) and the decision reasons */ - @Nullable - public Variation getVariation(@Nonnull Experiment experiment, - @Nonnull String userId, - @Nonnull Map filteredAttributes, - @Nonnull ProjectConfig projectConfig, - @Nonnull List options, - @Nonnull DecisionReasons reasons) { - if (!ExperimentUtils.isExperimentActive(experiment, reasons)) { - return null; + @Nonnull + public DecisionResponse getVariation(@Nonnull Experiment experiment, + @Nonnull String userId, + @Nonnull Map filteredAttributes, + @Nonnull ProjectConfig projectConfig, + @Nonnull List options) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + + if (!ExperimentUtils.isExperimentActive(experiment)) { + String message = reasons.addInfo("Experiment \"%s\" is not running.", experiment.getKey()); + logger.info(message); + return new DecisionResponse(null, reasons); } // look for forced bucketing first. - Variation variation = getForcedVariation(experiment, userId, reasons); + DecisionResponse decisionVariation = getForcedVariation(experiment, userId); + reasons.merge(decisionVariation.getReasons()); + Variation variation = decisionVariation.getResult(); // check for whitelisting if (variation == null) { - variation = getWhitelistedVariation(experiment, userId, reasons); + decisionVariation = getWhitelistedVariation(experiment, userId); + reasons.merge(decisionVariation.getReasons()); + variation = decisionVariation.getResult(); } if (variation != null) { - return variation; + return new DecisionResponse(variation, reasons); } // fetch the user profile map from the user profile service @@ -136,42 +143,49 @@ public Variation getVariation(@Nonnull Experiment experiment, // check if user exists in user profile if (userProfile != null) { - variation = getStoredVariation(experiment, userProfile, projectConfig, reasons); + decisionVariation = getStoredVariation(experiment, userProfile, projectConfig); + reasons.merge(decisionVariation.getReasons()); + variation = decisionVariation.getResult(); // return the stored variation if it exists if (variation != null) { - return variation; + return new DecisionResponse(variation, reasons); } } else { // if we could not find a user profile, make a new one userProfile = new UserProfile(userId, new HashMap()); } } - if (ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, experiment, filteredAttributes, EXPERIMENT, experiment.getKey(), reasons)) { - String bucketingId = getBucketingId(userId, filteredAttributes, reasons); - variation = bucketer.bucket(experiment, bucketingId, projectConfig, reasons); + DecisionResponse decisionMeetAudience = ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, experiment, filteredAttributes, EXPERIMENT, experiment.getKey()); + reasons.merge(decisionMeetAudience.getReasons()); + if (decisionMeetAudience.getResult()) { + String bucketingId = getBucketingId(userId, filteredAttributes); + + decisionVariation = bucketer.bucket(experiment, bucketingId, projectConfig); + reasons.merge(decisionVariation.getReasons()); + variation = decisionVariation.getResult(); if (variation != null) { if (userProfileService != null && !ignoreUPS) { - saveVariation(experiment, variation, userProfile, reasons); + saveVariation(experiment, variation, userProfile); } else { logger.debug("This decision will not be saved since the UserProfileService is null."); } } - return variation; + return new DecisionResponse(variation, reasons); } String message = reasons.addInfo("User \"%s\" does not meet conditions to be in experiment \"%s\".", userId, experiment.getKey()); logger.info(message); - return null; + return new DecisionResponse(null, reasons); } - @Nullable - public Variation getVariation(@Nonnull Experiment experiment, - @Nonnull String userId, - @Nonnull Map filteredAttributes, - @Nonnull ProjectConfig projectConfig) { - return getVariation(experiment, userId, filteredAttributes, projectConfig, Collections.emptyList(), DefaultDecisionReasons.newInstance()); + @Nonnull + public DecisionResponse getVariation(@Nonnull Experiment experiment, + @Nonnull String userId, + @Nonnull Map filteredAttributes, + @Nonnull ProjectConfig projectConfig) { + return getVariation(experiment, userId, filteredAttributes, projectConfig, Collections.emptyList()); } /** @@ -182,22 +196,28 @@ public Variation getVariation(@Nonnull Experiment experiment, * @param filteredAttributes A map of filtered attributes. * @param projectConfig The current projectConfig * @param options An array of decision options - * @param reasons Decision log messages - * @return {@link FeatureDecision} + * @return A {@link DecisionResponse} including a {@link FeatureDecision} and the decision reasons */ @Nonnull - public FeatureDecision getVariationForFeature(@Nonnull FeatureFlag featureFlag, - @Nonnull String userId, - @Nonnull Map filteredAttributes, - @Nonnull ProjectConfig projectConfig, - @Nonnull List options, - @Nonnull DecisionReasons reasons) { + public DecisionResponse getVariationForFeature(@Nonnull FeatureFlag featureFlag, + @Nonnull String userId, + @Nonnull Map filteredAttributes, + @Nonnull ProjectConfig projectConfig, + @Nonnull List options) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + if (!featureFlag.getExperimentIds().isEmpty()) { for (String experimentId : featureFlag.getExperimentIds()) { Experiment experiment = projectConfig.getExperimentIdMapping().get(experimentId); - Variation variation = getVariation(experiment, userId, filteredAttributes, projectConfig, options, reasons); + + DecisionResponse decisionVariation = getVariation(experiment, userId, filteredAttributes, projectConfig, options); + reasons.merge(decisionVariation.getReasons()); + Variation variation = decisionVariation.getResult(); + if (variation != null) { - return new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.FEATURE_TEST); + return new DecisionResponse( + new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.FEATURE_TEST), + reasons); } } } else { @@ -205,7 +225,10 @@ public FeatureDecision getVariationForFeature(@Nonnull FeatureFlag featureFlag, logger.info(message); } - FeatureDecision featureDecision = getVariationForFeatureInRollout(featureFlag, userId, filteredAttributes, projectConfig, reasons); + DecisionResponse decisionFeature = getVariationForFeatureInRollout(featureFlag, userId, filteredAttributes, projectConfig); + reasons.merge(decisionFeature.getReasons()); + FeatureDecision featureDecision = decisionFeature.getResult(); + if (featureDecision.variation == null) { String message = reasons.addInfo("The user \"%s\" was not bucketed into a rollout for feature flag \"%s\".", userId, featureFlag.getKey()); @@ -215,16 +238,15 @@ public FeatureDecision getVariationForFeature(@Nonnull FeatureFlag featureFlag, userId, featureFlag.getKey()); logger.info(message); } - return featureDecision; + return new DecisionResponse(featureDecision, reasons); } @Nonnull - public FeatureDecision getVariationForFeature(@Nonnull FeatureFlag featureFlag, - @Nonnull String userId, - @Nonnull Map filteredAttributes, - @Nonnull ProjectConfig projectConfig) { - - return getVariationForFeature(featureFlag, userId, filteredAttributes, projectConfig,Collections.emptyList(), DefaultDecisionReasons.newInstance()); + public DecisionResponse getVariationForFeature(@Nonnull FeatureFlag featureFlag, + @Nonnull String userId, + @Nonnull Map filteredAttributes, + @Nonnull ProjectConfig projectConfig) { + return getVariationForFeature(featureFlag, userId, filteredAttributes, projectConfig,Collections.emptyList()); } /** @@ -236,42 +258,52 @@ public FeatureDecision getVariationForFeature(@Nonnull FeatureFlag featureFlag, * @param userId User Identifier * @param filteredAttributes A map of filtered attributes. * @param projectConfig The current projectConfig - * @param reasons Decision log messages - * @return {@link FeatureDecision} + * @return A {@link DecisionResponse} including a {@link FeatureDecision} and the decision reasons */ @Nonnull - FeatureDecision getVariationForFeatureInRollout(@Nonnull FeatureFlag featureFlag, - @Nonnull String userId, - @Nonnull Map filteredAttributes, - @Nonnull ProjectConfig projectConfig, - @Nonnull DecisionReasons reasons) { + DecisionResponse getVariationForFeatureInRollout(@Nonnull FeatureFlag featureFlag, + @Nonnull String userId, + @Nonnull Map filteredAttributes, + @Nonnull ProjectConfig projectConfig) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + // use rollout to get variation for feature if (featureFlag.getRolloutId().isEmpty()) { String message = reasons.addInfo("The feature flag \"%s\" is not used in a rollout.", featureFlag.getKey()); logger.info(message); - return new FeatureDecision(null, null, null); + return new DecisionResponse(new FeatureDecision(null, null, null), reasons); } Rollout rollout = projectConfig.getRolloutIdMapping().get(featureFlag.getRolloutId()); if (rollout == null) { String message = reasons.addInfo("The rollout with id \"%s\" was not found in the datafile for feature flag \"%s\".", featureFlag.getRolloutId(), featureFlag.getKey()); logger.error(message); - return new FeatureDecision(null, null, null); + return new DecisionResponse(new FeatureDecision(null, null, null), reasons); } // for all rules before the everyone else rule int rolloutRulesLength = rollout.getExperiments().size(); - String bucketingId = getBucketingId(userId, filteredAttributes, reasons); + String bucketingId = getBucketingId(userId, filteredAttributes); + Variation variation; + DecisionResponse decisionMeetAudience; + DecisionResponse decisionVariation; for (int i = 0; i < rolloutRulesLength - 1; i++) { Experiment rolloutRule = rollout.getExperiments().get(i); - if (ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, rolloutRule, filteredAttributes, RULE, Integer.toString(i + 1), reasons)) { - variation = bucketer.bucket(rolloutRule, bucketingId, projectConfig, reasons); + + decisionMeetAudience = ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, rolloutRule, filteredAttributes, RULE, Integer.toString(i + 1)); + reasons.merge(decisionMeetAudience.getReasons()); + if (decisionMeetAudience.getResult()) { + decisionVariation = bucketer.bucket(rolloutRule, bucketingId, projectConfig); + reasons.merge(decisionVariation.getReasons()); + variation = decisionVariation.getResult(); + if (variation == null) { break; } - return new FeatureDecision(rolloutRule, variation, - FeatureDecision.DecisionSource.ROLLOUT); + return new DecisionResponse( + new FeatureDecision(rolloutRule, variation, FeatureDecision.DecisionSource.ROLLOUT), + reasons); } else { String message = reasons.addInfo("User \"%s\" does not meet conditions for targeting rule \"%d\".", userId, i + 1); logger.debug(message); @@ -280,24 +312,23 @@ FeatureDecision getVariationForFeatureInRollout(@Nonnull FeatureFlag featureFlag // get last rule which is the fall back rule Experiment finalRule = rollout.getExperiments().get(rolloutRulesLength - 1); - if (ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, finalRule, filteredAttributes, RULE, "Everyone Else", reasons)) { - variation = bucketer.bucket(finalRule, bucketingId, projectConfig, reasons); + + decisionMeetAudience = ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, finalRule, filteredAttributes, RULE, "Everyone Else"); + reasons.merge(decisionMeetAudience.getReasons()); + if (decisionMeetAudience.getResult()) { + decisionVariation = bucketer.bucket(finalRule, bucketingId, projectConfig); + variation = decisionVariation.getResult(); + reasons.merge(decisionVariation.getReasons()); + if (variation != null) { String message = reasons.addInfo("User \"%s\" meets conditions for targeting rule \"Everyone Else\".", userId); logger.debug(message); - return new FeatureDecision(finalRule, variation, - FeatureDecision.DecisionSource.ROLLOUT); + return new DecisionResponse( + new FeatureDecision(finalRule, variation, FeatureDecision.DecisionSource.ROLLOUT), + reasons); } } - return new FeatureDecision(null, null, null); - } - - @Nonnull - FeatureDecision getVariationForFeatureInRollout(@Nonnull FeatureFlag featureFlag, - @Nonnull String userId, - @Nonnull Map filteredAttributes, - @Nonnull ProjectConfig projectConfig) { - return getVariationForFeatureInRollout(featureFlag, userId, filteredAttributes, projectConfig, DefaultDecisionReasons.newInstance()); + return new DecisionResponse(new FeatureDecision(null, null, null), reasons); } /** @@ -305,14 +336,14 @@ FeatureDecision getVariationForFeatureInRollout(@Nonnull FeatureFlag featureFlag * * @param experiment {@link Experiment} in which user is to be bucketed. * @param userId User Identifier - * @param reasons Decision log messages - * @return null if the user is not whitelisted into any variation - * {@link Variation} the user is bucketed into if the user has a specified whitelisted variation. + * @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) + * and the decision reasons. The variation can be null if the user is not whitelisted into any variation. */ - @Nullable - Variation getWhitelistedVariation(@Nonnull Experiment experiment, - @Nonnull String userId, - @Nonnull DecisionReasons reasons) { + @Nonnull + DecisionResponse getWhitelistedVariation(@Nonnull Experiment experiment, + @Nonnull String userId) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + // if a user has a forced variation mapping, return the respective variation Map userIdToVariationKeyMap = experiment.getUserIdToVariationKeyMap(); if (userIdToVariationKeyMap.containsKey(userId)) { @@ -326,16 +357,9 @@ Variation getWhitelistedVariation(@Nonnull Experiment experiment, forcedVariationKey, userId); logger.error(message); } - return forcedVariation; + return new DecisionResponse(forcedVariation, reasons); } - return null; - } - - @Nullable - Variation getWhitelistedVariation(@Nonnull Experiment experiment, - @Nonnull String userId) { - - return getWhitelistedVariation(experiment, userId, DefaultDecisionReasons.newInstance()); + return new DecisionResponse(null, reasons); } /** @@ -344,15 +368,14 @@ Variation getWhitelistedVariation(@Nonnull Experiment experiment, * @param experiment {@link Experiment} in which the user was bucketed. * @param userProfile {@link UserProfile} of the user. * @param projectConfig The current projectConfig - * @param reasons Decision log messages - * @return null if the {@link UserProfileService} implementation is null or the user was not previously bucketed. - * else return the {@link Variation} the user was previously bucketed into. + * @return A {@link DecisionResponse} including the {@link Variation} that user was previously bucketed into (or null) + * and the decision reasons. The variation can be null if the {@link UserProfileService} implementation is null or the user was not previously bucketed. */ - @Nullable - Variation getStoredVariation(@Nonnull Experiment experiment, - @Nonnull UserProfile userProfile, - @Nonnull ProjectConfig projectConfig, - @Nonnull DecisionReasons reasons) { + @Nonnull + DecisionResponse getStoredVariation(@Nonnull Experiment experiment, + @Nonnull UserProfile userProfile, + @Nonnull ProjectConfig projectConfig) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); // ---------- Check User Profile for Sticky Bucketing ---------- // If a user profile instance is present then check it for a saved variation @@ -371,40 +394,31 @@ Variation getStoredVariation(@Nonnull Experiment experiment, savedVariation.getKey(), experimentKey, userProfile.userId); logger.info(message); // A variation is stored for this combined bucket id - return savedVariation; + return new DecisionResponse(savedVariation, reasons); } else { String message = reasons.addInfo("User \"%s\" was previously bucketed into variation with ID \"%s\" for experiment \"%s\", but no matching variation was found for that user. We will re-bucket the user.", userProfile.userId, variationId, experimentKey); logger.info(message); - return null; + return new DecisionResponse(null, reasons); } } else { String message = reasons.addInfo("No previously activated variation of experiment \"%s\" for user \"%s\" found in user profile.", experimentKey, userProfile.userId); logger.info(message); - return null; + return new DecisionResponse(null, reasons); } } - @Nullable - Variation getStoredVariation(@Nonnull Experiment experiment, - @Nonnull UserProfile userProfile, - @Nonnull ProjectConfig projectConfig) { - return getStoredVariation(experiment, userProfile, projectConfig, DefaultDecisionReasons.newInstance()); - } - /** * Save a {@link Variation} of an {@link Experiment} for a user in the {@link UserProfileService}. * * @param experiment The experiment the user was buck * @param variation The Variation to save. * @param userProfile A {@link UserProfile} instance of the user information. - * @param reasons Decision log messages */ void saveVariation(@Nonnull Experiment experiment, @Nonnull Variation variation, - @Nonnull UserProfile userProfile, - @Nonnull DecisionReasons reasons) { + @Nonnull UserProfile userProfile) { // only save if the user has implemented a user profile service if (userProfileService != null) { @@ -431,42 +445,28 @@ void saveVariation(@Nonnull Experiment experiment, } } - void saveVariation(@Nonnull Experiment experiment, - @Nonnull Variation variation, - @Nonnull UserProfile userProfile) { - saveVariation(experiment, variation, userProfile, DefaultDecisionReasons.newInstance()); - } - /** * Get the bucketingId of a user if a bucketingId exists in attributes, or else default to userId. * * @param userId The userId of the user. * @param filteredAttributes The user's attributes. This should be filtered to just attributes in the Datafile. - * @param reasons Decision log messages * @return bucketingId if it is a String type in attributes. * else return userId */ String getBucketingId(@Nonnull String userId, - @Nonnull Map filteredAttributes, - @Nonnull DecisionReasons reasons) { + @Nonnull Map filteredAttributes) { String bucketingId = userId; if (filteredAttributes != null && filteredAttributes.containsKey(ControlAttribute.BUCKETING_ATTRIBUTE.toString())) { if (String.class.isInstance(filteredAttributes.get(ControlAttribute.BUCKETING_ATTRIBUTE.toString()))) { bucketingId = (String) filteredAttributes.get(ControlAttribute.BUCKETING_ATTRIBUTE.toString()); logger.debug("BucketingId is valid: \"{}\"", bucketingId); } else { - String message = reasons.addInfo("BucketingID attribute is not a string. Defaulted to userId"); - logger.warn(message); + logger.warn("BucketingID attribute is not a string. Defaulted to userId"); } } return bucketingId; } - String getBucketingId(@Nonnull String userId, - @Nonnull Map filteredAttributes) { - return getBucketingId(userId, filteredAttributes, DefaultDecisionReasons.newInstance()); - } - public ConcurrentHashMap> getForcedVariationMapping() { return forcedVariationMapping; } @@ -500,6 +500,7 @@ public boolean setForcedVariation(@Nonnull Experiment experiment, // if the user id is invalid, return false. if (!validateUserId(userId)) { + logger.error("User ID is invalid"); return false; } @@ -545,17 +546,18 @@ public boolean setForcedVariation(@Nonnull Experiment experiment, * * @param experiment The experiment forced. * @param userId The user ID to be used for bucketing. - * @param reasons Decision log messages - * @return The variation the user was bucketed into. This value can be null if the - * forced variation fails. + * @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) + * and the decision reasons. The variation can be null if the forced variation fails. */ - @Nullable - public Variation getForcedVariation(@Nonnull Experiment experiment, - @Nonnull String userId, - @Nonnull DecisionReasons reasons) { - // if the user id is invalid, return false. + @Nonnull + public DecisionResponse getForcedVariation(@Nonnull Experiment experiment, + @Nonnull String userId) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + if (!validateUserId(userId)) { - return null; + String message = reasons.addInfo("User ID is invalid"); + logger.error(message); + return new DecisionResponse(null, reasons); } Map experimentToVariation = getForcedVariationMapping().get(userId); @@ -567,19 +569,13 @@ public Variation getForcedVariation(@Nonnull Experiment experiment, String message = reasons.addInfo("Variation \"%s\" is mapped to experiment \"%s\" and user \"%s\" in the forced variation map", variation.getKey(), experiment.getKey(), userId); logger.debug(message); - return variation; + return new DecisionResponse(variation, reasons); } } else { logger.debug("No variation for experiment \"{}\" mapped to user \"{}\" in the forced variation map ", experiment.getKey(), userId); } } - return null; - } - - @Nullable - public Variation getForcedVariation(@Nonnull Experiment experiment, - @Nonnull String userId) { - return getForcedVariation(experiment, userId, DefaultDecisionReasons.newInstance()); + return new DecisionResponse(null, reasons); } /** @@ -589,12 +585,7 @@ public Variation getForcedVariation(@Nonnull Experiment experiment, * @return whether the user ID is valid */ private boolean validateUserId(String userId) { - if (userId == null) { - logger.error("User ID is invalid"); - return false; - } - - return true; + return (userId != null); } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java index 20c15e95d..8b458d059 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java @@ -17,10 +17,10 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.optimizelydecision.DecisionReasons; import javax.annotation.Nonnull; import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; import java.util.List; import java.util.Map; @@ -40,9 +40,7 @@ public List getConditions() { } @Nullable - public Boolean evaluate(ProjectConfig config, - Map attributes, - DecisionReasons reasons) { + public Boolean evaluate(ProjectConfig config, Map attributes) { if (conditions == null) return null; boolean foundNull = false; // According to the matrix where: @@ -53,7 +51,7 @@ public Boolean evaluate(ProjectConfig config, // true and true is true // null and null is null for (Condition condition : conditions) { - Boolean conditionEval = condition.evaluate(config, attributes, reasons); + Boolean conditionEval = condition.evaluate(config, attributes); if (conditionEval == null) { foundNull = true; } else if (!conditionEval) { // false with nulls or trues is false. diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java index fb076a90d..57a4e5bec 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java @@ -18,12 +18,14 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.internal.InvalidAudienceCondition; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; import java.util.Map; import java.util.Objects; @@ -64,19 +66,16 @@ public String getAudienceId() { @Nullable @Override - public Boolean evaluate(ProjectConfig config, - Map attributes, - DecisionReasons reasons) { + public Boolean evaluate(ProjectConfig config, Map attributes) { if (config != null) { audience = config.getAudienceIdMapping().get(audienceId); } if (audience == null) { - String message = reasons.addInfo("Audience %s could not be found.", audienceId); - logger.error(message); + logger.error("Audience {} could not be found.", audienceId); return null; } logger.debug("Starting to evaluate audience \"{}\" with conditions: {}.", audience.getId(), audience.getConditions()); - Boolean result = audience.getConditions().evaluate(config, attributes, reasons); + Boolean result = audience.getConditions().evaluate(config, attributes); logger.debug("Audience \"{}\" evaluated to {}.", audience.getId(), result); return result; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java index 4d108214c..772d2b03e 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java @@ -17,7 +17,6 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.optimizelydecision.DecisionReasons; import javax.annotation.Nullable; import java.util.Map; @@ -28,8 +27,5 @@ public interface Condition { @Nullable - Boolean evaluate(ProjectConfig config, - Map attributes, - DecisionReasons reasons); - + Boolean evaluate(ProjectConfig config, Map attributes); } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java index b5978d200..8f8aedeae 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java @@ -16,7 +16,6 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.optimizelydecision.DecisionReasons; import javax.annotation.Nullable; import java.util.Map; @@ -24,9 +23,7 @@ public class EmptyCondition implements Condition { @Nullable @Override - public Boolean evaluate(ProjectConfig config, - Map attributes, - DecisionReasons reasons) { + public Boolean evaluate(ProjectConfig config, Map attributes) { return true; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java index 8a523bb8d..b7f45f2ac 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java @@ -17,11 +17,11 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.optimizelydecision.DecisionReasons; -import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; +import javax.annotation.Nonnull; + import java.util.Map; /** @@ -41,11 +41,9 @@ public Condition getCondition() { } @Nullable - public Boolean evaluate(ProjectConfig config, - Map attributes, - DecisionReasons reasons) { + public Boolean evaluate(ProjectConfig config, Map attributes) { - Boolean conditionEval = condition == null ? null : condition.evaluate(config, attributes, reasons); + Boolean conditionEval = condition == null ? null : condition.evaluate(config, attributes); return (conditionEval == null ? null : !conditionEval); } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java index ef76d92ad..fcf5100db 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java @@ -16,7 +16,6 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.optimizelydecision.DecisionReasons; import javax.annotation.Nullable; import java.util.Map; @@ -24,10 +23,7 @@ public class NullCondition implements Condition { @Nullable @Override - public Boolean evaluate(ProjectConfig config, - Map attributes, - DecisionReasons reasons) { + public Boolean evaluate(ProjectConfig config, Map attributes) { return null; } - } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java index b2c2f0afe..70572a9a9 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java @@ -17,7 +17,6 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.optimizelydecision.DecisionReasons; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -46,13 +45,11 @@ public List getConditions() { // false or false is false // null or null is null @Nullable - public Boolean evaluate(ProjectConfig config, - Map attributes, - DecisionReasons reasons) { + public Boolean evaluate(ProjectConfig config, Map attributes) { if (conditions == null) return null; boolean foundNull = false; for (Condition condition : conditions) { - Boolean conditionEval = condition.evaluate(config, attributes, reasons); + Boolean conditionEval = condition.evaluate(config, attributes); if (conditionEval == null) { // true with falses and nulls is still true foundNull = true; } else if (conditionEval) { diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java b/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java index 152bb7048..277f2f184 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java @@ -21,7 +21,6 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.audience.match.*; -import com.optimizely.ab.optimizelydecision.DecisionReasons; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -72,9 +71,7 @@ public Object getValue() { } @Nullable - public Boolean evaluate(ProjectConfig config, - Map attributes, - DecisionReasons reasons) { + public Boolean evaluate(ProjectConfig config, Map attributes) { if (attributes == null) { attributes = Collections.emptyMap(); } @@ -82,8 +79,7 @@ public Boolean evaluate(ProjectConfig config, Object userAttributeValue = attributes.get(name); if (!"custom_attribute".equals(type)) { - String message = reasons.addInfo("Audience condition \"%s\" uses an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK.", this); - logger.warn(message); + logger.warn("Audience condition \"{}\" uses an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK.", this); return null; // unknown type } // check user attribute value is equal @@ -98,27 +94,26 @@ public Boolean evaluate(ProjectConfig config, } catch(UnknownValueTypeException e) { if (!attributes.containsKey(name)) { //Missing attribute value - String message = reasons.addInfo("Audience condition \"%s\" evaluated to UNKNOWN because no value was passed for user attribute \"%s\"", this, name); - logger.debug(message); + logger.debug("Audience condition \"{}\" evaluated to UNKNOWN because no value was passed for user attribute \"{}\"", this, name); } else { //if attribute value is not valid if (userAttributeValue != null) { - String message = reasons.addInfo("Audience condition \"%s\" evaluated to UNKNOWN because a value of type \"%s\" was passed for user attribute \"%s\"", + logger.warn( + "Audience condition \"{}\" evaluated to UNKNOWN because a value of type \"{}\" was passed for user attribute \"{}\"", this, userAttributeValue.getClass().getCanonicalName(), name); - logger.warn(message); } else { - String message = reasons.addInfo("Audience condition \"%s\" evaluated to UNKNOWN because a null value was passed for user attribute \"%s\"", this, name); - logger.debug(message); + logger.debug( + "Audience condition \"{}\" evaluated to UNKNOWN because a null value was passed for user attribute \"{}\"", + this, + name); } } } catch (UnknownMatchTypeException | UnexpectedValueTypeException e) { - String message = reasons.addInfo("Audience condition \"%s\" " + e.getMessage(), this); - logger.warn(message); + logger.warn("Audience condition \"{}\" " + e.getMessage(), this); } catch (NullPointerException e) { - String message = reasons.addInfo("attribute or value null for match %s", match != null ? match : "legacy condition"); - logger.error(message, e); + logger.error("attribute or value null for match {}", match != null ? match : "legacy condition", e); } return null; } diff --git a/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java b/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java index e24cc1b6a..c1494bbda 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java @@ -1,6 +1,6 @@ /** * - * Copyright 2017-2020, Optimizely and contributors + * Copyright 2017-2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,12 +22,12 @@ import com.optimizely.ab.config.audience.Condition; import com.optimizely.ab.config.audience.OrCondition; import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.DecisionResponse; import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; -import javax.annotation.Nullable; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -43,23 +43,10 @@ private ExperimentUtils() { * Helper method to validate all pre-conditions before bucketing a user. * * @param experiment the experiment we are validating pre-conditions for - * @param reasons Decision log messages * @return whether the pre-conditions are satisfied */ - public static boolean isExperimentActive(@Nonnull Experiment experiment, - @Nonnull DecisionReasons reasons) { - - if (!experiment.isActive()) { - String message = reasons.addInfo("Experiment \"%s\" is not running.", experiment.getKey()); - logger.info(message); - return false; - } - - return true; - } - public static boolean isExperimentActive(@Nonnull Experiment experiment) { - return isExperimentActive(experiment, DefaultDecisionReasons.newInstance()); + return experiment.isActive(); } /** @@ -70,55 +57,45 @@ public static boolean isExperimentActive(@Nonnull Experiment experiment) { * @param attributes the attributes of the user * @param loggingEntityType It can be either experiment or rule. * @param loggingKey In case of loggingEntityType is experiment it will be experiment key or else it will be rule number. - * @param reasons Decision log messages * @return whether the user meets the criteria for the experiment */ - public static boolean doesUserMeetAudienceConditions(@Nonnull ProjectConfig projectConfig, - @Nonnull Experiment experiment, - @Nonnull Map attributes, - @Nonnull String loggingEntityType, - @Nonnull String loggingKey, - @Nonnull DecisionReasons reasons) { + @Nonnull + public static DecisionResponse doesUserMeetAudienceConditions(@Nonnull ProjectConfig projectConfig, + @Nonnull Experiment experiment, + @Nonnull Map attributes, + @Nonnull String loggingEntityType, + @Nonnull String loggingKey) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + + DecisionResponse decisionResponse; if (experiment.getAudienceConditions() != null) { logger.debug("Evaluating audiences for {} \"{}\": {}.", loggingEntityType, loggingKey, experiment.getAudienceConditions()); - Boolean resolveReturn = evaluateAudienceConditions(projectConfig, experiment, attributes, loggingEntityType, loggingKey, reasons); - return resolveReturn == null ? false : resolveReturn; + decisionResponse = evaluateAudienceConditions(projectConfig, experiment, attributes, loggingEntityType, loggingKey); } else { - Boolean resolveReturn = evaluateAudience(projectConfig, experiment, attributes, loggingEntityType, loggingKey, reasons); - return Boolean.TRUE.equals(resolveReturn); + decisionResponse = evaluateAudience(projectConfig, experiment, attributes, loggingEntityType, loggingKey); } - } - /** - * Determines whether a user satisfies audience conditions for the experiment. - * - * @param projectConfig the current projectConfig - * @param experiment the experiment we are evaluating audiences for - * @param attributes the attributes of the user - * @param loggingEntityType It can be either experiment or rule. - * @param loggingKey In case of loggingEntityType is experiment it will be experiment key or else it will be rule number. - * @return whether the user meets the criteria for the experiment - */ - public static boolean doesUserMeetAudienceConditions(@Nonnull ProjectConfig projectConfig, - @Nonnull Experiment experiment, - @Nonnull Map attributes, - @Nonnull String loggingEntityType, - @Nonnull String loggingKey) { - return doesUserMeetAudienceConditions(projectConfig, experiment, attributes, loggingEntityType, loggingKey, DefaultDecisionReasons.newInstance()); + Boolean resolveReturn = decisionResponse.getResult(); + reasons.merge(decisionResponse.getReasons()); + + return new DecisionResponse( + resolveReturn != null && resolveReturn, // make it Nonnull for if-evaluation + reasons); } - @Nullable - public static Boolean evaluateAudience(@Nonnull ProjectConfig projectConfig, - @Nonnull Experiment experiment, - @Nonnull Map attributes, - @Nonnull String loggingEntityType, - @Nonnull String loggingKey, - @Nonnull DecisionReasons reasons) { + @Nonnull + public static DecisionResponse evaluateAudience(@Nonnull ProjectConfig projectConfig, + @Nonnull Experiment experiment, + @Nonnull Map attributes, + @Nonnull String loggingEntityType, + @Nonnull String loggingKey) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + List experimentAudienceIds = experiment.getAudienceIds(); // if there are no audiences, ALL users should be part of the experiment if (experimentAudienceIds.isEmpty()) { - return true; + return new DecisionResponse(true, reasons); } List conditions = new ArrayList<>(); @@ -131,35 +108,35 @@ public static Boolean evaluateAudience(@Nonnull ProjectConfig projectConfig, logger.debug("Evaluating audiences for {} \"{}\": {}.", loggingEntityType, loggingKey, conditions); - Boolean result = implicitOr.evaluate(projectConfig, attributes, reasons); - + Boolean result = implicitOr.evaluate(projectConfig, attributes); String message = reasons.addInfo("Audiences for %s \"%s\" collectively evaluated to %s.", loggingEntityType, loggingKey, result); logger.info(message); - return result; + return new DecisionResponse(result, reasons); } - @Nullable - public static Boolean evaluateAudienceConditions(@Nonnull ProjectConfig projectConfig, - @Nonnull Experiment experiment, - @Nonnull Map attributes, - @Nonnull String loggingEntityType, - @Nonnull String loggingKey, - @Nonnull DecisionReasons reasons) { + @Nonnull + public static DecisionResponse evaluateAudienceConditions(@Nonnull ProjectConfig projectConfig, + @Nonnull Experiment experiment, + @Nonnull Map attributes, + @Nonnull String loggingEntityType, + @Nonnull String loggingKey) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); Condition conditions = experiment.getAudienceConditions(); - if (conditions == null) return null; + if (conditions == null) return new DecisionResponse(null, reasons); + Boolean result = null; try { - Boolean result = conditions.evaluate(projectConfig, attributes, reasons); + result = conditions.evaluate(projectConfig, attributes); String message = reasons.addInfo("Audiences for %s \"%s\" collectively evaluated to %s.", loggingEntityType, loggingKey, result); logger.info(message); - return result; } catch (Exception e) { String message = reasons.addInfo("Condition invalid: %s", e.getMessage()); logger.error(message); - return null; } + + return new DecisionResponse(result, reasons); } } diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionReasons.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionReasons.java index 0983ee4d2..82400a17b 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionReasons.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionReasons.java @@ -16,14 +16,34 @@ */ package com.optimizely.ab.optimizelydecision; +import java.util.ArrayList; import java.util.List; -public interface DecisionReasons { +public class DecisionReasons { - public void addError(String format, Object... args); + protected final List errors = new ArrayList<>(); + protected final List infos = new ArrayList<>(); - public String addInfo(String format, Object... args); + public void addError(String format, Object... args) { + String message = String.format(format, args); + errors.add(message); + } - public List toReport(); + public String addInfo(String format, Object... args) { + String message = String.format(format, args); + infos.add(message); + return message; + } + + public void merge(DecisionReasons target) { + errors.addAll(target.errors); + infos.addAll(target.infos); + } + + public List toReport() { + List reasons = new ArrayList<>(errors); + reasons.addAll(infos); + return reasons; + } } diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionResponse.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionResponse.java new file mode 100644 index 000000000..fee8aa32b --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionResponse.java @@ -0,0 +1,48 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public class DecisionResponse { + private T result; + private DecisionReasons reasons; + + public DecisionResponse(@Nullable T result, @Nonnull DecisionReasons reasons) { + this.result = result; + this.reasons = reasons; + } + + public static DecisionResponse responseNoReasons(@Nullable E result) { + return new DecisionResponse(result, DefaultDecisionReasons.newInstance()); + } + + public static DecisionResponse nullNoReasons() { + return new DecisionResponse(null, DefaultDecisionReasons.newInstance()); + } + + @Nullable + public T getResult() { + return result; + } + + @Nonnull + public DecisionReasons getReasons() { + return reasons; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DefaultDecisionReasons.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DefaultDecisionReasons.java index dd84d04fe..6f0f609f0 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DefaultDecisionReasons.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DefaultDecisionReasons.java @@ -1,3 +1,19 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ /** * * Copyright 2020, Optimizely and contributors @@ -17,38 +33,29 @@ package com.optimizely.ab.optimizelydecision; import javax.annotation.Nullable; -import java.util.ArrayList; import java.util.List; -public class DefaultDecisionReasons implements DecisionReasons { - - private final List errors = new ArrayList<>(); - private final List infos = new ArrayList<>(); +public class DefaultDecisionReasons extends DecisionReasons { public static DecisionReasons newInstance(@Nullable List options) { - if (options != null && options.contains(OptimizelyDecideOption.INCLUDE_REASONS)) return new DefaultDecisionReasons(); - else return new ErrorsDecisionReasons(); + if (options == null || options.contains(OptimizelyDecideOption.INCLUDE_REASONS)) return new DecisionReasons(); + else return new DefaultDecisionReasons(); } public static DecisionReasons newInstance() { return newInstance(null); } - public void addError(String format, Object... args) { - String message = String.format(format, args); - errors.add(message); - } - + @Override public String addInfo(String format, Object... args) { - String message = String.format(format, args); - infos.add(message); - return message; + // skip tracking and pass-through reasons other than critical errors. + return String.format(format, args); } - public List toReport() { - List reasons = new ArrayList<>(errors); - reasons.addAll(infos); - return reasons; + @Override + public void merge(DecisionReasons target) { + // ignore infos + errors.addAll(target.errors); } } diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/ErrorsDecisionReasons.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/ErrorsDecisionReasons.java deleted file mode 100644 index 91875eece..000000000 --- a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/ErrorsDecisionReasons.java +++ /dev/null @@ -1,56 +0,0 @@ -/** - * - * Copyright 2020, Optimizely and contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -/** - * - * Copyright 2020, Optimizely and contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.optimizely.ab.optimizelydecision; - -import java.util.ArrayList; -import java.util.List; - -public class ErrorsDecisionReasons implements DecisionReasons { - - private final List errors = new ArrayList<>(); - - public void addError(String format, Object... args) { - String message = String.format(format, args); - errors.add(message); - } - - public String addInfo(String format, Object... args) { - // skip tracking and pass-through reasons other than critical errors. - return String.format(format, args); - } - - public List toReport() { - return new ArrayList<>(errors); - } - -} diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java index 6cb7eb360..a0c0541ac 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -34,6 +34,7 @@ import com.optimizely.ab.internal.ControlAttribute; import com.optimizely.ab.internal.LogbackVerifier; import com.optimizely.ab.notification.*; +import com.optimizely.ab.optimizelydecision.DecisionResponse; import com.optimizely.ab.optimizelyjson.OptimizelyJSON; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Before; @@ -377,7 +378,7 @@ public void activateForNullVariation() throws Exception { Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); Map testUserAttributes = Collections.singletonMap("browser_type", "chromey"); - when(mockBucketer.bucket(eq(activatedExperiment), eq(testBucketingId), eq(validProjectConfig), anyObject())).thenReturn(null); + when(mockBucketer.bucket(eq(activatedExperiment), eq(testUserId), eq(validProjectConfig))).thenReturn(DecisionResponse.nullNoReasons()); logbackVerifier.expectMessage(Level.INFO, "Not activating user \"userId\" for experiment \"" + activatedExperiment.getKey() + "\"."); @@ -936,7 +937,7 @@ public void activateWithInvalidDatafile() throws Exception { assertNull(expectedVariation); // make sure we didn't even attempt to bucket the user - verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); + verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); } //======== track tests ========// @@ -1237,7 +1238,7 @@ public void trackWithInvalidDatafile() throws Exception { optimizely.track("event_with_launched_and_running_experiments", genericUserId); // make sure we didn't even attempt to bucket the user or fire any conversion events - verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); + verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); } @@ -1254,7 +1255,7 @@ public void getVariation() throws Exception { Optimizely optimizely = optimizelyBuilder.withBucketing(mockBucketer).build(); - when(mockBucketer.bucket(eq(activatedExperiment), eq(testUserId), eq(validProjectConfig), anyObject())).thenReturn(bucketedVariation); + when(mockBucketer.bucket(eq(activatedExperiment), eq(testUserId), eq(validProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); Map testUserAttributes = new HashMap<>(); testUserAttributes.put("browser_type", "chrome"); @@ -1264,7 +1265,7 @@ public void getVariation() throws Exception { testUserAttributes); // verify that the bucketing algorithm was called correctly - verify(mockBucketer).bucket(eq(activatedExperiment), eq(testUserId), eq(validProjectConfig), anyObject()); + verify(mockBucketer).bucket(eq(activatedExperiment), eq(testUserId), eq(validProjectConfig)); assertThat(actualVariation, is(bucketedVariation)); // verify that we didn't attempt to dispatch an event @@ -1285,13 +1286,13 @@ public void getVariationWithExperimentKey() throws Exception { .withConfig(noAudienceProjectConfig) .build(); - when(mockBucketer.bucket(eq(activatedExperiment), eq(testUserId), eq(noAudienceProjectConfig), anyObject())).thenReturn(bucketedVariation); + when(mockBucketer.bucket(eq(activatedExperiment), eq(testUserId), eq(noAudienceProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); // activate the experiment Variation actualVariation = optimizely.getVariation(activatedExperiment.getKey(), testUserId); // verify that the bucketing algorithm was called correctly - verify(mockBucketer).bucket(eq(activatedExperiment), eq(testUserId), eq(noAudienceProjectConfig), anyObject()); + verify(mockBucketer).bucket(eq(activatedExperiment), eq(testUserId), eq(noAudienceProjectConfig)); assertThat(actualVariation, is(bucketedVariation)); // verify that we didn't attempt to dispatch an event @@ -1346,7 +1347,7 @@ public void getVariationWithAudiences() throws Exception { Experiment experiment = validProjectConfig.getExperiments().get(0); Variation bucketedVariation = experiment.getVariations().get(0); - when(mockBucketer.bucket(eq(experiment), eq(testUserId), eq(validProjectConfig), anyObject())).thenReturn(bucketedVariation); + when(mockBucketer.bucket(eq(experiment), eq(testUserId), eq(validProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); Optimizely optimizely = optimizelyBuilder.withBucketing(mockBucketer).build(); @@ -1355,7 +1356,7 @@ public void getVariationWithAudiences() throws Exception { Variation actualVariation = optimizely.getVariation(experiment.getKey(), testUserId, testUserAttributes); - verify(mockBucketer).bucket(eq(experiment), eq(testUserId), eq(validProjectConfig), anyObject()); + verify(mockBucketer).bucket(eq(experiment), eq(testUserId), eq(validProjectConfig)); assertThat(actualVariation, is(bucketedVariation)); } @@ -1396,7 +1397,7 @@ public void getVariationNoAudiences() throws Exception { Experiment experiment = noAudienceProjectConfig.getExperiments().get(0); Variation bucketedVariation = experiment.getVariations().get(0); - when(mockBucketer.bucket(eq(experiment), eq(testUserId), eq(noAudienceProjectConfig), anyObject())).thenReturn(bucketedVariation); + when(mockBucketer.bucket(eq(experiment), eq(testUserId), eq(noAudienceProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); Optimizely optimizely = optimizelyBuilder .withConfig(noAudienceProjectConfig) @@ -1405,7 +1406,7 @@ public void getVariationNoAudiences() throws Exception { Variation actualVariation = optimizely.getVariation(experiment.getKey(), testUserId); - verify(mockBucketer).bucket(eq(experiment), eq(testUserId), eq(noAudienceProjectConfig), anyObject()); + verify(mockBucketer).bucket(eq(experiment), eq(testUserId), eq(noAudienceProjectConfig)); assertThat(actualVariation, is(bucketedVariation)); } @@ -1463,7 +1464,7 @@ public void getVariationForGroupExperimentWithMatchingAttributes() throws Except attributes.put("browser_type", "chrome"); } - when(mockBucketer.bucket(eq(experiment), eq("user"), eq(validProjectConfig), anyObject())).thenReturn(variation); + when(mockBucketer.bucket(eq(experiment), eq("user"), eq(validProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(variation)); Optimizely optimizely = optimizelyBuilder.withBucketing(mockBucketer).build(); @@ -1521,7 +1522,7 @@ public void getVariationWithInvalidDatafile() throws Exception { assertNull(variation); // make sure we didn't even attempt to bucket the user - verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); + verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); } //======== Notification listeners ========// @@ -1713,7 +1714,7 @@ public void getEnabledFeaturesWithNoFeatureEnabled() throws Exception { Optimizely optimizely = optimizelyBuilder.withDecisionService(mockDecisionService).build(); FeatureDecision featureDecision = new FeatureDecision(null, null, FeatureDecision.DecisionSource.ROLLOUT); - doReturn(featureDecision).when(mockDecisionService).getVariationForFeature( + doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( any(FeatureFlag.class), anyString(), anyMapOf(String.class, String.class), @@ -1830,7 +1831,7 @@ public void isFeatureEnabledWithListenerUserInExperimentFeatureOff() throws Exce Variation variation = new Variation("2", "variation_toggled_off", false, null); FeatureDecision featureDecision = new FeatureDecision(activatedExperiment, variation, FeatureDecision.DecisionSource.FEATURE_TEST); - doReturn(featureDecision).when(mockDecisionService).getVariationForFeature( + doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( any(FeatureFlag.class), anyString(), anyMapOf(String.class, String.class), @@ -2894,7 +2895,7 @@ public void getFeatureVariableValueReturnsDefaultValueWhenFeatureEnabledIsFalse( Optimizely optimizely = optimizelyBuilder.withDecisionService(mockDecisionService).build(); FeatureDecision featureDecision = new FeatureDecision(multivariateExperiment, VARIATION_MULTIVARIATE_EXPERIMENT_GRED, FeatureDecision.DecisionSource.FEATURE_TEST); - doReturn(featureDecision).when(mockDecisionService).getVariationForFeature( + doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( FEATURE_FLAG_MULTI_VARIATE_FEATURE, genericUserId, Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE), @@ -3176,7 +3177,7 @@ public void isFeatureEnabledReturnsFalseWhenUserIsNotBucketedIntoAnyVariation() Optimizely optimizely = optimizelyBuilder.withDecisionService(mockDecisionService).build(); FeatureDecision featureDecision = new FeatureDecision(null, null, null); - doReturn(featureDecision).when(mockDecisionService).getVariationForFeature( + doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( any(FeatureFlag.class), anyString(), anyMapOf(String.class, String.class), @@ -3218,7 +3219,7 @@ public void isFeatureEnabledReturnsTrueButDoesNotSendWhenUserIsBucketedIntoVaria Experiment experiment = validProjectConfig.getRolloutIdMapping().get(ROLLOUT_2_ID).getExperiments().get(0); Variation variation = new Variation("variationId", "variationKey", true, null); FeatureDecision featureDecision = new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.ROLLOUT); - doReturn(featureDecision).when(mockDecisionService).getVariationForFeature( + doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), eq(genericUserId), eq(Collections.emptyMap()), @@ -3303,7 +3304,7 @@ public void isFeatureEnabledTrueWhenFeatureEnabledOfVariationIsTrue() throws Exc Experiment experiment = validProjectConfig.getRolloutIdMapping().get(ROLLOUT_2_ID).getExperiments().get(0); Variation variation = new Variation("variationId", "variationKey", true, null); FeatureDecision featureDecision = new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.ROLLOUT); - doReturn(featureDecision).when(mockDecisionService).getVariationForFeature( + doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), eq(genericUserId), eq(Collections.emptyMap()), @@ -3333,7 +3334,7 @@ public void isFeatureEnabledFalseWhenFeatureEnabledOfVariationIsFalse() throws E Experiment experiment = validProjectConfig.getRolloutIdMapping().get(ROLLOUT_2_ID).getExperiments().get(0); Variation variation = new Variation("variationId", "variationKey", false, null); FeatureDecision featureDecision = new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.ROLLOUT); - doReturn(featureDecision).when(mockDecisionService).getVariationForFeature( + doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), eq(genericUserId), eq(Collections.emptyMap()), @@ -3363,7 +3364,7 @@ public void isFeatureEnabledReturnsFalseAndDispatchesWhenUserIsBucketedIntoAnExp Variation variation = new Variation("2", "variation_toggled_off", false, null); FeatureDecision featureDecision = new FeatureDecision(activatedExperiment, variation, FeatureDecision.DecisionSource.FEATURE_TEST); - doReturn(featureDecision).when(mockDecisionService).getVariationForFeature( + doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( any(FeatureFlag.class), anyString(), anyMapOf(String.class, String.class), @@ -3509,7 +3510,7 @@ public void getEnabledFeatureWithMockDecisionService() throws Exception { Optimizely optimizely = optimizelyBuilder.withDecisionService(mockDecisionService).build(); FeatureDecision featureDecision = new FeatureDecision(null, null, FeatureDecision.DecisionSource.ROLLOUT); - doReturn(featureDecision).when(mockDecisionService).getVariationForFeature( + doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( any(FeatureFlag.class), anyString(), anyMapOf(String.class, String.class), diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java index 50c62141d..5b4c29382 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -752,112 +752,7 @@ public void decideReasons_variableValueInvalid() { assertEquals(decision.getReasons().get(0), DecisionMessage.VARIABLE_VALUE_INVALID.reason("any-key")); } - // reasons (logs with includeReasons) - - @Test - public void decideReasons_conditionNoMatchingAudience() throws ConfigParseException { - String flagKey = "feature_1"; - String audienceId = "invalid_id"; - - Experiment experiment = getSpyExperiment(flagKey); - when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); - addSpyExperiment(experiment); - OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); - - assertTrue(decision.getReasons().contains( - String.format("Audience %s could not be found.", audienceId) - )); - } - - @Test - public void decideReasons_evaluateAttributeInvalidType() { - String flagKey = "feature_1"; - String audienceId = "13389130056"; - - Experiment experiment = getSpyExperiment(flagKey); - when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); - addSpyExperiment(experiment); - OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("country", 25)); - - assertTrue(decision.getReasons().contains( - String.format("Audience condition \"{name='country', type='custom_attribute', match='exact', value='US'}\" evaluated to UNKNOWN because a value of type \"java.lang.Integer\" was passed for user attribute \"country\"") - )); - } - - @Test - public void decideReasons_evaluateAttributeValueOutOfRange() { - String flagKey = "feature_1"; - String audienceId = "age_18"; - - Experiment experiment = getSpyExperiment(flagKey); - when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); - addSpyExperiment(experiment); - OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", (float)Math.pow(2, 54))); - - assertTrue(decision.getReasons().contains( - String.format("Audience condition \"{name='age', type='custom_attribute', match='gt', value=18.0}\" evaluated to UNKNOWN because a value of type \"java.lang.Float\" was passed for user attribute \"age\"") - )); - } - - @Test - public void decideReasons_userAttributeInvalidType() { - String flagKey = "feature_1"; - String audienceId = "invalid_type"; - - Experiment experiment = getSpyExperiment(flagKey); - when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); - addSpyExperiment(experiment); - OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", 25)); - - assertTrue(decision.getReasons().contains( - String.format("Audience condition \"{name='age', type='invalid', match='gt', value=18.0}\" uses an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK.") - )); - } - - @Test - public void decideReasons_userAttributeInvalidMatch() { - String flagKey = "feature_1"; - String audienceId = "invalid_match"; - - Experiment experiment = getSpyExperiment(flagKey); - when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); - addSpyExperiment(experiment); - OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", 25)); - - assertTrue(decision.getReasons().contains( - String.format("Audience condition \"{name='age', type='custom_attribute', match='invalid', value=18.0}\" uses an unknown match type. You may need to upgrade to a newer release of the Optimizely SDK.") - )); - } - - @Test - public void decideReasons_userAttributeNilValue() { - String flagKey = "feature_1"; - String audienceId = "nil_value"; - - Experiment experiment = getSpyExperiment(flagKey); - when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); - addSpyExperiment(experiment); - OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", 25)); - - assertTrue(decision.getReasons().contains( - String.format("Audience condition \"{name='age', type='custom_attribute', match='gt', value=null}\" evaluated to UNKNOWN because a value of type \"java.lang.Integer\" was passed for user attribute \"age\"") - )); - } - - @Test - public void decideReasons_missingAttributeValue() { - String flagKey = "feature_1"; - String audienceId = "age_18"; - - Experiment experiment = getSpyExperiment(flagKey); - when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); - addSpyExperiment(experiment); - OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); - - assertTrue(decision.getReasons().contains( - String.format("Audience condition \"{name='age', type='custom_attribute', match='gt', value=18.0}\" evaluated to UNKNOWN because no value was passed for user attribute \"age\"") - )); - } + // reasons (infos with includeReasons) @Test public void decideReasons_experimentNotRunning() { @@ -1084,6 +979,111 @@ public void decideReasons_userNotInExperiment() { )); } + @Test + public void decideReasons_conditionNoMatchingAudience() throws ConfigParseException { + String flagKey = "feature_1"; + String audienceId = "invalid_id"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("Audiences for experiment \"%s\" collectively evaluated to null.", experiment.getKey()) + )); + } + + @Test + public void decideReasons_evaluateAttributeInvalidType() { + String flagKey = "feature_1"; + String audienceId = "13389130056"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("country", 25)); + + assertTrue(decision.getReasons().contains( + String.format("Audiences for experiment \"%s\" collectively evaluated to null.", experiment.getKey()) + )); + } + + @Test + public void decideReasons_evaluateAttributeValueOutOfRange() { + String flagKey = "feature_1"; + String audienceId = "age_18"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", (float)Math.pow(2, 54))); + + assertTrue(decision.getReasons().contains( + String.format("Audiences for experiment \"%s\" collectively evaluated to null.", experiment.getKey()) + )); + } + + @Test + public void decideReasons_userAttributeInvalidType() { + String flagKey = "feature_1"; + String audienceId = "invalid_type"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", 25)); + + assertTrue(decision.getReasons().contains( + String.format("Audiences for experiment \"%s\" collectively evaluated to null.", experiment.getKey()) + )); + } + + @Test + public void decideReasons_userAttributeInvalidMatch() { + String flagKey = "feature_1"; + String audienceId = "invalid_match"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", 25)); + + assertTrue(decision.getReasons().contains( + String.format("Audiences for experiment \"%s\" collectively evaluated to null.", experiment.getKey()) + )); + } + + @Test + public void decideReasons_userAttributeNilValue() { + String flagKey = "feature_1"; + String audienceId = "nil_value"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", 25)); + + assertTrue(decision.getReasons().contains( + String.format("Audiences for experiment \"%s\" collectively evaluated to null.", experiment.getKey()) + )); + } + + @Test + public void decideReasons_missingAttributeValue() { + String flagKey = "feature_1"; + String audienceId = "age_18"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("Audiences for experiment \"%s\" collectively evaluated to null.", experiment.getKey()) + )); + } + // utils Map createUserProfileMap(String experimentId, String variationId) { diff --git a/core-api/src/test/java/com/optimizely/ab/bucketing/BucketerTest.java b/core-api/src/test/java/com/optimizely/ab/bucketing/BucketerTest.java index 0db346366..5a67d1841 100644 --- a/core-api/src/test/java/com/optimizely/ab/bucketing/BucketerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/bucketing/BucketerTest.java @@ -167,25 +167,25 @@ public void bucketToMultipleVariations() throws Exception { // verify bucketing to the first variation bucketValue.set(0); - assertThat(algorithm.bucket(experiment, "user1", projectConfig), is(variations.get(0))); + assertThat(algorithm.bucket(experiment, "user1", projectConfig).getResult(), is(variations.get(0))); bucketValue.set(500); - assertThat(algorithm.bucket(experiment, "user2", projectConfig), is(variations.get(0))); + assertThat(algorithm.bucket(experiment, "user2", projectConfig).getResult(), is(variations.get(0))); bucketValue.set(999); - assertThat(algorithm.bucket(experiment, "user3", projectConfig), is(variations.get(0))); + assertThat(algorithm.bucket(experiment, "user3", projectConfig).getResult(), is(variations.get(0))); // verify the second variation bucketValue.set(1000); - assertThat(algorithm.bucket(experiment, "user4", projectConfig), is(variations.get(1))); + assertThat(algorithm.bucket(experiment, "user4", projectConfig).getResult(), is(variations.get(1))); bucketValue.set(4000); - assertThat(algorithm.bucket(experiment, "user5", projectConfig), is(variations.get(1))); + assertThat(algorithm.bucket(experiment, "user5", projectConfig).getResult(), is(variations.get(1))); bucketValue.set(4999); - assertThat(algorithm.bucket(experiment, "user6", projectConfig), is(variations.get(1))); + assertThat(algorithm.bucket(experiment, "user6", projectConfig).getResult(), is(variations.get(1))); // ...and the rest bucketValue.set(5100); - assertThat(algorithm.bucket(experiment, "user7", projectConfig), is(variations.get(2))); + assertThat(algorithm.bucket(experiment, "user7", projectConfig).getResult(), is(variations.get(2))); bucketValue.set(6500); - assertThat(algorithm.bucket(experiment, "user8", projectConfig), is(variations.get(3))); + assertThat(algorithm.bucket(experiment, "user8", projectConfig).getResult(), is(variations.get(3))); } /** @@ -217,14 +217,14 @@ public void bucketToControl() throws Exception { // verify bucketing to the first variation bucketValue.set(0); - assertThat(algorithm.bucket(experiment, bucketingId, projectConfig), is(variations.get(0))); + assertThat(algorithm.bucket(experiment, bucketingId, projectConfig).getResult(), is(variations.get(0))); logbackVerifier.expectMessage(Level.DEBUG, "Assigned bucket 1000 to user with bucketingId \"" + bucketingId + "\" when bucketing to a variation."); logbackVerifier.expectMessage(Level.INFO, "User with bucketingId \"" + bucketingId + "\" is not in any variation of experiment \"exp_key\"."); // verify bucketing to no variation (null) bucketValue.set(1000); - assertNull(algorithm.bucket(experiment, bucketingId, projectConfig)); + assertNull(algorithm.bucket(experiment, bucketingId, projectConfig).getResult()); } @@ -249,7 +249,7 @@ public void bucketUserInExperiment() throws Exception { logbackVerifier.expectMessage(Level.DEBUG, "Assigned bucket 3000 to user with bucketingId \"blah\" when bucketing to a variation."); logbackVerifier.expectMessage(Level.INFO, "User with bucketingId \"blah\" is in variation \"e2_vtag1\" of experiment \"group_etag2\"."); - assertThat(algorithm.bucket(groupExperiment, "blah", projectConfig), is(groupExperiment.getVariations().get(0))); + assertThat(algorithm.bucket(groupExperiment, "blah", projectConfig).getResult(), is(groupExperiment.getVariations().get(0))); } /** @@ -271,7 +271,7 @@ public void bucketUserNotInExperiment() throws Exception { "Assigned bucket 3000 to user with bucketingId \"blah\" during experiment bucketing."); logbackVerifier.expectMessage(Level.INFO, "User with bucketingId \"blah\" is not in experiment \"group_etag1\" of group 42"); - assertNull(algorithm.bucket(groupExperiment, "blah", projectConfig)); + assertNull(algorithm.bucket(groupExperiment, "blah", projectConfig).getResult()); } /** @@ -291,7 +291,7 @@ public void bucketUserToDeletedExperimentSpace() throws Exception { logbackVerifier.expectMessage(Level.DEBUG, "Assigned bucket " + bucketIntVal + " to user with bucketingId \"blah\" during experiment bucketing."); logbackVerifier.expectMessage(Level.INFO, "User with bucketingId \"blah\" is not in any experiment of group 42."); - assertNull(algorithm.bucket(groupExperiment, "blah", projectConfig)); + assertNull(algorithm.bucket(groupExperiment, "blah", projectConfig).getResult()); } /** @@ -312,7 +312,7 @@ public void bucketUserToVariationInOverlappingGroupExperiment() throws Exception logbackVerifier.expectMessage( Level.INFO, "User with bucketingId \"blah\" is in variation \"e1_vtag1\" of experiment \"overlapping_etag1\"."); - assertThat(algorithm.bucket(groupExperiment, "blah", projectConfig), is(expectedVariation)); + assertThat(algorithm.bucket(groupExperiment, "blah", projectConfig).getResult(), is(expectedVariation)); } /** @@ -332,7 +332,7 @@ public void bucketUserNotInOverlappingGroupExperiment() throws Exception { logbackVerifier.expectMessage(Level.INFO, "User with bucketingId \"blah\" is not in any variation of experiment \"overlapping_etag1\"."); - assertNull(algorithm.bucket(groupExperiment, "blah", projectConfig)); + assertNull(algorithm.bucket(groupExperiment, "blah", projectConfig).getResult()); } @Test @@ -351,7 +351,7 @@ public void testBucketWithBucketingId() { logbackVerifier.expectMessage( Level.INFO, "User with bucketingId \"" + bucketingId + "\" is in variation \"e1_vtag1\" of experiment \"overlapping_etag1\"."); - assertThat(algorithm.bucket(groupExperiment, bucketingId, projectConfig), is(expectedVariation)); + assertThat(algorithm.bucket(groupExperiment, bucketingId, projectConfig).getResult(), is(expectedVariation)); } diff --git a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java index d12cd9a56..59dc47b22 100644 --- a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java @@ -20,6 +20,7 @@ import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.internal.ControlAttribute; import com.optimizely.ab.internal.LogbackVerifier; +import com.optimizely.ab.optimizelydecision.DecisionResponse; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Before; import org.junit.Rule; @@ -88,15 +89,15 @@ public void getVariationWhitelistedPrecedesAudienceEval() throws Exception { Variation expectedVariation = experiment.getVariations().get(0); // user excluded without audiences and whitelisting - assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig)); + assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig).getResult()); logbackVerifier.expectMessage(Level.INFO, "User \"" + whitelistedUserId + "\" is forced in variation \"vtag1\"."); // no attributes provided for a experiment that has an audience - assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.emptyMap(), validProjectConfig), is(expectedVariation)); + assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.emptyMap(), validProjectConfig).getResult(), is(expectedVariation)); - verify(decisionService).getWhitelistedVariation(eq(experiment), eq(whitelistedUserId), anyObject()); - verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), any(ProjectConfig.class), anyObject()); + verify(decisionService).getWhitelistedVariation(eq(experiment), eq(whitelistedUserId)); + verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), any(ProjectConfig.class)); } /** @@ -110,19 +111,19 @@ public void getForcedVariationBeforeWhitelisting() throws Exception { Variation expectedVariation = experiment.getVariations().get(1); // user excluded without audiences and whitelisting - assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig)); + assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig).getResult()); // set the runtimeForcedVariation decisionService.setForcedVariation(experiment, whitelistedUserId, expectedVariation.getKey()); // no attributes provided for a experiment that has an audience - assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.emptyMap(), validProjectConfig), is(expectedVariation)); + assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.emptyMap(), validProjectConfig).getResult(), is(expectedVariation)); //verify(decisionService).getForcedVariation(experiment.getKey(), whitelistedUserId); - verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), any(ProjectConfig.class), anyObject()); - assertEquals(decisionService.getWhitelistedVariation(experiment, whitelistedUserId), whitelistVariation); + verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), any(ProjectConfig.class)); + assertEquals(decisionService.getWhitelistedVariation(experiment, whitelistedUserId).getResult(), whitelistVariation); assertTrue(decisionService.setForcedVariation(experiment, whitelistedUserId, null)); - assertNull(decisionService.getForcedVariation(experiment, whitelistedUserId)); - assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.emptyMap(), validProjectConfig), is(whitelistVariation)); + assertNull(decisionService.getForcedVariation(experiment, whitelistedUserId).getResult()); + assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.emptyMap(), validProjectConfig).getResult(), is(whitelistVariation)); } /** @@ -135,16 +136,16 @@ public void getVariationForcedPrecedesAudienceEval() throws Exception { Variation expectedVariation = experiment.getVariations().get(1); // user excluded without audiences and whitelisting - assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig)); + assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig).getResult()); // set the runtimeForcedVariation decisionService.setForcedVariation(experiment, genericUserId, expectedVariation.getKey()); // no attributes provided for a experiment that has an audience - assertThat(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig), is(expectedVariation)); + assertThat(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig).getResult(), is(expectedVariation)); - verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), eq(validProjectConfig), anyObject()); + verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), eq(validProjectConfig)); assertEquals(decisionService.setForcedVariation(experiment, genericUserId, null), true); - assertNull(decisionService.getForcedVariation(experiment, genericUserId)); + assertNull(decisionService.getForcedVariation(experiment, genericUserId).getResult()); } /** @@ -164,18 +165,18 @@ public void getVariationForcedBeforeUserProfile() throws Exception { DecisionService decisionService = spy(new DecisionService(new Bucketer(), mockErrorHandler, userProfileService)); // ensure that normal users still get excluded from the experiment when they fail audience evaluation - assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig)); + assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig).getResult()); // ensure that a user with a saved user profile, sees the same variation regardless of audience evaluation assertEquals(variation, - decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), validProjectConfig)); + decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), validProjectConfig).getResult()); Variation forcedVariation = experiment.getVariations().get(1); decisionService.setForcedVariation(experiment, userProfileId, forcedVariation.getKey()); assertEquals(forcedVariation, - decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), validProjectConfig)); + decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), validProjectConfig).getResult()); assertTrue(decisionService.setForcedVariation(experiment, userProfileId, null)); - assertNull(decisionService.getForcedVariation(experiment, userProfileId)); + assertNull(decisionService.getForcedVariation(experiment, userProfileId).getResult()); } /** @@ -195,11 +196,11 @@ public void getVariationEvaluatesUserProfileBeforeAudienceTargeting() throws Exc DecisionService decisionService = spy(new DecisionService(new Bucketer(), mockErrorHandler, userProfileService)); // ensure that normal users still get excluded from the experiment when they fail audience evaluation - assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig)); + assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig).getResult()); // ensure that a user with a saved user profile, sees the same variation regardless of audience evaluation assertEquals(variation, - decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), validProjectConfig)); + decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), validProjectConfig).getResult()); } @@ -216,7 +217,7 @@ public void getVariationOnNonRunningExperimentWithForcedVariation() { Variation variation = experiment.getVariations().get(0); // ensure that the not running variation returns null with no forced variation set. - assertNull(decisionService.getVariation(experiment, "userId", Collections.emptyMap(), validProjectConfig)); + assertNull(decisionService.getVariation(experiment, "userId", Collections.emptyMap(), validProjectConfig).getResult()); // we call getVariation 3 times on an experiment that is not running. logbackVerifier.expectMessage(Level.INFO, @@ -227,12 +228,12 @@ public void getVariationOnNonRunningExperimentWithForcedVariation() { // ensure that a user with a forced variation set // still gets back a null variation if the variation is not running. - assertNull(decisionService.getVariation(experiment, "userId", Collections.emptyMap(), validProjectConfig)); + assertNull(decisionService.getVariation(experiment, "userId", Collections.emptyMap(), validProjectConfig).getResult()); // set the forced variation back to null assertTrue(decisionService.setForcedVariation(experiment, "userId", null)); // test one more time that the getVariation returns null for the experiment that is not running. - assertNull(decisionService.getVariation(experiment, "userId", Collections.emptyMap(), validProjectConfig)); + assertNull(decisionService.getVariation(experiment, "userId", Collections.emptyMap(), validProjectConfig).getResult()); } @@ -264,7 +265,7 @@ public void getVariationForFeatureReturnsNullWhenFeatureFlagExperimentIdsIsEmpty emptyFeatureFlag, genericUserId, Collections.emptyMap(), - validProjectConfig); + validProjectConfig).getResult(); assertNull(featureDecision.variation); assertNull(featureDecision.decisionSource); @@ -283,21 +284,19 @@ public void getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperiment FeatureFlag spyFeatureFlag = spy(FEATURE_FLAG_MULTI_VARIATE_FEATURE); // do not bucket to any experiments - doReturn(null).when(decisionService).getVariation( + doReturn(DecisionResponse.nullNoReasons()).when(decisionService).getVariation( any(Experiment.class), anyString(), anyMapOf(String.class, String.class), any(ProjectConfig.class), - anyObject(), anyObject() ); // do not bucket to any rollouts - doReturn(new FeatureDecision(null, null, null)).when(decisionService).getVariationForFeatureInRollout( + doReturn(DecisionResponse.responseNoReasons(new FeatureDecision(null, null, null))).when(decisionService).getVariationForFeatureInRollout( any(FeatureFlag.class), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class), - anyObject() + any(ProjectConfig.class) ); // try to get a variation back from the decision service for the feature flag @@ -306,7 +305,7 @@ public void getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperiment genericUserId, Collections.emptyMap(), validProjectConfig - ); + ).getResult(); assertNull(featureDecision.variation); assertNull(featureDecision.decisionSource); @@ -327,21 +326,19 @@ public void getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperiment public void getVariationForFeatureReturnsVariationReturnedFromGetVariation() { FeatureFlag spyFeatureFlag = spy(ValidProjectConfigV4.FEATURE_FLAG_MUTEX_GROUP_FEATURE); - doReturn(null).when(decisionService).getVariation( + doReturn(DecisionResponse.nullNoReasons()).when(decisionService).getVariation( eq(ValidProjectConfigV4.EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1), anyString(), anyMapOf(String.class, String.class), any(ProjectConfig.class), - anyObject(), anyObject() ); - doReturn(ValidProjectConfigV4.VARIATION_MUTEX_GROUP_EXP_2_VAR_1).when(decisionService).getVariation( + doReturn(DecisionResponse.responseNoReasons(ValidProjectConfigV4.VARIATION_MUTEX_GROUP_EXP_2_VAR_1)).when(decisionService).getVariation( eq(ValidProjectConfigV4.EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2), anyString(), anyMapOf(String.class, String.class), any(ProjectConfig.class), - anyObject(), anyObject() ); @@ -350,7 +347,7 @@ public void getVariationForFeatureReturnsVariationReturnedFromGetVariation() { genericUserId, Collections.emptyMap(), v4ProjectConfig - ); + ).getResult(); assertEquals(ValidProjectConfigV4.VARIATION_MUTEX_GROUP_EXP_2_VAR_1, featureDecision.variation); assertEquals(FeatureDecision.DecisionSource.FEATURE_TEST, featureDecision.decisionSource); @@ -376,24 +373,22 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() Variation rolloutVariation = rolloutExperiment.getVariations().get(0); // return variation for experiment - doReturn(experimentVariation) + doReturn(DecisionResponse.responseNoReasons(experimentVariation)) .when(decisionService).getVariation( eq(featureExperiment), anyString(), anyMapOf(String.class, String.class), any(ProjectConfig.class), - anyObject(), anyObject() ); // return variation for rollout - doReturn(new FeatureDecision(rolloutExperiment, rolloutVariation, FeatureDecision.DecisionSource.ROLLOUT)) + doReturn(DecisionResponse.responseNoReasons(new FeatureDecision(rolloutExperiment, rolloutVariation, FeatureDecision.DecisionSource.ROLLOUT))) .when(decisionService).getVariationForFeatureInRollout( eq(featureFlag), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class), - anyObject() + any(ProjectConfig.class) ); // make sure we get the right variation back @@ -402,7 +397,7 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() genericUserId, Collections.emptyMap(), v4ProjectConfig - ); + ).getResult(); assertEquals(experimentVariation, featureDecision.variation); assertEquals(FeatureDecision.DecisionSource.FEATURE_TEST, featureDecision.decisionSource); @@ -411,8 +406,7 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() any(FeatureFlag.class), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class), - anyObject() + any(ProjectConfig.class) ); // make sure we ask for experiment bucketing once @@ -421,7 +415,6 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() anyString(), anyMapOf(String.class, String.class), any(ProjectConfig.class), - anyObject(), anyObject() ); } @@ -442,24 +435,22 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails Variation rolloutVariation = rolloutExperiment.getVariations().get(0); // return variation for experiment - doReturn(null) + doReturn(DecisionResponse.nullNoReasons()) .when(decisionService).getVariation( eq(featureExperiment), anyString(), anyMapOf(String.class, String.class), any(ProjectConfig.class), - anyObject(), anyObject() ); // return variation for rollout - doReturn(new FeatureDecision(rolloutExperiment, rolloutVariation, FeatureDecision.DecisionSource.ROLLOUT)) + doReturn(DecisionResponse.responseNoReasons(new FeatureDecision(rolloutExperiment, rolloutVariation, FeatureDecision.DecisionSource.ROLLOUT))) .when(decisionService).getVariationForFeatureInRollout( eq(featureFlag), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class), - anyObject() + any(ProjectConfig.class) ); // make sure we get the right variation back @@ -468,7 +459,7 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails genericUserId, Collections.emptyMap(), v4ProjectConfig - ); + ).getResult(); assertEquals(rolloutVariation, featureDecision.variation); assertEquals(FeatureDecision.DecisionSource.ROLLOUT, featureDecision.decisionSource); @@ -477,8 +468,7 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails any(FeatureFlag.class), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class), - anyObject() + any(ProjectConfig.class) ); // make sure we ask for experiment bucketing once @@ -487,7 +477,6 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails anyString(), anyMapOf(String.class, String.class), any(ProjectConfig.class), - anyObject(), anyObject() ); @@ -517,7 +506,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenFeatureIsNotAttachedTo genericUserId, Collections.emptyMap(), validProjectConfig - ); + ).getResult(); assertNull(featureDecision.variation); assertNull(featureDecision.decisionSource); @@ -534,7 +523,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenFeatureIsNotAttachedTo @Test public void getVariationForFeatureInRolloutReturnsNullWhenUserIsExcludedFromAllTraffic() { Bucketer mockBucketer = mock(Bucketer.class); - when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(null); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.nullNoReasons()); DecisionService decisionService = new DecisionService( mockBucketer, @@ -549,14 +538,14 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserIsExcludedFromAllT ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE ), v4ProjectConfig - ); + ).getResult(); assertNull(featureDecision.variation); assertNull(featureDecision.decisionSource); // with fall back bucketing, the user has at most 2 chances to get bucketed with traffic allocation // one chance with the audience rollout rule // one chance with the everyone else rule - verify(mockBucketer, atMost(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); + verify(mockBucketer, atMost(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); } /** @@ -567,7 +556,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserIsExcludedFromAllT @Test public void getVariationForFeatureInRolloutReturnsNullWhenUserFailsAllAudiencesAndTraffic() { Bucketer mockBucketer = mock(Bucketer.class); - when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(null); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.nullNoReasons()); DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); @@ -576,12 +565,12 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserFailsAllAudiencesA genericUserId, Collections.emptyMap(), v4ProjectConfig - ); + ).getResult(); assertNull(featureDecision.variation); assertNull(featureDecision.decisionSource); // user is only bucketed once for the everyone else rule - verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); + verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); } /** @@ -595,7 +584,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsAllAudie Rollout rollout = ROLLOUT_2; Experiment everyoneElseRule = rollout.getExperiments().get(rollout.getExperiments().size() - 1); Variation expectedVariation = everyoneElseRule.getVariations().get(0); - when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(expectedVariation); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.responseNoReasons(expectedVariation)); DecisionService decisionService = new DecisionService( mockBucketer, @@ -608,7 +597,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsAllAudie genericUserId, Collections.emptyMap(), v4ProjectConfig - ); + ).getResult(); logbackVerifier.expectMessage(Level.DEBUG, "Evaluating audiences for rule \"1\": [3468206642]."); logbackVerifier.expectMessage(Level.INFO, "Audiences for rule \"1\" collectively evaluated to null."); logbackVerifier.expectMessage(Level.DEBUG, "Evaluating audiences for rule \"2\": [3988293898]."); @@ -621,7 +610,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsAllAudie assertEquals(FeatureDecision.DecisionSource.ROLLOUT, featureDecision.decisionSource); // verify user is only bucketed once for everyone else rule - verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); + verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); } /** @@ -636,8 +625,8 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI Rollout rollout = ROLLOUT_2; Experiment everyoneElseRule = rollout.getExperiments().get(rollout.getExperiments().size() - 1); Variation expectedVariation = everyoneElseRule.getVariations().get(0); - when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(null); - when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(expectedVariation); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.nullNoReasons()); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.responseNoReasons(expectedVariation)); DecisionService decisionService = new DecisionService( mockBucketer, @@ -652,14 +641,14 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE ), v4ProjectConfig - ); + ).getResult(); assertEquals(expectedVariation, featureDecision.variation); assertEquals(FeatureDecision.DecisionSource.ROLLOUT, featureDecision.decisionSource); logbackVerifier.expectMessage(Level.DEBUG, "User \"genericUserId\" meets conditions for targeting rule \"Everyone Else\"."); // verify user is only bucketed once for everyone else rule - verify(mockBucketer, times(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); + verify(mockBucketer, times(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); } /** @@ -678,9 +667,9 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI Variation englishCitizenVariation = englishCitizensRule.getVariations().get(0); Experiment everyoneElseRule = rollout.getExperiments().get(rollout.getExperiments().size() - 1); Variation expectedVariation = everyoneElseRule.getVariations().get(0); - when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(null); - when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(expectedVariation); - when(mockBucketer.bucket(eq(englishCitizensRule), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(englishCitizenVariation); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.nullNoReasons()); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.responseNoReasons(expectedVariation)); + when(mockBucketer.bucket(eq(englishCitizensRule), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.responseNoReasons(englishCitizenVariation)); DecisionService decisionService = new DecisionService( mockBucketer, @@ -700,12 +689,12 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI ) ), v4ProjectConfig - ); + ).getResult(); assertEquals(expectedVariation, featureDecision.variation); assertEquals(FeatureDecision.DecisionSource.ROLLOUT, featureDecision.decisionSource); // verify user is only bucketed once for everyone else rule - verify(mockBucketer, times(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); + verify(mockBucketer, times(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); } /** @@ -721,9 +710,9 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTargetin Variation englishCitizenVariation = englishCitizensRule.getVariations().get(0); Experiment everyoneElseRule = rollout.getExperiments().get(rollout.getExperiments().size() - 1); Variation everyoneElseVariation = everyoneElseRule.getVariations().get(0); - when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(null); - when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(everyoneElseVariation); - when(mockBucketer.bucket(eq(englishCitizensRule), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(englishCitizenVariation); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.nullNoReasons()); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.responseNoReasons(everyoneElseVariation)); + when(mockBucketer.bucket(eq(englishCitizensRule), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.responseNoReasons(englishCitizenVariation)); DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); @@ -734,7 +723,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTargetin ATTRIBUTE_NATIONALITY_KEY, AUDIENCE_ENGLISH_CITIZENS_VALUE ), v4ProjectConfig - ); + ).getResult(); assertEquals(englishCitizenVariation, featureDecision.variation); assertEquals(FeatureDecision.DecisionSource.ROLLOUT, featureDecision.decisionSource); logbackVerifier.expectMessage(Level.INFO, "Audiences for rule \"2\" collectively evaluated to null"); @@ -743,7 +732,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTargetin logbackVerifier.expectMessage(Level.DEBUG, "Audience \"4194404272\" evaluated to true."); logbackVerifier.expectMessage(Level.INFO, "Audiences for rule \"3\" collectively evaluated to true"); // verify user is only bucketed once for everyone else rule - verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); + verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); } //========= white list tests ==========/ @@ -755,7 +744,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTargetin public void getWhitelistedReturnsForcedVariation() { logbackVerifier.expectMessage(Level.INFO, "User \"" + whitelistedUserId + "\" is forced in variation \"" + whitelistedVariation.getKey() + "\"."); - assertEquals(whitelistedVariation, decisionService.getWhitelistedVariation(whitelistedExperiment, whitelistedUserId)); + assertEquals(whitelistedVariation, decisionService.getWhitelistedVariation(whitelistedExperiment, whitelistedUserId).getResult()); } /** @@ -784,7 +773,7 @@ public void getWhitelistedWithInvalidVariation() throws Exception { Level.ERROR, "Variation \"" + invalidVariationKey + "\" is not in the datafile. Not activating user \"" + userId + "\"."); - assertNull(decisionService.getWhitelistedVariation(experiment, userId)); + assertNull(decisionService.getWhitelistedVariation(experiment, userId).getResult()); } /** @@ -792,7 +781,7 @@ public void getWhitelistedWithInvalidVariation() throws Exception { */ @Test public void getWhitelistedReturnsNullWhenUserIsNotWhitelisted() throws Exception { - assertNull(decisionService.getWhitelistedVariation(whitelistedExperiment, genericUserId)); + assertNull(decisionService.getWhitelistedVariation(whitelistedExperiment, genericUserId).getResult()); } //======== User Profile tests =========// @@ -822,7 +811,7 @@ public void bucketReturnsVariationStoredInUserProfile() throws Exception { // ensure user with an entry in the user profile is bucketed into the corresponding stored variation assertEquals(variation, - decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), noAudienceProjectConfig)); + decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), noAudienceProjectConfig).getResult()); verify(userProfileService).lookup(userProfileId); } @@ -845,7 +834,7 @@ public void getStoredVariationLogsWhenLookupReturnsNull() throws Exception { logbackVerifier.expectMessage(Level.INFO, "No previously activated variation of experiment " + "\"" + experiment.getKey() + "\" for user \"" + userProfileId + "\" found in user profile."); - assertNull(decisionService.getStoredVariation(experiment, userProfile, noAudienceProjectConfig)); + assertNull(decisionService.getStoredVariation(experiment, userProfile, noAudienceProjectConfig).getResult()); } /** @@ -874,7 +863,7 @@ public void getStoredVariationReturnsNullWhenVariationIsNoLongerInConfig() throw "experiment \"" + experiment.getKey() + "\", but no matching variation " + "was found for that user. We will re-bucket the user."); - assertNull(decisionService.getStoredVariation(experiment, storedUserProfile, noAudienceProjectConfig)); + assertNull(decisionService.getStoredVariation(experiment, storedUserProfile, noAudienceProjectConfig).getResult()); } /** @@ -896,12 +885,12 @@ public void getVariationSavesBucketedVariationIntoUserProfile() throws Exception Collections.singletonMap(experiment.getId(), decision)); Bucketer mockBucketer = mock(Bucketer.class); - when(mockBucketer.bucket(eq(experiment), eq(userProfileId), eq(noAudienceProjectConfig), anyObject())).thenReturn(variation); + when(mockBucketer.bucket(eq(experiment), eq(userProfileId), eq(noAudienceProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(variation)); DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, userProfileService); assertEquals(variation, decisionService.getVariation( - experiment, userProfileId, Collections.emptyMap(), noAudienceProjectConfig) + experiment, userProfileId, Collections.emptyMap(), noAudienceProjectConfig).getResult() ); logbackVerifier.expectMessage(Level.INFO, String.format("Saved variation \"%s\" of experiment \"%s\" for user \"" + userProfileId + "\".", variation.getId(), @@ -958,10 +947,10 @@ public void getVariationSavesANewUserProfile() throws Exception { UserProfileService userProfileService = mock(UserProfileService.class); DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService); - when(bucketer.bucket(eq(experiment), eq(userProfileId), eq(noAudienceProjectConfig), anyObject())).thenReturn(variation); + when(bucketer.bucket(eq(experiment), eq(userProfileId), eq(noAudienceProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(variation)); when(userProfileService.lookup(userProfileId)).thenReturn(null); - assertEquals(variation, decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), noAudienceProjectConfig)); + assertEquals(variation, decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), noAudienceProjectConfig).getResult()); verify(userProfileService).save(expectedUserProfile.toMap()); } @@ -972,12 +961,12 @@ public void getVariationBucketingId() throws Exception { Experiment experiment = validProjectConfig.getExperiments().get(0); Variation expectedVariation = experiment.getVariations().get(0); - when(bucketer.bucket(eq(experiment), eq("bucketId"), eq(validProjectConfig), anyObject())).thenReturn(expectedVariation); + when(bucketer.bucket(eq(experiment), eq("bucketId"), eq(validProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(expectedVariation)); Map attr = new HashMap(); attr.put(ControlAttribute.BUCKETING_ATTRIBUTE.toString(), "bucketId"); // user excluded without audiences and whitelisting - assertThat(decisionService.getVariation(experiment, genericUserId, attr, validProjectConfig), is(expectedVariation)); + assertThat(decisionService.getVariation(experiment, genericUserId, attr, validProjectConfig).getResult(), is(expectedVariation)); } @@ -996,8 +985,8 @@ public void getVariationForRolloutWithBucketingId() { attributes.put(ControlAttribute.BUCKETING_ATTRIBUTE.toString(), bucketingId); Bucketer bucketer = mock(Bucketer.class); - when(bucketer.bucket(eq(rolloutRuleExperiment), eq(userId), eq(v4ProjectConfig), anyObject())).thenReturn(null); - when(bucketer.bucket(eq(rolloutRuleExperiment), eq(bucketingId), eq(v4ProjectConfig), anyObject())).thenReturn(rolloutVariation); + when(bucketer.bucket(eq(rolloutRuleExperiment), eq(userId), eq(v4ProjectConfig))).thenReturn(DecisionResponse.nullNoReasons()); + when(bucketer.bucket(eq(rolloutRuleExperiment), eq(bucketingId), eq(v4ProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(rolloutVariation)); DecisionService decisionService = spy(new DecisionService( bucketer, @@ -1010,7 +999,7 @@ public void getVariationForRolloutWithBucketingId() { rolloutVariation, FeatureDecision.DecisionSource.ROLLOUT); - FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, attributes, v4ProjectConfig); + FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, attributes, v4ProjectConfig).getResult(); assertEquals(expectedFeatureDecision, featureDecision); } @@ -1049,7 +1038,7 @@ public void setForcedVariationNullUserId() { @SuppressFBWarnings("NP") public void getForcedVariationNullUserId() { Experiment experiment = validProjectConfig.getExperimentKeyMapping().get("etag1"); - assertNull(decisionService.getForcedVariation(experiment, null)); + assertNull(decisionService.getForcedVariation(experiment, null).getResult()); } @Test @@ -1061,7 +1050,7 @@ public void setForcedVariationEmptyUserId() { @Test public void getForcedVariationEmptyUserId() { Experiment experiment = validProjectConfig.getExperimentKeyMapping().get("etag1"); - assertNull(decisionService.getForcedVariation(experiment, "")); + assertNull(decisionService.getForcedVariation(experiment, "").getResult()); } /* Invalid Variation Id (set only */ @@ -1075,7 +1064,7 @@ public void setForcedVariationWrongVariationKey() { public void setForcedVariationNullVariationKey() { Experiment experiment = validProjectConfig.getExperimentKeyMapping().get("etag1"); assertFalse(decisionService.setForcedVariation(experiment, "testUser1", null)); - assertNull(decisionService.getForcedVariation(experiment, "testUser1")); + assertNull(decisionService.getForcedVariation(experiment, "testUser1").getResult()); } @Test @@ -1090,7 +1079,7 @@ public void setForcedVariationDifferentVariations() { Experiment experiment = validProjectConfig.getExperimentKeyMapping().get("etag1"); assertTrue(decisionService.setForcedVariation(experiment, "testUser1", "vtag1")); assertTrue(decisionService.setForcedVariation(experiment, "testUser1", "vtag2")); - assertEquals(decisionService.getForcedVariation(experiment, "testUser1").getKey(), "vtag2"); + assertEquals(decisionService.getForcedVariation(experiment, "testUser1").getResult().getKey(), "vtag2"); assertTrue(decisionService.setForcedVariation(experiment, "testUser1", null)); } @@ -1105,11 +1094,11 @@ public void setForcedVariationMultipleVariationsExperiments() { assertTrue(decisionService.setForcedVariation(experiment2, "testUser1", "vtag3")); assertTrue(decisionService.setForcedVariation(experiment2, "testUser2", "vtag4")); - assertEquals(decisionService.getForcedVariation(experiment1, "testUser1").getKey(), "vtag1"); - assertEquals(decisionService.getForcedVariation(experiment1, "testUser2").getKey(), "vtag2"); + assertEquals(decisionService.getForcedVariation(experiment1, "testUser1").getResult().getKey(), "vtag1"); + assertEquals(decisionService.getForcedVariation(experiment1, "testUser2").getResult().getKey(), "vtag2"); - assertEquals(decisionService.getForcedVariation(experiment2, "testUser1").getKey(), "vtag3"); - assertEquals(decisionService.getForcedVariation(experiment2, "testUser2").getKey(), "vtag4"); + assertEquals(decisionService.getForcedVariation(experiment2, "testUser1").getResult().getKey(), "vtag3"); + assertEquals(decisionService.getForcedVariation(experiment2, "testUser2").getResult().getKey(), "vtag4"); assertTrue(decisionService.setForcedVariation(experiment1, "testUser1", null)); assertTrue(decisionService.setForcedVariation(experiment1, "testUser2", null)); @@ -1117,11 +1106,11 @@ public void setForcedVariationMultipleVariationsExperiments() { assertTrue(decisionService.setForcedVariation(experiment2, "testUser1", null)); assertTrue(decisionService.setForcedVariation(experiment2, "testUser2", null)); - assertNull(decisionService.getForcedVariation(experiment1, "testUser1")); - assertNull(decisionService.getForcedVariation(experiment1, "testUser2")); + assertNull(decisionService.getForcedVariation(experiment1, "testUser1").getResult()); + assertNull(decisionService.getForcedVariation(experiment1, "testUser2").getResult()); - assertNull(decisionService.getForcedVariation(experiment2, "testUser1")); - assertNull(decisionService.getForcedVariation(experiment2, "testUser2")); + assertNull(decisionService.getForcedVariation(experiment2, "testUser1").getResult()); + assertNull(decisionService.getForcedVariation(experiment2, "testUser2").getResult()); } @Test @@ -1134,21 +1123,21 @@ public void setForcedVariationMultipleUsers() { assertTrue(decisionService.setForcedVariation(experiment1, "testUser3", "vtag1")); assertTrue(decisionService.setForcedVariation(experiment1, "testUser4", "vtag1")); - assertEquals(decisionService.getForcedVariation(experiment1, "testUser1").getKey(), "vtag1"); - assertEquals(decisionService.getForcedVariation(experiment1, "testUser2").getKey(), "vtag1"); - assertEquals(decisionService.getForcedVariation(experiment1, "testUser3").getKey(), "vtag1"); - assertEquals(decisionService.getForcedVariation(experiment1, "testUser4").getKey(), "vtag1"); + assertEquals(decisionService.getForcedVariation(experiment1, "testUser1").getResult().getKey(), "vtag1"); + assertEquals(decisionService.getForcedVariation(experiment1, "testUser2").getResult().getKey(), "vtag1"); + assertEquals(decisionService.getForcedVariation(experiment1, "testUser3").getResult().getKey(), "vtag1"); + assertEquals(decisionService.getForcedVariation(experiment1, "testUser4").getResult().getKey(), "vtag1"); assertTrue(decisionService.setForcedVariation(experiment1, "testUser1", null)); assertTrue(decisionService.setForcedVariation(experiment1, "testUser2", null)); assertTrue(decisionService.setForcedVariation(experiment1, "testUser3", null)); assertTrue(decisionService.setForcedVariation(experiment1, "testUser4", null)); - assertNull(decisionService.getForcedVariation(experiment1, "testUser1")); - assertNull(decisionService.getForcedVariation(experiment1, "testUser2")); + assertNull(decisionService.getForcedVariation(experiment1, "testUser1").getResult()); + assertNull(decisionService.getForcedVariation(experiment1, "testUser2").getResult()); - assertNull(decisionService.getForcedVariation(experiment2, "testUser1")); - assertNull(decisionService.getForcedVariation(experiment2, "testUser2")); + assertNull(decisionService.getForcedVariation(experiment2, "testUser1").getResult()); + assertNull(decisionService.getForcedVariation(experiment2, "testUser2").getResult()); } } diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java index 216099298..0a6e41ddc 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java @@ -18,8 +18,6 @@ import ch.qos.logback.classic.Level; import com.optimizely.ab.internal.LogbackVerifier; -import com.optimizely.ab.optimizelydecision.DecisionReasons; -import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Before; import org.junit.Rule; @@ -44,8 +42,6 @@ public class AudienceConditionEvaluationTest { Map testUserAttributes; Map testTypedUserAttributes; - DecisionReasons reasons = DefaultDecisionReasons.newInstance(); - @Before public void initialize() { testUserAttributes = new HashMap<>(); @@ -70,7 +66,7 @@ public void userAttributeEvaluateTrue() throws Exception { assertNull(testInstance.getMatch()); assertEquals(testInstance.getName(), "browser_type"); assertEquals(testInstance.getType(), "custom_attribute"); - assertTrue(testInstance.evaluate(null, testUserAttributes, reasons)); + assertTrue(testInstance.evaluate(null, testUserAttributes)); } /** @@ -79,7 +75,7 @@ public void userAttributeEvaluateTrue() throws Exception { @Test public void userAttributeEvaluateFalse() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", null, "firefox"); - assertFalse(testInstance.evaluate(null, testUserAttributes, reasons)); + assertFalse(testInstance.evaluate(null, testUserAttributes)); } /** @@ -88,7 +84,7 @@ public void userAttributeEvaluateFalse() throws Exception { @Test public void userAttributeUnknownAttribute() throws Exception { UserAttribute testInstance = new UserAttribute("unknown_dim", "custom_attribute", null, "unknown"); - assertFalse(testInstance.evaluate(null, testUserAttributes, reasons)); + assertFalse(testInstance.evaluate(null, testUserAttributes)); } /** @@ -97,7 +93,7 @@ public void userAttributeUnknownAttribute() throws Exception { @Test public void invalidMatchCondition() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "unknown_dimension", null, "chrome"); - assertNull(testInstance.evaluate(null, testUserAttributes, reasons)); + assertNull(testInstance.evaluate(null, testUserAttributes)); } /** @@ -106,7 +102,7 @@ public void invalidMatchCondition() throws Exception { @Test public void invalidMatch() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "blah", "chrome"); - assertNull(testInstance.evaluate(null, testUserAttributes, reasons)); + assertNull(testInstance.evaluate(null, testUserAttributes)); logbackVerifier.expectMessage(Level.WARN, "Audience condition \"{name='browser_type', type='custom_attribute', match='blah', value='chrome'}\" uses an unknown match type. You may need to upgrade to a newer release of the Optimizely SDK"); } @@ -117,7 +113,7 @@ public void invalidMatch() throws Exception { @Test public void unexpectedAttributeType() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "gt", 20); - assertNull(testInstance.evaluate(null, testUserAttributes, reasons)); + assertNull(testInstance.evaluate(null, testUserAttributes)); logbackVerifier.expectMessage(Level.WARN, "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because a value of type \"java.lang.String\" was passed for user attribute \"browser_type\""); } @@ -128,7 +124,7 @@ public void unexpectedAttributeType() throws Exception { @Test public void unexpectedAttributeTypeNull() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "gt", 20); - assertNull(testInstance.evaluate(null, Collections.singletonMap("browser_type", null), reasons)); + assertNull(testInstance.evaluate(null, Collections.singletonMap("browser_type", null))); logbackVerifier.expectMessage(Level.DEBUG, "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because a null value was passed for user attribute \"browser_type\""); } @@ -140,7 +136,7 @@ public void unexpectedAttributeTypeNull() throws Exception { @Test public void missingAttribute() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "gt", 20); - assertNull(testInstance.evaluate(null, Collections.EMPTY_MAP, reasons)); + assertNull(testInstance.evaluate(null, Collections.EMPTY_MAP)); logbackVerifier.expectMessage(Level.DEBUG, "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because no value was passed for user attribute \"browser_type\""); } @@ -151,7 +147,7 @@ public void missingAttribute() throws Exception { @Test public void nullAttribute() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "gt", 20); - assertNull(testInstance.evaluate(null, null, reasons)); + assertNull(testInstance.evaluate(null, null)); logbackVerifier.expectMessage(Level.DEBUG, "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because no value was passed for user attribute \"browser_type\""); } @@ -162,7 +158,7 @@ public void nullAttribute() throws Exception { @Test public void unknownConditionType() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "blah", "exists", "firefox"); - assertNull(testInstance.evaluate(null, testUserAttributes, reasons)); + assertNull(testInstance.evaluate(null, testUserAttributes)); logbackVerifier.expectMessage(Level.WARN, "Audience condition \"{name='browser_type', type='blah', match='exists', value='firefox'}\" uses an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK."); } @@ -176,9 +172,9 @@ public void existsMatchConditionEmptyStringEvaluatesTrue() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "exists", "firefox"); Map attributes = new HashMap<>(); attributes.put("browser_type", ""); - assertTrue(testInstance.evaluate(null, attributes, reasons)); + assertTrue(testInstance.evaluate(null, attributes)); attributes.put("browser_type", null); - assertFalse(testInstance.evaluate(null, attributes, reasons)); + assertFalse(testInstance.evaluate(null, attributes)); } /** @@ -188,16 +184,16 @@ public void existsMatchConditionEmptyStringEvaluatesTrue() throws Exception { @Test public void existsMatchConditionEvaluatesTrue() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "exists", "firefox"); - assertTrue(testInstance.evaluate(null, testUserAttributes, reasons)); + assertTrue(testInstance.evaluate(null, testUserAttributes)); UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute", "exists", false); UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "exists", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "exists", 4.55); UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "exists", testUserAttributes); - assertTrue(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); - assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); - assertTrue(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceObject.evaluate(null, testTypedUserAttributes)); } /** @@ -208,8 +204,8 @@ public void existsMatchConditionEvaluatesTrue() throws Exception { public void existsMatchConditionEvaluatesFalse() throws Exception { UserAttribute testInstance = new UserAttribute("bad_var", "custom_attribute", "exists", "chrome"); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "exists", "chrome"); - assertFalse(testInstance.evaluate(null, testTypedUserAttributes, reasons)); - assertFalse(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); + assertFalse(testInstance.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceNull.evaluate(null, testTypedUserAttributes)); } /** @@ -224,11 +220,11 @@ public void exactMatchConditionEvaluatesTrue() { UserAttribute testInstanceFloat = new UserAttribute("num_size", "custom_attribute", "exact", (float) 3); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "exact", 3.55); - assertTrue(testInstanceString.evaluate(null, testUserAttributes, reasons)); - assertTrue(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); - assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 3), reasons)); - assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testUserAttributes)); + assertTrue(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 3))); + assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); } /** @@ -261,22 +257,22 @@ public void exactMatchConditionEvaluatesNullWithInvalidUserAttr() { assertNull(testInstanceInteger.evaluate( null, - Collections.singletonMap("num_size", bigInteger), reasons)); + Collections.singletonMap("num_size", bigInteger))); assertNull(testInstanceFloat.evaluate( null, - Collections.singletonMap("num_size", invalidFloatValue), reasons)); + Collections.singletonMap("num_size", invalidFloatValue))); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble), reasons)); + Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble), reasons)); + Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", infiniteNANDouble)), reasons)); + Collections.singletonMap("num_counts", infiniteNANDouble)))); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", largeDouble)), reasons)); + Collections.singletonMap("num_counts", largeDouble)))); } /** @@ -294,10 +290,10 @@ public void invalidExactMatchConditionEvaluatesNull() { UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "exact", infiniteNegativeInfiniteDouble); UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "exact", infiniteNANDouble); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); } /** @@ -311,10 +307,10 @@ public void exactMatchConditionEvaluatesFalse() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "exact", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "exact", 5.55); - assertFalse(testInstanceString.evaluate(null, testUserAttributes, reasons)); - assertFalse(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); - assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); - assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertFalse(testInstanceString.evaluate(null, testUserAttributes)); + assertFalse(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); } /** @@ -330,15 +326,15 @@ public void exactMatchConditionEvaluatesNull() { UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "exact", "3.55"); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "exact", "null_val"); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceString.evaluate(null, testUserAttributes, reasons)); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceString.evaluate(null, testUserAttributes)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); Map attr = new HashMap<>(); attr.put("browser_type", "true"); - assertNull(testInstanceString.evaluate(null, attr, reasons)); + assertNull(testInstanceString.evaluate(null, attr)); } /** @@ -352,13 +348,13 @@ public void gtMatchConditionEvaluatesTrue() { UserAttribute testInstanceFloat = new UserAttribute("num_size", "custom_attribute", "gt", (float) 2); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "gt", 2.55); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); - assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 3), reasons)); - assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 3))); + assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); Map badAttributes = new HashMap<>(); badAttributes.put("num_size", "bobs burgers"); - assertNull(testInstanceInteger.evaluate(null, badAttributes, reasons)); + assertNull(testInstanceInteger.evaluate(null, badAttributes)); } /** @@ -392,22 +388,22 @@ public void gtMatchConditionEvaluatesNullWithInvalidUserAttr() { assertNull(testInstanceInteger.evaluate( null, - Collections.singletonMap("num_size", bigInteger), reasons)); + Collections.singletonMap("num_size", bigInteger))); assertNull(testInstanceFloat.evaluate( null, - Collections.singletonMap("num_size", invalidFloatValue), reasons)); + Collections.singletonMap("num_size", invalidFloatValue))); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble), reasons)); + Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble), reasons)); + Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", infiniteNANDouble)), reasons)); + Collections.singletonMap("num_counts", infiniteNANDouble)))); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", largeDouble)), reasons)); + Collections.singletonMap("num_counts", largeDouble)))); } /** @@ -425,10 +421,10 @@ public void gtMatchConditionEvaluatesNullWithInvalidAttr() { UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "gt", infiniteNegativeInfiniteDouble); UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "gt", infiniteNANDouble); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); } /** @@ -441,8 +437,8 @@ public void gtMatchConditionEvaluatesFalse() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "gt", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "gt", 5.55); - assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); - assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); } /** @@ -456,10 +452,10 @@ public void gtMatchConditionEvaluatesNull() { UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "gt", 3.5); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "gt", 3.5); - assertNull(testInstanceString.evaluate(null, testUserAttributes, reasons)); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceString.evaluate(null, testUserAttributes)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); } @@ -474,13 +470,13 @@ public void geMatchConditionEvaluatesTrue() { UserAttribute testInstanceFloat = new UserAttribute("num_size", "custom_attribute", "ge", (float) 2); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "ge", 2.55); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); - assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 2), reasons)); - assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 2))); + assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); Map badAttributes = new HashMap<>(); badAttributes.put("num_size", "bobs burgers"); - assertNull(testInstanceInteger.evaluate(null, badAttributes, reasons)); + assertNull(testInstanceInteger.evaluate(null, badAttributes)); } /** @@ -514,22 +510,22 @@ public void geMatchConditionEvaluatesNullWithInvalidUserAttr() { assertNull(testInstanceInteger.evaluate( null, - Collections.singletonMap("num_size", bigInteger), reasons)); + Collections.singletonMap("num_size", bigInteger))); assertNull(testInstanceFloat.evaluate( null, - Collections.singletonMap("num_size", invalidFloatValue), reasons)); + Collections.singletonMap("num_size", invalidFloatValue))); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble), reasons)); + Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble), reasons)); + Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", infiniteNANDouble)), reasons)); + Collections.singletonMap("num_counts", infiniteNANDouble)))); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", largeDouble)), reasons)); + Collections.singletonMap("num_counts", largeDouble)))); } /** @@ -547,10 +543,10 @@ public void geMatchConditionEvaluatesNullWithInvalidAttr() { UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "ge", infiniteNegativeInfiniteDouble); UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "ge", infiniteNANDouble); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); } /** @@ -563,8 +559,8 @@ public void geMatchConditionEvaluatesFalse() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "ge", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "ge", 5.55); - assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); - assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); } /** @@ -578,10 +574,10 @@ public void geMatchConditionEvaluatesNull() { UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "ge", 3.5); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "ge", 3.5); - assertNull(testInstanceString.evaluate(null, testUserAttributes, reasons)); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceString.evaluate(null, testUserAttributes)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); } @@ -595,8 +591,8 @@ public void ltMatchConditionEvaluatesTrue() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "lt", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "lt", 5.55); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); - assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); } /** @@ -609,8 +605,8 @@ public void ltMatchConditionEvaluatesFalse() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "lt", 2); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "lt", 2.55); - assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); - assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); } /** @@ -624,10 +620,10 @@ public void ltMatchConditionEvaluatesNull() { UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "lt", 3.5); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "lt", 3.5); - assertNull(testInstanceString.evaluate(null, testUserAttributes, reasons)); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceString.evaluate(null, testUserAttributes)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); } /** @@ -661,22 +657,22 @@ public void ltMatchConditionEvaluatesNullWithInvalidUserAttr() { assertNull(testInstanceInteger.evaluate( null, - Collections.singletonMap("num_size", bigInteger), reasons)); + Collections.singletonMap("num_size", bigInteger))); assertNull(testInstanceFloat.evaluate( null, - Collections.singletonMap("num_size", invalidFloatValue), reasons)); + Collections.singletonMap("num_size", invalidFloatValue))); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble), reasons)); + Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble), reasons)); + Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", infiniteNANDouble)), reasons)); + Collections.singletonMap("num_counts", infiniteNANDouble)))); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", largeDouble)), reasons)); + Collections.singletonMap("num_counts", largeDouble)))); } /** @@ -694,10 +690,10 @@ public void ltMatchConditionEvaluatesNullWithInvalidAttributes() { UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "lt", infiniteNegativeInfiniteDouble); UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "lt", infiniteNANDouble); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); } @@ -711,8 +707,8 @@ public void leMatchConditionEvaluatesTrue() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "le", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "le", 5.55); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); - assertTrue(testInstanceDouble.evaluate(null, Collections.singletonMap("num_counts", 5.55), reasons)); + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceDouble.evaluate(null, Collections.singletonMap("num_counts", 5.55))); } /** @@ -725,8 +721,8 @@ public void leMatchConditionEvaluatesFalse() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "le", 2); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "le", 2.55); - assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); - assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); } /** @@ -740,10 +736,10 @@ public void leMatchConditionEvaluatesNull() { UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "le", 3.5); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "le", 3.5); - assertNull(testInstanceString.evaluate(null, testUserAttributes, reasons)); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceString.evaluate(null, testUserAttributes)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); } /** @@ -777,22 +773,22 @@ public void leMatchConditionEvaluatesNullWithInvalidUserAttr() { assertNull(testInstanceInteger.evaluate( null, - Collections.singletonMap("num_size", bigInteger), reasons)); + Collections.singletonMap("num_size", bigInteger))); assertNull(testInstanceFloat.evaluate( null, - Collections.singletonMap("num_size", invalidFloatValue), reasons)); + Collections.singletonMap("num_size", invalidFloatValue))); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble), reasons)); + Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble), reasons)); + Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", infiniteNANDouble)), reasons)); + Collections.singletonMap("num_counts", infiniteNANDouble)))); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", largeDouble)), reasons)); + Collections.singletonMap("num_counts", largeDouble)))); } /** @@ -810,10 +806,10 @@ public void leMatchConditionEvaluatesNullWithInvalidAttributes() { UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "le", infiniteNegativeInfiniteDouble); UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "le", infiniteNANDouble); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); } /** @@ -823,7 +819,7 @@ public void leMatchConditionEvaluatesNullWithInvalidAttributes() { @Test public void substringMatchConditionEvaluatesTrue() { UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "substring", "chrome"); - assertTrue(testInstanceString.evaluate(null, testUserAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testUserAttributes)); } /** @@ -833,7 +829,7 @@ public void substringMatchConditionEvaluatesTrue() { @Test public void substringMatchConditionPartialMatchEvaluatesTrue() { UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "substring", "chro"); - assertTrue(testInstanceString.evaluate(null, testUserAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testUserAttributes)); } /** @@ -843,7 +839,7 @@ public void substringMatchConditionPartialMatchEvaluatesTrue() { @Test public void substringMatchConditionEvaluatesFalse() { UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "substring", "chr0me"); - assertFalse(testInstanceString.evaluate(null, testUserAttributes, reasons)); + assertFalse(testInstanceString.evaluate(null, testUserAttributes)); } /** @@ -858,11 +854,11 @@ public void substringMatchConditionEvaluatesNull() { UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "substring", "chrome1"); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "substring", "chrome1"); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); } //======== Semantic version evaluation tests ========// @@ -873,7 +869,7 @@ public void testSemanticVersionEqualsMatchInvalidInput() { Map testAttributes = new HashMap(); testAttributes.put("version", 2.0); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); + assertNull(testInstanceString.evaluate(null, testAttributes)); } @Test @@ -881,7 +877,7 @@ public void semanticVersionInvalidMajorShouldBeNumberOnly() { Map testAttributes = new HashMap(); testAttributes.put("version", "a.1.2"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); + assertNull(testInstanceString.evaluate(null, testAttributes)); } @Test @@ -889,7 +885,7 @@ public void semanticVersionInvalidMinorShouldBeNumberOnly() { Map testAttributes = new HashMap(); testAttributes.put("version", "1.b.2"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); + assertNull(testInstanceString.evaluate(null, testAttributes)); } @Test @@ -897,7 +893,7 @@ public void semanticVersionInvalidPatchShouldBeNumberOnly() { Map testAttributes = new HashMap(); testAttributes.put("version", "1.2.c"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); + assertNull(testInstanceString.evaluate(null, testAttributes)); } // Test SemanticVersionEqualsMatch returns null if given invalid UserCondition Variable type @@ -906,7 +902,7 @@ public void testSemanticVersionEqualsMatchInvalidUserConditionVariable() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.0"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", 2.0); - assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); + assertNull(testInstanceString.evaluate(null, testAttributes)); } // Test SemanticVersionGTMatch returns null if given invalid value type @@ -915,7 +911,7 @@ public void testSemanticVersionGTMatchInvalidInput() { Map testAttributes = new HashMap(); testAttributes.put("version", false); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); + assertNull(testInstanceString.evaluate(null, testAttributes)); } // Test SemanticVersionGEMatch returns null if given invalid value type @@ -924,7 +920,7 @@ public void testSemanticVersionGEMatchInvalidInput() { Map testAttributes = new HashMap(); testAttributes.put("version", 2); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); + assertNull(testInstanceString.evaluate(null, testAttributes)); } // Test SemanticVersionLTMatch returns null if given invalid value type @@ -933,7 +929,7 @@ public void testSemanticVersionLTMatchInvalidInput() { Map testAttributes = new HashMap(); testAttributes.put("version", 2); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); + assertNull(testInstanceString.evaluate(null, testAttributes)); } // Test SemanticVersionLEMatch returns null if given invalid value type @@ -942,7 +938,7 @@ public void testSemanticVersionLEMatchInvalidInput() { Map testAttributes = new HashMap(); testAttributes.put("version", 2); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); + assertNull(testInstanceString.evaluate(null, testAttributes)); } // Test if not same when targetVersion is only major.minor.patch and version is major.minor @@ -951,7 +947,7 @@ public void testIsSemanticNotSameConditionValueMajorMinorPatch() { Map testAttributes = new HashMap(); testAttributes.put("version", "1.2"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "1.2.0"); - assertFalse(testInstanceString.evaluate(null, testAttributes, reasons)); + assertFalse(testInstanceString.evaluate(null, testAttributes)); } // Test if same when target is only major but user condition checks only major.minor,patch @@ -960,7 +956,7 @@ public void testIsSemanticSameSingleDigit() { Map testAttributes = new HashMap(); testAttributes.put("version", "3.0.0"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3"); - assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testAttributes)); } // Test if greater when User value patch is greater even when its beta @@ -969,7 +965,7 @@ public void testIsSemanticGreaterWhenUserConditionComparesMajorMinorAndPatchVers Map testAttributes = new HashMap(); testAttributes.put("version", "3.1.1-beta"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.0"); - assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testAttributes)); } // Test if greater when preRelease is greater alphabetically @@ -978,7 +974,7 @@ public void testIsSemanticGreaterWhenMajorMinorPatchReleaseVersionCharacter() { Map testAttributes = new HashMap(); testAttributes.put("version", "3.1.1-beta.y.1+1.1"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.1-beta.x.1+1.1"); - assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testAttributes)); } // Test if greater when preRelease version number is greater @@ -987,7 +983,7 @@ public void testIsSemanticGreaterWhenMajorMinorPatchPreReleaseVersionNum() { Map testAttributes = new HashMap(); testAttributes.put("version", "3.1.1-beta.x.2+1.1"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.1-beta.x.1+1.1"); - assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testAttributes)); } // Test if equals semantic version even when only same preRelease is passed in user attribute and no build meta @@ -996,7 +992,7 @@ public void testIsSemanticEqualWhenMajorMinorPatchPreReleaseVersionNum() { Map testAttributes = new HashMap(); testAttributes.put("version", "3.1.1-beta.x.1"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3.1.1-beta.x.1"); - assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testAttributes)); } // Test if not same @@ -1005,7 +1001,7 @@ public void testIsSemanticNotSameReturnsFalse() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.2"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.1.1"); - assertFalse(testInstanceString.evaluate(null, testAttributes, reasons)); + assertFalse(testInstanceString.evaluate(null, testAttributes)); } // Test when target is full semantic version major.minor.patch @@ -1014,7 +1010,7 @@ public void testIsSemanticSameFull() { Map testAttributes = new HashMap(); testAttributes.put("version", "3.0.1"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3.0.1"); - assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testAttributes)); } // Test compare less when user condition checks only major.minor @@ -1023,7 +1019,7 @@ public void testIsSemanticLess() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.6"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.2"); - assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testAttributes)); } // When user condition checks major.minor but target is major.minor.patch then its equals @@ -1032,7 +1028,7 @@ public void testIsSemanticLessFalse() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.0"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.1"); - assertFalse(testInstanceString.evaluate(null, testAttributes, reasons)); + assertFalse(testInstanceString.evaluate(null, testAttributes)); } // Test compare less when target is full major.minor.patch @@ -1041,7 +1037,7 @@ public void testIsSemanticFullLess() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.6"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.1.9"); - assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testAttributes)); } // Test compare greater when user condition checks only major.minor @@ -1050,7 +1046,7 @@ public void testIsSemanticMore() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.3.6"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.2"); - assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testAttributes)); } // Test compare greater when both are major.minor.patch-beta but target is greater than user condition @@ -1059,7 +1055,7 @@ public void testIsSemanticMoreWhenBeta() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.3.6-beta"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.3.5-beta"); - assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testAttributes)); } // Test compare greater when target is major.minor.patch @@ -1068,7 +1064,7 @@ public void testIsSemanticFullMore() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.7"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.6"); - assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testAttributes)); } // Test compare greater when target is major.minor.patch is smaller then it returns false @@ -1077,7 +1073,7 @@ public void testSemanticVersionGTFullMoreReturnsFalse() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.10"); - assertFalse(testInstanceString.evaluate(null, testAttributes, reasons)); + assertFalse(testInstanceString.evaluate(null, testAttributes)); } // Test compare equal when both are exactly same - major.minor.patch-beta @@ -1086,7 +1082,7 @@ public void testIsSemanticFullEqual() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9-beta"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.1.9-beta"); - assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testAttributes)); } // Test compare equal when both major.minor.patch is same, but due to beta user condition is smaller @@ -1095,7 +1091,7 @@ public void testIsSemanticLessWhenBeta() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.9-beta"); - assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testAttributes)); } // Test compare greater when target is major.minor.patch-beta and user condition only compares major.minor.patch @@ -1104,7 +1100,7 @@ public void testIsSemanticGreaterBeta() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.9-beta"); - assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testAttributes)); } // Test compare equal when target is major.minor.patch @@ -1113,7 +1109,7 @@ public void testIsSemanticLessEqualsWhenEqualsReturnsTrue() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.1.9"); - assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testAttributes)); } // Test compare less when target is major.minor.patch @@ -1122,7 +1118,7 @@ public void testIsSemanticLessEqualsWhenLessReturnsTrue() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.132.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.233.91"); - assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testAttributes)); } // Test compare less when target is major.minor.patch @@ -1131,7 +1127,7 @@ public void testIsSemanticLessEqualsWhenGreaterReturnsFalse() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.233.91"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.132.009"); - assertFalse(testInstanceString.evaluate(null, testAttributes, reasons)); + assertFalse(testInstanceString.evaluate(null, testAttributes)); } // Test compare equal when target is major.minor.patch @@ -1140,7 +1136,7 @@ public void testIsSemanticGreaterEqualsWhenEqualsReturnsTrue() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.1.9"); - assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testAttributes)); } // Test compare less when target is major.minor.patch @@ -1149,7 +1145,7 @@ public void testIsSemanticGreaterEqualsWhenLessReturnsTrue() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.233.91"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.132.9"); - assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); + assertTrue(testInstanceString.evaluate(null, testAttributes)); } // Test compare less when target is major.minor.patch @@ -1158,7 +1154,7 @@ public void testIsSemanticGreaterEqualsWhenLessReturnsFalse() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.132.009"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.233.91"); - assertFalse(testInstanceString.evaluate(null, testAttributes, reasons)); + assertFalse(testInstanceString.evaluate(null, testAttributes)); } /** @@ -1167,7 +1163,7 @@ public void testIsSemanticGreaterEqualsWhenLessReturnsFalse() { @Test public void notConditionEvaluateNull() { NotCondition notCondition = new NotCondition(new NullCondition()); - assertNull(notCondition.evaluate(null, testUserAttributes, reasons)); + assertNull(notCondition.evaluate(null, testUserAttributes)); } /** @@ -1176,11 +1172,11 @@ public void notConditionEvaluateNull() { @Test public void notConditionEvaluateTrue() { UserAttribute userAttribute = mock(UserAttribute.class); - when(userAttribute.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); + when(userAttribute.evaluate(null, testUserAttributes)).thenReturn(false); NotCondition notCondition = new NotCondition(userAttribute); - assertTrue(notCondition.evaluate(null, testUserAttributes, reasons)); - verify(userAttribute, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + assertTrue(notCondition.evaluate(null, testUserAttributes)); + verify(userAttribute, times(1)).evaluate(null, testUserAttributes); } /** @@ -1189,11 +1185,11 @@ public void notConditionEvaluateTrue() { @Test public void notConditionEvaluateFalse() { UserAttribute userAttribute = mock(UserAttribute.class); - when(userAttribute.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); + when(userAttribute.evaluate(null, testUserAttributes)).thenReturn(true); NotCondition notCondition = new NotCondition(userAttribute); - assertFalse(notCondition.evaluate(null, testUserAttributes, reasons)); - verify(userAttribute, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + assertFalse(notCondition.evaluate(null, testUserAttributes)); + verify(userAttribute, times(1)).evaluate(null, testUserAttributes); } /** @@ -1202,20 +1198,20 @@ public void notConditionEvaluateFalse() { @Test public void orConditionEvaluateTrue() { UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); + when(userAttribute1.evaluate(null, testUserAttributes)).thenReturn(true); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); + when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(false); List conditions = new ArrayList(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertTrue(orCondition.evaluate(null, testUserAttributes, reasons)); - verify(userAttribute1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + assertTrue(orCondition.evaluate(null, testUserAttributes)); + verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); // shouldn't be called due to short-circuiting in 'Or' evaluation - verify(userAttribute2, times(0)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + verify(userAttribute2, times(0)).evaluate(null, testUserAttributes); } /** @@ -1224,20 +1220,20 @@ public void orConditionEvaluateTrue() { @Test public void orConditionEvaluateTrueWithNullAndTrue() { UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(null); + when(userAttribute1.evaluate(null, testUserAttributes)).thenReturn(null); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); + when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(true); List conditions = new ArrayList(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertTrue(orCondition.evaluate(null, testUserAttributes, reasons)); - verify(userAttribute1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + assertTrue(orCondition.evaluate(null, testUserAttributes)); + verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); // shouldn't be called due to short-circuiting in 'Or' evaluation - verify(userAttribute2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + verify(userAttribute2, times(1)).evaluate(null, testUserAttributes); } /** @@ -1246,20 +1242,20 @@ public void orConditionEvaluateTrueWithNullAndTrue() { @Test public void orConditionEvaluateNullWithNullAndFalse() { UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(null); + when(userAttribute1.evaluate(null, testUserAttributes)).thenReturn(null); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); + when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(false); List conditions = new ArrayList(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertNull(orCondition.evaluate(null, testUserAttributes, reasons)); - verify(userAttribute1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + assertNull(orCondition.evaluate(null, testUserAttributes)); + verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); // shouldn't be called due to short-circuiting in 'Or' evaluation - verify(userAttribute2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + verify(userAttribute2, times(1)).evaluate(null, testUserAttributes); } /** @@ -1268,20 +1264,20 @@ public void orConditionEvaluateNullWithNullAndFalse() { @Test public void orConditionEvaluateFalseWithFalseAndFalse() { UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); + when(userAttribute1.evaluate(null, testUserAttributes)).thenReturn(false); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); + when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(false); List conditions = new ArrayList(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertFalse(orCondition.evaluate(null, testUserAttributes, reasons)); - verify(userAttribute1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + assertFalse(orCondition.evaluate(null, testUserAttributes)); + verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); // shouldn't be called due to short-circuiting in 'Or' evaluation - verify(userAttribute2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + verify(userAttribute2, times(1)).evaluate(null, testUserAttributes); } /** @@ -1290,19 +1286,19 @@ public void orConditionEvaluateFalseWithFalseAndFalse() { @Test public void orConditionEvaluateFalse() { UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); + when(userAttribute1.evaluate(null, testUserAttributes)).thenReturn(false); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); + when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(false); List conditions = new ArrayList(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertFalse(orCondition.evaluate(null, testUserAttributes, reasons)); - verify(userAttribute1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); - verify(userAttribute2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + assertFalse(orCondition.evaluate(null, testUserAttributes)); + verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); + verify(userAttribute2, times(1)).evaluate(null, testUserAttributes); } /** @@ -1311,19 +1307,19 @@ public void orConditionEvaluateFalse() { @Test public void andConditionEvaluateTrue() { OrCondition orCondition1 = mock(OrCondition.class); - when(orCondition1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); + when(orCondition1.evaluate(null, testUserAttributes)).thenReturn(true); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); + when(orCondition2.evaluate(null, testUserAttributes)).thenReturn(true); List conditions = new ArrayList(); conditions.add(orCondition1); conditions.add(orCondition2); AndCondition andCondition = new AndCondition(conditions); - assertTrue(andCondition.evaluate(null, testUserAttributes, reasons)); - verify(orCondition1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); - verify(orCondition2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + assertTrue(andCondition.evaluate(null, testUserAttributes)); + verify(orCondition1, times(1)).evaluate(null, testUserAttributes); + verify(orCondition2, times(1)).evaluate(null, testUserAttributes); } /** @@ -1332,19 +1328,19 @@ public void andConditionEvaluateTrue() { @Test public void andConditionEvaluateFalseWithNullAndFalse() { OrCondition orCondition1 = mock(OrCondition.class); - when(orCondition1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(null); + when(orCondition1.evaluate(null, testUserAttributes)).thenReturn(null); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); + when(orCondition2.evaluate(null, testUserAttributes)).thenReturn(false); List conditions = new ArrayList(); conditions.add(orCondition1); conditions.add(orCondition2); AndCondition andCondition = new AndCondition(conditions); - assertFalse(andCondition.evaluate(null, testUserAttributes, reasons)); - verify(orCondition1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); - verify(orCondition2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + assertFalse(andCondition.evaluate(null, testUserAttributes)); + verify(orCondition1, times(1)).evaluate(null, testUserAttributes); + verify(orCondition2, times(1)).evaluate(null, testUserAttributes); } /** @@ -1353,19 +1349,19 @@ public void andConditionEvaluateFalseWithNullAndFalse() { @Test public void andConditionEvaluateNullWithNullAndTrue() { OrCondition orCondition1 = mock(OrCondition.class); - when(orCondition1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(null); + when(orCondition1.evaluate(null, testUserAttributes)).thenReturn(null); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); + when(orCondition2.evaluate(null, testUserAttributes)).thenReturn(true); List conditions = new ArrayList(); conditions.add(orCondition1); conditions.add(orCondition2); AndCondition andCondition = new AndCondition(conditions); - assertNull(andCondition.evaluate(null, testUserAttributes, reasons)); - verify(orCondition1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); - verify(orCondition2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + assertNull(andCondition.evaluate(null, testUserAttributes)); + verify(orCondition1, times(1)).evaluate(null, testUserAttributes); + verify(orCondition2, times(1)).evaluate(null, testUserAttributes); } /** @@ -1374,10 +1370,10 @@ public void andConditionEvaluateNullWithNullAndTrue() { @Test public void andConditionEvaluateFalse() { OrCondition orCondition1 = mock(OrCondition.class); - when(orCondition1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); + when(orCondition1.evaluate(null, testUserAttributes)).thenReturn(false); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); + when(orCondition2.evaluate(null, testUserAttributes)).thenReturn(true); // and[false, true] List conditions = new ArrayList(); @@ -1385,13 +1381,13 @@ public void andConditionEvaluateFalse() { conditions.add(orCondition2); AndCondition andCondition = new AndCondition(conditions); - assertFalse(andCondition.evaluate(null, testUserAttributes, reasons)); - verify(orCondition1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + assertFalse(andCondition.evaluate(null, testUserAttributes)); + verify(orCondition1, times(1)).evaluate(null, testUserAttributes); // shouldn't be called due to short-circuiting in 'And' evaluation - verify(orCondition2, times(0)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + verify(orCondition2, times(0)).evaluate(null, testUserAttributes); OrCondition orCondition3 = mock(OrCondition.class); - when(orCondition3.evaluate(null, testUserAttributes, reasons)).thenReturn(null); + when(orCondition3.evaluate(null, testUserAttributes)).thenReturn(null); // and[null, false] List conditions2 = new ArrayList(); @@ -1399,7 +1395,7 @@ public void andConditionEvaluateFalse() { conditions2.add(orCondition1); AndCondition andCondition2 = new AndCondition(conditions2); - assertFalse(andCondition2.evaluate(null, testUserAttributes, reasons)); + assertFalse(andCondition2.evaluate(null, testUserAttributes)); // and[true, false, null] List conditions3 = new ArrayList(); @@ -1408,7 +1404,7 @@ public void andConditionEvaluateFalse() { conditions3.add(orCondition1); AndCondition andCondition3 = new AndCondition(conditions3); - assertFalse(andCondition3.evaluate(null, testUserAttributes, reasons)); + assertFalse(andCondition3.evaluate(null, testUserAttributes)); } /** @@ -1440,8 +1436,8 @@ public void nullValueEvaluate() { attributeValue ); - assertNull(nullValueAttribute.evaluate(null, Collections.emptyMap(), reasons)); - assertNull(nullValueAttribute.evaluate(null, Collections.singletonMap(attributeName, attributeValue), reasons)); - assertNull(nullValueAttribute.evaluate(null, (Collections.singletonMap(attributeName, "")), reasons)); + assertNull(nullValueAttribute.evaluate(null, Collections.emptyMap())); + assertNull(nullValueAttribute.evaluate(null, Collections.singletonMap(attributeName, attributeValue))); + assertNull(nullValueAttribute.evaluate(null, (Collections.singletonMap(attributeName, "")))); } } diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java index acb0cc5a4..1c5a48313 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java @@ -27,6 +27,7 @@ import com.optimizely.ab.event.internal.payload.EventBatch; import com.optimizely.ab.internal.ControlAttribute; import com.optimizely.ab.internal.ReservedEventKey; +import com.optimizely.ab.optimizelydecision.DecisionResponse; import org.junit.After; import org.junit.Test; import org.junit.runner.RunWith; @@ -737,7 +738,7 @@ public void createConversionParamsWithEventMetrics() throws Exception { // Bucket to the first variation for all experiments. for (Experiment experiment : validProjectConfig.getExperiments()) { when(mockBucketAlgorithm.bucket(experiment, userId, validProjectConfig)) - .thenReturn(experiment.getVariations().get(0)); + .thenReturn(DecisionResponse.responseNoReasons(experiment.getVariations().get(0))); } Map attributeMap = Collections.singletonMap(attribute.getKey(), "value"); @@ -822,7 +823,7 @@ public void createConversionEventAndroidClientEngineClientVersion() throws Excep Bucketer mockBucketAlgorithm = mock(Bucketer.class); for (Experiment experiment : validProjectConfig.getExperiments()) { when(mockBucketAlgorithm.bucket(experiment, userId, validProjectConfig)) - .thenReturn(experiment.getVariations().get(0)); + .thenReturn(DecisionResponse.responseNoReasons(experiment.getVariations().get(0))); } Map attributeMap = Collections.singletonMap(attribute.getKey(), "value"); @@ -857,7 +858,7 @@ public void createConversionEventAndroidTVClientEngineClientVersion() throws Exc Bucketer mockBucketAlgorithm = mock(Bucketer.class); for (Experiment experiment : projectConfig.getExperiments()) { when(mockBucketAlgorithm.bucket(experiment, userId, validProjectConfig)) - .thenReturn(experiment.getVariations().get(0)); + .thenReturn(DecisionResponse.responseNoReasons(experiment.getVariations().get(0))); } Map attributeMap = Collections.singletonMap(attribute.getKey(), "value"); diff --git a/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java b/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java index 841e5a504..fd1529aaf 100644 --- a/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java +++ b/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java @@ -128,7 +128,7 @@ public void isExperimentActiveReturnsFalseWhenTheExperimentIsNotStarted() { @Test public void doesUserMeetAudienceConditionsReturnsTrueIfExperimentHasNoAudiences() { Experiment experiment = noAudienceProjectConfig.getExperiments().get(0); - assertTrue(doesUserMeetAudienceConditions(noAudienceProjectConfig, experiment, Collections.emptyMap(), RULE, "Everyone Else")); + assertTrue(doesUserMeetAudienceConditions(noAudienceProjectConfig, experiment, Collections.emptyMap(), RULE, "Everyone Else").getResult()); } /** @@ -138,7 +138,7 @@ public void doesUserMeetAudienceConditionsReturnsTrueIfExperimentHasNoAudiences( @Test public void doesUserMeetAudienceConditionsEvaluatesEvenIfExperimentHasAudiencesButUserHasNoAttributes() { Experiment experiment = projectConfig.getExperiments().get(0); - Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, Collections.emptyMap(), EXPERIMENT, experiment.getKey()); + Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, Collections.emptyMap(), EXPERIMENT, experiment.getKey()).getResult(); assertTrue(result); logbackVerifier.expectMessage(Level.DEBUG, "Evaluating audiences for experiment \"etag1\": [100]."); @@ -158,7 +158,7 @@ public void doesUserMeetAudienceConditionsEvaluatesEvenIfExperimentHasAudiencesB @Test public void doesUserMeetAudienceConditionsEvaluatesEvenIfExperimentHasAudiencesButUserSendNullAttributes() throws Exception { Experiment experiment = projectConfig.getExperiments().get(0); - Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, null, EXPERIMENT, experiment.getKey()); + Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, null, EXPERIMENT, experiment.getKey()).getResult(); assertTrue(result); logbackVerifier.expectMessage(Level.DEBUG, @@ -179,7 +179,7 @@ public void doesUserMeetAudienceConditionsEvaluatesEvenIfExperimentHasAudiencesB public void doesUserMeetAudienceConditionsEvaluatesExperimentHasTypedAudiences() { Experiment experiment = v4ProjectConfig.getExperiments().get(1); Map attribute = Collections.singletonMap("booleanKey", true); - Boolean result = doesUserMeetAudienceConditions(v4ProjectConfig, experiment, attribute, EXPERIMENT, experiment.getKey()); + Boolean result = doesUserMeetAudienceConditions(v4ProjectConfig, experiment, attribute, EXPERIMENT, experiment.getKey()).getResult(); assertTrue(result); logbackVerifier.expectMessage(Level.DEBUG, @@ -200,7 +200,7 @@ public void doesUserMeetAudienceConditionsEvaluatesExperimentHasTypedAudiences() public void doesUserMeetAudienceConditionsReturnsTrueIfUserSatisfiesAnAudience() { Experiment experiment = projectConfig.getExperiments().get(0); Map attributes = Collections.singletonMap("browser_type", "chrome"); - Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, attributes, EXPERIMENT, experiment.getKey()); + Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, attributes, EXPERIMENT, experiment.getKey()).getResult(); assertTrue(result); logbackVerifier.expectMessage(Level.DEBUG, @@ -221,7 +221,7 @@ public void doesUserMeetAudienceConditionsReturnsTrueIfUserSatisfiesAnAudience() public void doesUserMeetAudienceConditionsReturnsTrueIfUserDoesNotSatisfyAnyAudiences() { Experiment experiment = projectConfig.getExperiments().get(0); Map attributes = Collections.singletonMap("browser_type", "firefox"); - Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, attributes, EXPERIMENT, experiment.getKey()); + Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, attributes, EXPERIMENT, experiment.getKey()).getResult(); assertFalse(result); logbackVerifier.expectMessage(Level.DEBUG, @@ -246,8 +246,8 @@ public void doesUserMeetAudienceConditionsHandlesNullValue() { AUDIENCE_WITH_MISSING_VALUE_VALUE); Map nonMatchingMap = Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, "American"); - assertTrue(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, satisfiesFirstCondition, EXPERIMENT, experiment.getKey())); - assertFalse(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, nonMatchingMap, EXPERIMENT, experiment.getKey())); + assertTrue(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, satisfiesFirstCondition, EXPERIMENT, experiment.getKey()).getResult()); + assertFalse(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, nonMatchingMap, EXPERIMENT, experiment.getKey()).getResult()); } /** @@ -258,7 +258,7 @@ public void doesUserMeetAudienceConditionsHandlesNullValueAttributesWithNull() { Experiment experiment = v4ProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_WITH_MALFORMED_AUDIENCE_KEY); Map attributesWithNull = Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, null); - assertFalse(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, attributesWithNull, EXPERIMENT, experiment.getKey())); + assertFalse(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, attributesWithNull, EXPERIMENT, experiment.getKey()).getResult()); logbackVerifier.expectMessage(Level.DEBUG, "Starting to evaluate audience \"2196265320\" with conditions: [and, [or, [or, {name='nationality', type='custom_attribute', match='null', value='English'}, {name='nationality', type='custom_attribute', match='null', value=null}]]]."); @@ -279,7 +279,7 @@ public void doesUserMeetAudienceConditionsHandlesNullConditionValue() { Map attributesEmpty = Collections.emptyMap(); // It should explicitly be set to null otherwise we will return false on empty maps - assertFalse(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, attributesEmpty, EXPERIMENT, experiment.getKey())); + assertFalse(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, attributesEmpty, EXPERIMENT, experiment.getKey()).getResult()); logbackVerifier.expectMessage(Level.DEBUG, "Starting to evaluate audience \"2196265320\" with conditions: [and, [or, [or, {name='nationality', type='custom_attribute', match='null', value='English'}, {name='nationality', type='custom_attribute', match='null', value=null}]]]."); From c17ff5e3437f82fce55125c2b74fa502613554e3 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Thu, 14 Jan 2021 09:59:14 -0800 Subject: [PATCH 038/147] prepare for release 3.8.0-beta2 (#418) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ac46949..ba9f9a7af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Optimizely Java X SDK Changelog +## [3.8.0-beta2] +January 14th, 2021 + +### Fixes: +- Clone user-context before calling Optimizely decide ([#417](https://github.com/optimizely/java-sdk/pull/417)) +- Return reasons as a part of tuple in decision hierarchy ([#415](https://github.com/optimizely/java-sdk/pull/415)) + ## [3.8.0-beta] December 14th, 2020 From 06b128e4fb4f6398c0b5839452d8c905d509c3a7 Mon Sep 17 00:00:00 2001 From: mnoman09 Date: Thu, 28 Jan 2021 04:01:42 +0500 Subject: [PATCH 039/147] Fix: SendImpressionEvent will return false when event is not sent (#420) --- .../java/com/optimizely/ab/Optimizely.java | 22 ++-- .../ab/OptimizelyUserContextTest.java | 108 +++++++++++++++++- 2 files changed, 118 insertions(+), 12 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 4440608b3..fb62ded7f 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -255,14 +255,14 @@ private void sendImpression(@Nonnull ProjectConfig projectConfig, * @param flagKey It can either be empty if ruleType is experiment or it's feature key in case ruleType is feature-test or rollout * @param ruleType It can either be experiment in case impression event is sent from activate or it's feature-test or rollout */ - private void sendImpression(@Nonnull ProjectConfig projectConfig, - @Nullable Experiment experiment, - @Nonnull String userId, - @Nonnull Map filteredAttributes, - @Nullable Variation variation, - @Nonnull String flagKey, - @Nonnull String ruleType, - @Nonnull boolean enabled) { + private boolean sendImpression(@Nonnull ProjectConfig projectConfig, + @Nullable Experiment experiment, + @Nonnull String userId, + @Nonnull Map filteredAttributes, + @Nullable Variation variation, + @Nonnull String flagKey, + @Nonnull String ruleType, + @Nonnull boolean enabled) { UserEvent userEvent = UserEventFactory.createImpressionEvent( projectConfig, @@ -275,7 +275,7 @@ private void sendImpression(@Nonnull ProjectConfig projectConfig, enabled); if (userEvent == null) { - return; + return false; } eventProcessor.process(userEvent); if (experiment != null) { @@ -290,6 +290,7 @@ private void sendImpression(@Nonnull ProjectConfig projectConfig, experiment, userId, filteredAttributes, variation, impressionEvent); notificationCenter.send(activateNotification); } + return true; } //======== track calls ========// @@ -1218,7 +1219,7 @@ OptimizelyDecision decide(@Nonnull OptimizelyUserContext user, String ruleKey = flagDecision.experiment != null ? flagDecision.experiment.getKey() : null; if (!allOptions.contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT)) { - sendImpression( + decisionEventDispatched = sendImpression( projectConfig, flagDecision.experiment, userId, @@ -1227,7 +1228,6 @@ OptimizelyDecision decide(@Nonnull OptimizelyUserContext user, key, decisionSource.toString(), flagEnabled); - decisionEventDispatched = true; } DecisionNotification decisionNotification = DecisionNotification.newFlagDecisionNotificationBuilder() diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java index 5b4c29382..0ac8beedb 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2020, Optimizely and contributors + * Copyright 2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -495,6 +495,112 @@ public void decide_doNotSendEvent_withOption() { OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.DISABLE_DECISION_EVENT)); assertEquals(decision.getVariationKey(), "variation_with_traffic"); + + // impression event not expected here + } + + @Test + public void decide_sendEvent_featureTest_withSendFlagDecisionsOn() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + Map attributes = Collections.singletonMap("gender", "f"); + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + + optimizely.addDecisionNotificationHandler( + decisionNotification -> { + Assert.assertEquals(decisionNotification.getDecisionInfo().get(DECISION_EVENT_DISPATCHED), true); + isListenerCalled = true; + }); + + String flagKey = "feature_2"; + String experimentId = "10420810910"; + String variationId = "10418551353"; + isListenerCalled = false; + user.decide(flagKey); + assertTrue(isListenerCalled); + + eventHandler.expectImpression(experimentId, variationId, userId, attributes); + } + + @Test + public void decide_sendEvent_rollout_withSendFlagDecisionsOn() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + Map attributes = Collections.singletonMap("gender", "f"); + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + + optimizely.addDecisionNotificationHandler( + decisionNotification -> { + Assert.assertEquals(decisionNotification.getDecisionInfo().get(DECISION_EVENT_DISPATCHED), true); + isListenerCalled = true; + }); + + String flagKey = "feature_3"; + String experimentId = null; + String variationId = null; + isListenerCalled = false; + user.decide(flagKey); + assertTrue(isListenerCalled); + + eventHandler.expectImpression(null, "", userId, attributes); + } + + @Test + public void decide_sendEvent_featureTest_withSendFlagDecisionsOff() { + String datafileWithSendFlagDecisionsOff = datafile.replace("\"sendFlagDecisions\": true", "\"sendFlagDecisions\": false"); + optimizely = new Optimizely.Builder() + .withDatafile(datafileWithSendFlagDecisionsOff) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + Map attributes = Collections.singletonMap("gender", "f"); + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + + optimizely.addDecisionNotificationHandler( + decisionNotification -> { + Assert.assertEquals(decisionNotification.getDecisionInfo().get(DECISION_EVENT_DISPATCHED), true); + isListenerCalled = true; + }); + + String flagKey = "feature_2"; + String experimentId = "10420810910"; + String variationId = "10418551353"; + isListenerCalled = false; + user.decide(flagKey); + assertTrue(isListenerCalled); + + eventHandler.expectImpression(experimentId, variationId, userId, attributes); + } + + @Test + public void decide_sendEvent_rollout_withSendFlagDecisionsOff() { + String datafileWithSendFlagDecisionsOff = datafile.replace("\"sendFlagDecisions\": true", "\"sendFlagDecisions\": false"); + optimizely = new Optimizely.Builder() + .withDatafile(datafileWithSendFlagDecisionsOff) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + Map attributes = Collections.singletonMap("gender", "f"); + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + + optimizely.addDecisionNotificationHandler( + decisionNotification -> { + Assert.assertEquals(decisionNotification.getDecisionInfo().get(DECISION_EVENT_DISPATCHED), false); + isListenerCalled = true; + }); + + String flagKey = "feature_3"; + isListenerCalled = false; + user.decide(flagKey); + assertTrue(isListenerCalled); + + // impression event not expected here } // notifications From 5a09786e3f1b909f6a24c29944c5393013d66a67 Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Wed, 3 Feb 2021 15:32:48 -0800 Subject: [PATCH 040/147] fix: close the closable response. (#419) --- .../ab/config/HttpProjectConfigManager.java | 14 ++++++++++-- .../config/HttpProjectConfigManagerTest.java | 22 ++++++++++++++----- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java index afe7db451..eb43e67a5 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java @@ -24,6 +24,7 @@ import com.optimizely.ab.notification.NotificationCenter; import org.apache.http.*; import org.apache.http.client.ClientProtocolException; +import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.util.EntityUtils; import org.slf4j.Logger; @@ -116,10 +117,10 @@ static ProjectConfig parseProjectConfig(String datafile) throws ConfigParseExcep @Override protected ProjectConfig poll() { HttpGet httpGet = createHttpRequest(); - + CloseableHttpResponse response = null; logger.debug("Fetching datafile from: {}", httpGet.getURI()); try { - HttpResponse response = httpClient.execute(httpGet); + response = httpClient.execute(httpGet); String datafile = getDatafileFromResponse(response); if (datafile == null) { return null; @@ -128,6 +129,15 @@ protected ProjectConfig poll() { } catch (ConfigParseException | IOException e) { logger.error("Error fetching datafile", e); } + finally { + if (response != null) { + try { + response.close(); + } catch (IOException e) { + logger.warn(e.getLocalizedMessage()); + } + } + } return null; } diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java index 43c0d3c31..fb76a27b7 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java @@ -36,6 +36,7 @@ import org.mockito.Mock; import org.mockito.runners.MockitoJUnitRunner; +import java.io.IOException; import java.net.URI; import java.util.concurrent.TimeUnit; @@ -48,6 +49,17 @@ @RunWith(MockitoJUnitRunner.class) public class HttpProjectConfigManagerTest { + static class MyResponse extends BasicHttpResponse implements CloseableHttpResponse { + + public MyResponse(ProtocolVersion protocolVersion, Integer status, String body) { + super(protocolVersion, status, body); + } + + @Override + public void close() throws IOException { + + } + } @Mock private OptimizelyHttpClient mockHttpClient; @@ -246,7 +258,7 @@ public void testInvalidBlockingTimeout() { @Ignore public void testGetDatafileHttpResponse2XX() throws Exception { String modifiedStamp = "Wed, 24 Apr 2019 07:07:07 GMT"; - HttpResponse getResponse = new BasicHttpResponse(new ProtocolVersion("TEST", 0, 0), 200, "TEST"); + CloseableHttpResponse getResponse = new MyResponse(new ProtocolVersion("TEST", 0, 0), 200, "TEST"); getResponse.setEntity(new StringEntity(datafileString)); getResponse.setHeader(HttpHeaders.LAST_MODIFIED, modifiedStamp); @@ -260,7 +272,7 @@ public void testGetDatafileHttpResponse2XX() throws Exception { @Test(expected = ClientProtocolException.class) public void testGetDatafileHttpResponse3XX() throws Exception { - HttpResponse getResponse = new BasicHttpResponse(new ProtocolVersion("TEST", 0, 0), 300, "TEST"); + CloseableHttpResponse getResponse = new MyResponse(new ProtocolVersion("TEST", 0, 0), 300, "TEST"); getResponse.setEntity(new StringEntity(datafileString)); projectConfigManager.getDatafileFromResponse(getResponse); @@ -268,7 +280,7 @@ public void testGetDatafileHttpResponse3XX() throws Exception { @Test public void testGetDatafileHttpResponse304() throws Exception { - HttpResponse getResponse = new BasicHttpResponse(new ProtocolVersion("TEST", 0, 0), 304, "TEST"); + CloseableHttpResponse getResponse = new MyResponse(new ProtocolVersion("TEST", 0, 0), 304, "TEST"); getResponse.setEntity(new StringEntity(datafileString)); String datafile = projectConfigManager.getDatafileFromResponse(getResponse); @@ -277,7 +289,7 @@ public void testGetDatafileHttpResponse304() throws Exception { @Test(expected = ClientProtocolException.class) public void testGetDatafileHttpResponse4XX() throws Exception { - HttpResponse getResponse = new BasicHttpResponse(new ProtocolVersion("TEST", 0, 0), 400, "TEST"); + CloseableHttpResponse getResponse = new MyResponse(new ProtocolVersion("TEST", 0, 0), 400, "TEST"); getResponse.setEntity(new StringEntity(datafileString)); projectConfigManager.getDatafileFromResponse(getResponse); @@ -285,7 +297,7 @@ public void testGetDatafileHttpResponse4XX() throws Exception { @Test(expected = ClientProtocolException.class) public void testGetDatafileHttpResponse5XX() throws Exception { - HttpResponse getResponse = new BasicHttpResponse(new ProtocolVersion("TEST", 0, 0), 500, "TEST"); + CloseableHttpResponse getResponse = new MyResponse(new ProtocolVersion("TEST", 0, 0), 500, "TEST"); getResponse.setEntity(new StringEntity(datafileString)); projectConfigManager.getDatafileFromResponse(getResponse); From 56917f8d0941c2502a844e066a93ded493dcd1e0 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Wed, 3 Feb 2021 16:53:41 -0800 Subject: [PATCH 041/147] chore: prepare for release 3.8.0 (#421) --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba9f9a7af..053d8b6cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Optimizely Java X SDK Changelog +## [3.8.0] +February 3rd, 2021 + +### New Features + +- Introducing a new primary interface for retrieving feature flag status, configuration and associated experiment decisions for users ([#406](https://github.com/optimizely/java-sdk/pull/406), [#415](https://github.com/optimizely/java-sdk/pull/415), [#417](https://github.com/optimizely/java-sdk/pull/417)). The new `OptimizelyUserContext` class is instantiated with `createUserContext` and exposes the following APIs to get `OptimizelyDecision`: + + - setAttribute + - decide + - decideAll + - decideForKeys + - trackEvent + +- For details, refer to our documentation page: [https://docs.developers.optimizely.com/full-stack/v4.0/docs/java-sdk](https://docs.developers.optimizely.com/full-stack/v4.0/docs/java-sdk). + +### Fixes +- Close the closable response from apache httpclient ([#419](https://github.com/optimizely/java-sdk/pull/419)) + + ## [3.8.0-beta2] January 14th, 2021 From b253f4783feb79ea72044b74af56bfa8e0311499 Mon Sep 17 00:00:00 2001 From: mnoman09 Date: Thu, 25 Feb 2021 22:06:54 +0500 Subject: [PATCH 042/147] getVariationForFeatureInRollout to return null decisionResponse when rolloutRulesLength is zero (#423) --- .../main/java/com/optimizely/ab/bucketing/DecisionService.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java index 8f7eeaca5..c6a267f5b 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java @@ -283,6 +283,9 @@ DecisionResponse getVariationForFeatureInRollout(@Nonnull Featu // for all rules before the everyone else rule int rolloutRulesLength = rollout.getExperiments().size(); + if (rolloutRulesLength == 0) { + return new DecisionResponse(new FeatureDecision(null, null, null), reasons); + } String bucketingId = getBucketingId(userId, filteredAttributes); Variation variation; From 352772404108c35c686c1cb7471fce267b98eba3 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Mon, 1 Mar 2021 14:23:14 -0800 Subject: [PATCH 043/147] chore: migrate upload repository to maven-central from jcenter (#424) --- build.gradle | 140 +++++++++++++++++++---------------- java-quickstart/build.gradle | 2 + 2 files changed, 79 insertions(+), 63 deletions(-) diff --git a/build.gradle b/build.gradle index 1585e6885..541d561d4 100644 --- a/build.gradle +++ b/build.gradle @@ -5,11 +5,9 @@ plugins { id 'nebula.optional-base' version '3.2.0' id 'com.github.hierynomus.license' version '0.15.0' id 'com.github.spotbugs' version "4.5.0" - id "com.jfrog.bintray" version "1.8.5" } allprojects { - group = 'com.optimizely.ab' apply plugin: 'idea' apply plugin: 'jacoco' @@ -22,26 +20,27 @@ allprojects { } } -apply from: 'gradle/publish.gradle' - allprojects { + group = 'com.optimizely.ab' + def travis_defined_version = System.getenv('TRAVIS_TAG') if (travis_defined_version != null) { version = travis_defined_version } } -subprojects { - apply plugin: 'com.jfrog.bintray' +def publishedProjects = subprojects.findAll { it.name != 'java-quickstart' } + +configure(publishedProjects) { apply plugin: 'com.github.spotbugs' apply plugin: 'jacoco' apply plugin: 'java' apply plugin: 'maven-publish' + apply plugin: 'signing' apply plugin: 'me.champeau.gradle.jmh' apply plugin: 'nebula.optional-base' apply plugin: 'com.github.hierynomus.license' - sourceCompatibility = 1.8 targetCompatibility = 1.8 @@ -62,11 +61,6 @@ subprojects { from javadoc.destinationDir } - artifacts { - archives sourcesJar - archives javadocJar - } - spotbugsMain { reports { xml.enabled = false @@ -115,34 +109,42 @@ subprojects { testCompile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: jacksonVersion } - publishing { - publications { - mavenJava(MavenPublication) { - from components.java - artifact sourcesJar - artifact javadocJar - pom.withXml { - asNode().children().last() + { - resolveStrategy = Closure.DELEGATE_FIRST - url 'https://github.com/optimizely/java-sdk' - licenses { - license { - name 'The Apache Software License, Version 2.0' - url 'http://www.apache.org/license/LICENSE-2.0.txt' - distribution 'repo' - } - } - developers { - developer { - id 'optimizely' - name 'Optimizely' - email 'developers@optimizely.com' - } - } + def docTitle = "Optimizely Java SDK" + if (name.equals('core-httpclient-impl')) { + docTitle = "Optimizely Java SDK: Httpclient" + } + + afterEvaluate { + publishing { + publications { + release(MavenPublication) { + customizePom(pom, docTitle) + + from components.java + artifact sourcesJar + artifact javadocJar + } + } + repositories { + maven { + url "https://oss.sonatype.org/service/local/staging/deploy/maven2" + credentials { + username System.getenv('MAVEN_CENTRAL_USERNAME') + password System.getenv('MAVEN_CENTRAL_PASSWORD') } } } } + + signing { + // skip signing for "local" version into MavenLocal for test-app + required { !version.equals("local") } + + def signingKey = System.getenv('MAVEN_SIGNING_KEY') + def signingPassword = System.getenv('MAVEN_SIGNING_PASSPHRASE') + useInMemoryPgpKeys(signingKey, signingPassword) + sign publishing.publications.release + } } license { @@ -153,42 +155,20 @@ subprojects { ext.year = Calendar.getInstance().get(Calendar.YEAR) } - def bintrayName = 'core-api'; - if (name.equals('core-httpclient-impl')) { - bintrayName = 'httpclient' - } - - bintray { - user = System.getenv('BINTRAY_USER') - key = System.getenv('BINTRAY_KEY') - pkg { - repo = 'optimizely' - name = "optimizely-sdk-${bintrayName}" - userOrg = 'optimizely' - version { - name = rootProject.version - } - publications = ['mavenJava'] - } - } - - build.dependsOn('generatePomFileForMavenJavaPublication') - - bintrayUpload.dependsOn 'build' - task ship() { - dependsOn('bintrayUpload') + dependsOn('publish') } + // concurrent publishing (maven-publish) causes an issue with maven-central repository + // - a single module splits into multiple staging repos, so validation fails. + // - adding this ordering requirement forces sequential publishing processes. + project(':core-api').javadocJar.mustRunAfter = [':core-httpclient-impl:ship'] } task ship() { dependsOn(':core-api:ship', ':core-httpclient-impl:ship') } -// Only report code coverage for projects that are distributed -def publishedProjects = subprojects.findAll { it.path != ':simulator' } - task jacocoMerge(type: JacocoMerge) { publishedProjects.each { subproject -> executionData subproject.tasks.withType(Test) @@ -225,3 +205,37 @@ tasks.coveralls { dependsOn jacocoRootReport onlyIf { System.env.'CI' && !JavaVersion.current().isJava9Compatible() } } + +// standard POM format required by MavenCentral + +def customizePom(pom, title) { + pom.withXml { + asNode().children().last() + { + // keep this - otherwise some properties are not made into pom properly + resolveStrategy = Closure.DELEGATE_FIRST + + name title + url 'https://github.com/optimizely/java-sdk' + description 'The Java SDK for Optimizely Full Stack (feature flag management for product development teams)' + licenses { + license { + name 'The Apache Software License, Version 2.0' + url 'http://www.apache.org/licenses/LICENSE-2.0.txt' + distribution 'repo' + } + } + developers { + developer { + id 'optimizely' + name 'Optimizely' + email 'optimizely-fullstack@optimizely.com' + } + } + scm { + connection 'scm:git:git://github.com/optimizely/java-sdk.git' + developerConnection 'scm:git:ssh:github.com/optimizely/java-sdk.git' + url 'https://github.com/optimizely/java-sdk.git' + } + } + } +} diff --git a/java-quickstart/build.gradle b/java-quickstart/build.gradle index 28f9bd7f4..db68cef27 100644 --- a/java-quickstart/build.gradle +++ b/java-quickstart/build.gradle @@ -1,3 +1,5 @@ +apply plugin: 'java' + dependencies { compile project(':core-api') compile project(':core-httpclient-impl') From 0eaf61bee14101d63a0fc479f77acd7924fa8d65 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Mon, 1 Mar 2021 17:02:02 -0800 Subject: [PATCH 044/147] fix: clean up all javadoc warnings (#426) --- .../java/com/optimizely/ab/Optimizely.java | 27 +++++++++++++ .../optimizely/ab/OptimizelyUserContext.java | 4 +- .../ab/config/DatafileProjectConfig.java | 5 ++- .../ab/config/ProjectConfigUtils.java | 13 +++++- .../config/audience/AudienceIdCondition.java | 4 +- .../config/audience/match/MatchRegistry.java | 6 +-- .../audience/match/SemanticVersion.java | 7 +++- .../ab/config/parser/ConfigParser.java | 10 +++-- .../ab/event/BatchEventProcessor.java | 24 ++++++++++- .../ab/internal/ConditionUtils.java | 6 ++- .../optimizely/ab/internal/EventTagUtils.java | 9 +++-- .../optimizely/ab/internal/PropertyUtils.java | 40 ++++++++++++++++++- .../optimizely/ab/internal/SafetyUtils.java | 4 +- .../ab/notification/ActivateNotification.java | 4 +- .../ab/notification/NotificationCenter.java | 8 ++-- .../ab/notification/TrackNotification.java | 4 +- .../ab/optimizelyjson/OptimizelyJSON.java | 6 ++- .../com/optimizely/ab/OptimizelyFactory.java | 31 ++++++++++++-- .../ab/config/HttpProjectConfigManager.java | 10 ++++- .../ab/event/AsyncEventHandler.java | 11 ++++- 20 files changed, 200 insertions(+), 33 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index fb62ded7f..8a7034d0e 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1354,6 +1354,9 @@ public NotificationCenter getNotificationCenter() { /** * Convenience method for adding DecisionNotification Handlers + * + * @param handler DicisionNotification handler + * @return A handler Id (greater than 0 if succeeded) */ public int addDecisionNotificationHandler(NotificationHandler handler) { return addNotificationHandler(DecisionNotification.class, handler); @@ -1361,6 +1364,9 @@ public int addDecisionNotificationHandler(NotificationHandler handler) { return addNotificationHandler(TrackNotification.class, handler); @@ -1368,6 +1374,9 @@ public int addTrackNotificationHandler(NotificationHandler ha /** * Convenience method for adding UpdateConfigNotification Handlers + * + * @param handler UpdateConfigNotification handler + * @return A handler Id (greater than 0 if succeeded) */ public int addUpdateConfigNotificationHandler(NotificationHandler handler) { return addNotificationHandler(UpdateConfigNotification.class, handler); @@ -1375,6 +1384,9 @@ public int addUpdateConfigNotificationHandler(NotificationHandler handler) { return addNotificationHandler(LogEvent.class, handler); @@ -1382,6 +1394,11 @@ public int addLogEventNotificationHandler(NotificationHandler handler) /** * Convenience method for adding NotificationHandlers + * + * @param clazz The class of NotificationHandler + * @param handler NotificationHandler handler + * @param This is the type parameter + * @return A handler Id (greater than 0 if succeeded) */ public int addNotificationHandler(Class clazz, NotificationHandler handler) { return notificationCenter.addNotificationHandler(clazz, handler); @@ -1403,6 +1420,10 @@ public int addNotificationHandler(Class clazz, NotificationHandler han * .withEventHandler(eventHandler) * .build(); * + * + * @param datafile A datafile + * @param eventHandler An EventHandler + * @return An Optimizely builder */ @Deprecated public static Builder builder(@Nonnull String datafile, @@ -1460,6 +1481,9 @@ public Builder withErrorHandler(ErrorHandler errorHandler) { * method. * {@link com.optimizely.ab.event.BatchEventProcessor.Builder#withEventHandler(com.optimizely.ab.event.EventHandler)} label} * Please use that builder method instead. + * + * @param eventHandler An EventHandler + * @return An Optimizely builder */ @Deprecated public Builder withEventHandler(EventHandler eventHandler) { @@ -1469,6 +1493,9 @@ public Builder withEventHandler(EventHandler eventHandler) { /** * You can instantiate a BatchEventProcessor or a ForwardingEventProcessor or supply your own. + * + * @param eventProcessor An EventProcessor + * @return An Optimizely builder */ public Builder withEventProcessor(EventProcessor eventProcessor) { this.eventProcessor = eventProcessor; diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java index 55f380753..f9cff6f44 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -155,7 +155,7 @@ public Map decideAll() { * * @param eventName The event name. * @param eventTags A map of event tag names to event tag values. - * @throws UnknownEventTypeException + * @throws UnknownEventTypeException when event type is unknown */ public void trackEvent(@Nonnull String eventName, @Nonnull Map eventTags) throws UnknownEventTypeException { @@ -166,7 +166,7 @@ public void trackEvent(@Nonnull String eventName, * Track an event. * * @param eventName The event name. - * @throws UnknownEventTypeException + * @throws UnknownEventTypeException when event type is unknown */ public void trackEvent(@Nonnull String eventName) throws UnknownEventTypeException { trackEvent(eventName, Collections.emptyMap()); diff --git a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java index 786d13a2c..2bc377a29 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2020, Optimizely and contributors + * Copyright 2016-2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -278,7 +278,7 @@ private List aggregateGroupExperiments(List groups) { /** * Checks is attributeKey is reserved or not and if it exist in attributeKeyMapping * - * @param attributeKey + * @param attributeKey The attribute key * @return AttributeId corresponding to AttributeKeyMapping, AttributeKey when it's a reserved attribute and * null when attributeKey is equal to BOT_FILTERING_ATTRIBUTE key. */ @@ -484,6 +484,7 @@ public Builder withDatafile(String datafile) { /** * @return a {@link DatafileProjectConfig} instance given a JSON string datafile + * @throws ConfigParseException when parsing datafile fails */ public ProjectConfig build() throws ConfigParseException { if (datafile == null) { diff --git a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfigUtils.java b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfigUtils.java index 1d1978096..060f82a06 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfigUtils.java +++ b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfigUtils.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, 2019, Optimizely and contributors + * Copyright 2016-2017,2019,2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,10 @@ public class ProjectConfigUtils { /** * Helper method for creating convenience mappings from key to entity + * + * @param nameables The list of IdMapped entities + * @param This is the type parameter + * @return The map of key to entity */ public static Map generateNameMapping(List nameables) { Map nameMapping = new HashMap(); @@ -38,6 +42,10 @@ public static Map generateNameMapping(List /** * Helper method for creating convenience mappings from ID to entity + * + * @param nameables The list of IdMapped entities + * @param This is the type parameter + * @return The map of ID to entity */ public static Map generateIdMapping(List nameables) { Map nameMapping = new HashMap(); @@ -50,6 +58,9 @@ public static Map generateIdMapping(List name /** * Helper method for creating convenience mappings of ExperimentID to featureFlags it is included in. + * + * @param featureFlags The list of feture flags + * @return The mapping of ExperimentID to featureFlags */ public static Map> generateExperimentFeatureMapping(List featureFlags) { Map> experimentFeatureMap = new HashMap<>(); diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java index 57a4e5bec..c4f052ebb 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018-2020, Optimizely and contributors + * Copyright 2018-2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -45,7 +45,7 @@ public class AudienceIdCondition implements Condition { /** * Constructor used in json parsing to store the audienceId parsed from Experiment.audienceConditions. * - * @param audienceId + * @param audienceId The audience id */ @JsonCreator public AudienceIdCondition(String audienceId) { diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchRegistry.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchRegistry.java index a468bc5e2..f78c35c8d 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchRegistry.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchRegistry.java @@ -1,6 +1,6 @@ /** * - * Copyright 2020, Optimizely and contributors + * Copyright 2020-2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -72,8 +72,8 @@ public static Match getMatch(String name) throws UnknownMatchTypeException { * register registers a Match implementation with it's name. * NOTE: This does not check for existence so default implementations can * be overridden. - * @param name - * @param match + * @param name The match name + * @param match The match implementation */ public static void register(String name, Match match) { registry.put(name, match); diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java index d963e7702..22fd56c4a 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java @@ -1,6 +1,6 @@ /** * - * Copyright 2020, Optimizely and contributors + * Copyright 2020-2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -44,6 +44,11 @@ public SemanticVersion(String version) { /** * compare takes object inputs and coerces them into SemanticVersion objects before performing the comparison. * If the input values cannot be coerced then an {@link UnexpectedValueTypeException} is thrown. + * + * @param o1 The object to be compared + * @param o2 The object to be compared to + * @return The compare result + * @throws UnexpectedValueTypeException when an error is detected while comparing */ public static int compare(Object o1, Object o2) throws UnexpectedValueTypeException { if (o1 instanceof String && o2 instanceof String) { diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/ConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/ConfigParser.java index c8fe74b08..966478cff 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/ConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/ConfigParser.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017,2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -33,14 +33,18 @@ public interface ConfigParser { /** - * @param json the json to parse - * @return generates a {@code ProjectConfig} configuration from the provided json + * @param json The json to parse + * @return The {@code ProjectConfig} configuration from the provided json * @throws ConfigParseException when there's an issue parsing the provided project config */ ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParseException; /** * OptimizelyJSON parsing + * + * @param src The OptimizelyJSON + * @return The serialized String + * @throws JsonParseException when parsing JSON fails */ String toJson(Object src) throws JsonParseException; T fromJson(String json, Class clazz) throws JsonParseException; diff --git a/core-api/src/main/java/com/optimizely/ab/event/BatchEventProcessor.java b/core-api/src/main/java/com/optimizely/ab/event/BatchEventProcessor.java index f10c134b3..daf81d71a 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/BatchEventProcessor.java +++ b/core-api/src/main/java/com/optimizely/ab/event/BatchEventProcessor.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019, Optimizely and contributors + * Copyright 2019,2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -246,6 +246,9 @@ public static class Builder { /** * {@link EventHandler} implementation used to dispatch events to Optimizely. + * + * @param eventHandler The event handler + * @return The BatchEventProcessor builder */ public Builder withEventHandler(EventHandler eventHandler) { this.eventHandler = eventHandler; @@ -254,6 +257,9 @@ public Builder withEventHandler(EventHandler eventHandler) { /** * EventQueue is the underlying BlockingQueue used to buffer events before being added to the batch payload. + * + * @param eventQueue The event queue + * @return The BatchEventProcessor builder */ public Builder withEventQueue(BlockingQueue eventQueue) { this.eventQueue = eventQueue; @@ -262,6 +268,9 @@ public Builder withEventQueue(BlockingQueue eventQueue) { /** * BatchSize is the maximum number of events contained within a single event batch. + * + * @param batchSize The batch size + * @return The BatchEventProcessor builder */ public Builder withBatchSize(Integer batchSize) { this.batchSize = batchSize; @@ -271,6 +280,9 @@ public Builder withBatchSize(Integer batchSize) { /** * FlushInterval is the maximum duration, in milliseconds, that an event will remain in flight before * being flushed to the event dispatcher. + * + * @param flushInterval The flush interval + * @return The BatchEventProcessor builder */ public Builder withFlushInterval(Long flushInterval) { this.flushInterval = flushInterval; @@ -279,6 +291,9 @@ public Builder withFlushInterval(Long flushInterval) { /** * ExecutorService used to execute the {@link EventConsumer} thread. + * + * @param executor The ExecutorService + * @return The BatchEventProcessor builder */ public Builder withExecutor(ExecutorService executor) { this.executor = executor; @@ -287,6 +302,10 @@ public Builder withExecutor(ExecutorService executor) { /** * Timeout is the maximum time to wait for the EventProcessor to close. + * + * @param duration The max time to wait for the EventProcessor to close + * @param timeUnit The time unit + * @return The BatchEventProcessor builder */ public Builder withTimeout(long duration, TimeUnit timeUnit) { this.timeoutMillis = timeUnit.toMillis(duration); @@ -295,6 +314,9 @@ public Builder withTimeout(long duration, TimeUnit timeUnit) { /** * NotificationCenter used to notify when event batches are flushed. + * + * @param notificationCenter The NotificationCenter + * @return The BatchEventProcessor builder */ public Builder withNotificationCenter(NotificationCenter notificationCenter) { this.notificationCenter = notificationCenter; diff --git a/core-api/src/main/java/com/optimizely/ab/internal/ConditionUtils.java b/core-api/src/main/java/com/optimizely/ab/internal/ConditionUtils.java index 5e6e36339..d9af5b58b 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/ConditionUtils.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/ConditionUtils.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018-2019, Optimizely and contributors + * Copyright 2018-2019,2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -122,7 +122,9 @@ static public Condition parseConditions(Class clazz, Object object) throw /** * parse conditions using List and Map * + * @param clazz the class of parsed condition * @param rawObjectList list of conditions + * @param This is the type parameter * @return audienceCondition */ static public Condition parseConditions(Class clazz, List rawObjectList) throws InvalidAudienceCondition { @@ -183,7 +185,9 @@ static public String operand(Object object) { /** * Parse conditions from org.json.JsonArray * + * @param clazz the class of parsed condition * @param conditionJson jsonArray to parse + * @param This is the type parameter * @return condition parsed from conditionJson. */ static public Condition parseConditions(Class clazz, org.json.JSONArray conditionJson) throws InvalidAudienceCondition { diff --git a/core-api/src/main/java/com/optimizely/ab/internal/EventTagUtils.java b/core-api/src/main/java/com/optimizely/ab/internal/EventTagUtils.java index 54d76fe53..76b6b9ae3 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/EventTagUtils.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/EventTagUtils.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2019, Optimizely and contributors + * Copyright 2016-2019,2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,8 +29,8 @@ public final class EventTagUtils { /** * Grab the revenue value from the event tags. "revenue" is a reserved keyword. * - * @param eventTags - * @return Long + * @param eventTags The event tags + * @return Long The revenue value */ public static Long getRevenueValue(@Nonnull Map eventTags) { Long eventValue = null; @@ -51,6 +51,9 @@ public static Long getRevenueValue(@Nonnull Map eventTags) { /** * Fetch the numeric metric value from event tags. "value" is a reserved keyword. + * + * @param eventTags The event tags + * @return The numeric metric value */ public static Double getNumericValue(@Nonnull Map eventTags) { Double eventValue = null; diff --git a/core-api/src/main/java/com/optimizely/ab/internal/PropertyUtils.java b/core-api/src/main/java/com/optimizely/ab/internal/PropertyUtils.java index 5fc66982a..4ef03b2cc 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/PropertyUtils.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/PropertyUtils.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019, Optimizely + * Copyright 2019,2021, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -65,6 +65,7 @@ public final class PropertyUtils { /** * Clears a System property prepended with "optimizely.". + * @param key The configuration key */ public static void clear(String key) { System.clearProperty("optimizely." + key); @@ -72,6 +73,9 @@ public static void clear(String key) { /** * Sets a System property prepended with "optimizely.". + * + * @param key The configuration key + * @param value The String value */ public static void set(String key, String value) { System.setProperty("optimizely." + key, value); @@ -84,6 +88,9 @@ public static void set(String key, String value) { *
    • Environment variables - Key is prepended with "optimizely.", uppercased and "." are replaced with "_".
    • *
    • Optimizely Properties - Key is sourced as-is.
    • * + * + * @param key The configuration key + * @return The String value */ public static String get(String key) { return get(key, null); @@ -97,6 +104,10 @@ public static String get(String key) { *
    • Environment variables - Key is prepended with "optimizely.", upper cased and "."s are replaced with "_"s.
    • *
    • Optimizely Properties - Key is sourced as-is.
    • * + * + * @param key The configuration key + * @param dafault The default value + * @return The String value */ public static String get(String key, String dafault) { // Try to obtain from a Java System Property @@ -131,6 +142,9 @@ public static String get(String key, String dafault) { *
    • Environment variables - Key is prepended with "optimizely.", upper cased and "."s are replaced with "_"s.
    • *
    • Optimizely Properties - Key is sourced as-is.
    • * + * + * @param key The configuration key + * @return The integer value */ public static Long getLong(String key) { return getLong(key, null); @@ -144,6 +158,10 @@ public static Long getLong(String key) { *
    • Environment variables - Key is prepended with "optimizely.", upper cased and "."s are replaced with "_"s.
    • *
    • Optimizely Properties - Key is sourced as-is.
    • * + * + * @param key The configuration key + * @param dafault The default value + * @return The long value */ public static Long getLong(String key, Long dafault) { String value = get(key); @@ -168,7 +186,11 @@ public static Long getLong(String key, Long dafault) { *
    • Environment variables - Key is prepended with "optimizely.", upper cased and "."s are replaced with "_"s.
    • *
    • Optimizely Properties - Key is sourced as-is.
    • * + * + * @param key The configuration key + * @return The integer value */ + public static Integer getInteger(String key) { return getInteger(key, null); } @@ -181,7 +203,12 @@ public static Integer getInteger(String key) { *
    • Environment variables - Key is prepended with "optimizely.", upper cased and "."s are replaced with "_"s.
    • *
    • Optimizely Properties - Key is sourced as-is.
    • * + * + * @param key The configuration key + * @param dafault The default value + * @return The integer value */ + public static Integer getInteger(String key, Integer dafault) { String value = get(key); if (value == null) { @@ -204,6 +231,11 @@ public static Integer getInteger(String key, Integer dafault) { *
    • Environment variables - Key is prepended with "optimizely.", upper cased and "."s are replaced with "_"s.
    • *
    • Optimizely Properties - Key is sourced as-is.
    • * + * + * @param key The configuration key + * @param clazz The value class + * @param This is the type parameter + * @return The value */ public static T getEnum(String key, Class clazz) { return getEnum(key, clazz, null); @@ -217,6 +249,12 @@ public static T getEnum(String key, Class clazz) { *
    • Environment variables - Key is prepended with "optimizely.", upper cased and "."s are replaced with "_"s.
    • *
    • Optimizely Properties - Key is sourced as-is.
    • * + * + * @param key The configuration key + * @param clazz The value class + * @param dafault The default value + * @param This is the type parameter + * @return The value */ @SuppressWarnings("unchecked") public static T getEnum(String key, Class clazz, T dafault) { diff --git a/core-api/src/main/java/com/optimizely/ab/internal/SafetyUtils.java b/core-api/src/main/java/com/optimizely/ab/internal/SafetyUtils.java index 800fbde12..eaed2e9d5 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/SafetyUtils.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/SafetyUtils.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019, Optimizely + * Copyright 2019,2021, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,8 @@ public class SafetyUtils { /** * Helper method which checks if Object is an instance of AutoCloseable and calls close() on it. + * + * @param obj The object */ public static void tryClose(Object obj) { if (!(obj instanceof AutoCloseable)) { diff --git a/core-api/src/main/java/com/optimizely/ab/notification/ActivateNotification.java b/core-api/src/main/java/com/optimizely/ab/notification/ActivateNotification.java index 8bb273425..dc70079de 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/ActivateNotification.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/ActivateNotification.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019, Optimizely and contributors + * Copyright 2019,2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -78,6 +78,8 @@ public Variation getVariation() { * This interface is deprecated since this is no longer a one-to-one mapping. * Please use a {@link NotificationHandler} explicitly for LogEvent messages. * {@link com.optimizely.ab.Optimizely#addLogEventNotificationHandler(NotificationHandler)} + * + * @return The event */ @Deprecated public LogEvent getEvent() { diff --git a/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java b/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java index 499f8ec43..df8a7afbb 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java @@ -1,6 +1,6 @@ /** * - * Copyright 2017-2020, Optimizely and contributors + * Copyright 2017-2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -122,7 +122,7 @@ public int addNotificationHandler(Class clazz, NotificationHandler han /** * Convenience method to support lambdas as callbacks in later version of Java (8+). * - * @param activateNotificationListener + * @param activateNotificationListener The ActivateNotificationListener * @return greater than zero if added. * * @deprecated by {@link NotificationManager#addHandler(NotificationHandler)} @@ -151,7 +151,7 @@ public int addActivateNotificationListener(final ActivateNotificationListenerInt /** * Convenience method to support lambdas as callbacks in later versions of Java (8+) * - * @param trackNotificationListener + * @param trackNotificationListener The TrackNotificationListener * @return greater than zero if added. * * @deprecated by {@link NotificationManager#addHandler(NotificationHandler)} @@ -255,6 +255,8 @@ public void clearNotificationListeners(NotificationType notificationType) { /** * Clear notification listeners by notification class. + * + * @param clazz The NotificationLister class */ public void clearNotificationListeners(Class clazz) { NotificationManager notificationManager = getNotificationManager(clazz); diff --git a/core-api/src/main/java/com/optimizely/ab/notification/TrackNotification.java b/core-api/src/main/java/com/optimizely/ab/notification/TrackNotification.java index 540dfa277..4651d5bbb 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/TrackNotification.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/TrackNotification.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019, Optimizely and contributors + * Copyright 2019,2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -72,6 +72,8 @@ public String getUserId() { * This interface is deprecated since this is no longer a one-to-one mapping. * Please use a {@link NotificationHandler} explicitly for LogEvent messages. * {@link com.optimizely.ab.Optimizely#addLogEventNotificationHandler(NotificationHandler)} + * + * @return The event */ @Deprecated public LogEvent getEvent() { diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyjson/OptimizelyJSON.java b/core-api/src/main/java/com/optimizely/ab/optimizelyjson/OptimizelyJSON.java index 97bff838c..4cb835958 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelyjson/OptimizelyJSON.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyjson/OptimizelyJSON.java @@ -1,6 +1,6 @@ /** * - * Copyright 2020, Optimizely and contributors + * Copyright 2020-2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -73,6 +73,8 @@ public String toString() { /** * Returns the {@code Map} representation of json data + * + * @return The {@code Map} representation of json data */ @Nullable public Map toMap() { @@ -100,7 +102,9 @@ public Map toMap() { * * @param jsonKey The JSON key paths for the data to access * @param clazz The user-defined class that the json data will be parsed to + * @param This is the type parameter * @return an instance of clazz type with the parsed data filled in (or null if parse fails) + * @throws JsonParseException when a JSON parser is not available. */ @Nullable public T getValue(@Nullable String jsonKey, Class clazz) throws JsonParseException { diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java index fa4a83dc4..2ba52d6e4 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019-2020, Optimizely + * Copyright 2019-2021, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -52,6 +52,8 @@ public final class OptimizelyFactory { /** * Convenience method for setting the maximum number of events contained within a batch. * {@link AsyncEventHandler} + * + * @param batchSize The max number of events for batching */ public static void setMaxEventBatchSize(int batchSize) { if (batchSize <= 0) { @@ -65,6 +67,8 @@ public static void setMaxEventBatchSize(int batchSize) { /** * Convenience method for setting the maximum time interval in milliseconds between event dispatches. * {@link AsyncEventHandler} + * + * @param batchInterval The max time interval for event batching */ public static void setMaxEventBatchInterval(long batchInterval) { if (batchInterval <= 0) { @@ -78,6 +82,9 @@ public static void setMaxEventBatchInterval(long batchInterval) { /** * Convenience method for setting the required queueing parameters for event dispatching. * {@link AsyncEventHandler} + * + * @param queueCapacity A depth of the event queue + * @param numberWorkers The number of workers */ public static void setEventQueueParams(int queueCapacity, int numberWorkers) { if (queueCapacity <= 0) { @@ -97,6 +104,9 @@ public static void setEventQueueParams(int queueCapacity, int numberWorkers) { /** * Convenience method for setting the blocking timeout. * {@link HttpProjectConfigManager.Builder#withBlockingTimeout(Long, TimeUnit)} + * + * @param blockingDuration The blocking time duration + * @param blockingTimeout The blocking time unit */ public static void setBlockingTimeout(long blockingDuration, TimeUnit blockingTimeout) { if (blockingTimeout == null) { @@ -116,6 +126,9 @@ public static void setBlockingTimeout(long blockingDuration, TimeUnit blockingTi /** * Convenience method for setting the polling interval on System properties. * {@link HttpProjectConfigManager.Builder#withPollingInterval(Long, TimeUnit)} + * + * @param pollingDuration The polling interval + * @param pollingTimeout The polling time unit */ public static void setPollingInterval(long pollingDuration, TimeUnit pollingTimeout) { if (pollingTimeout == null) { @@ -135,6 +148,8 @@ public static void setPollingInterval(long pollingDuration, TimeUnit pollingTime /** * Convenience method for setting the sdk key on System properties. * {@link HttpProjectConfigManager.Builder#withSdkKey(String)} + * + * @param sdkKey The sdk key */ public static void setSdkKey(String sdkKey) { if (sdkKey == null) { @@ -148,6 +163,8 @@ public static void setSdkKey(String sdkKey) { /** * Convenience method for setting the Datafile Access Token on System properties. * {@link HttpProjectConfigManager.Builder#withDatafileAccessToken(String)} + * + * @param datafileAccessToken The datafile access token */ public static void setDatafileAccessToken(String datafileAccessToken) { if (datafileAccessToken == null) { @@ -160,8 +177,8 @@ public static void setDatafileAccessToken(String datafileAccessToken) { /** * Returns a new Optimizely instance based on preset configuration. - * EventHandler - {@link AsyncEventHandler} - * ProjectConfigManager - {@link HttpProjectConfigManager} + * + * @return A new Optimizely instance */ public static Optimizely newDefaultInstance() { String sdkKey = PropertyUtils.get(HttpProjectConfigManager.CONFIG_SDK_KEY); @@ -174,6 +191,7 @@ public static Optimizely newDefaultInstance() { * ProjectConfigManager - {@link HttpProjectConfigManager} * * @param sdkKey SDK key used to build the ProjectConfigManager. + * @return A new Optimizely instance */ public static Optimizely newDefaultInstance(String sdkKey) { if (sdkKey == null) { @@ -191,6 +209,7 @@ public static Optimizely newDefaultInstance(String sdkKey) { * * @param sdkKey SDK key used to build the ProjectConfigManager. * @param fallback Fallback datafile string used by the ProjectConfigManager to be immediately available. + * @return A new Optimizely instance */ public static Optimizely newDefaultInstance(String sdkKey, String fallback) { String datafileAccessToken = PropertyUtils.get(HttpProjectConfigManager.CONFIG_DATAFILE_AUTH_TOKEN); @@ -203,6 +222,7 @@ public static Optimizely newDefaultInstance(String sdkKey, String fallback) { * @param sdkKey SDK key used to build the ProjectConfigManager. * @param fallback Fallback datafile string used by the ProjectConfigManager to be immediately available. * @param datafileAccessToken Token for authenticated datafile access. + * @return A new Optimizely instance */ public static Optimizely newDefaultInstance(String sdkKey, String fallback, String datafileAccessToken) { NotificationCenter notificationCenter = new NotificationCenter(); @@ -224,6 +244,7 @@ public static Optimizely newDefaultInstance(String sdkKey, String fallback, Stri * EventHandler - {@link AsyncEventHandler} * * @param configManager The {@link ProjectConfigManager} supplied to Optimizely instance. + * @return A new Optimizely instance */ public static Optimizely newDefaultInstance(ProjectConfigManager configManager) { return newDefaultInstance(configManager, null); @@ -235,6 +256,7 @@ public static Optimizely newDefaultInstance(ProjectConfigManager configManager) * * @param configManager The {@link ProjectConfigManager} supplied to Optimizely instance. * @param notificationCenter The {@link NotificationCenter} supplied to Optimizely instance. + * @return A new Optimizely instance */ public static Optimizely newDefaultInstance(ProjectConfigManager configManager, NotificationCenter notificationCenter) { EventHandler eventHandler = AsyncEventHandler.builder().build(); @@ -247,7 +269,8 @@ public static Optimizely newDefaultInstance(ProjectConfigManager configManager, * @param configManager The {@link ProjectConfigManager} supplied to Optimizely instance. * @param notificationCenter The {@link ProjectConfigManager} supplied to Optimizely instance. * @param eventHandler The {@link EventHandler} supplied to Optimizely instance. - */ + * @return A new Optimizely instance + * */ public static Optimizely newDefaultInstance(ProjectConfigManager configManager, NotificationCenter notificationCenter, EventHandler eventHandler) { if (notificationCenter == null) { notificationCenter = new NotificationCenter(); diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java index eb43e67a5..c771da9fa 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019, Optimizely + * Copyright 2019,2021, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,6 +22,7 @@ import com.optimizely.ab.config.parser.ConfigParseException; import com.optimizely.ab.internal.PropertyUtils; import com.optimizely.ab.notification.NotificationCenter; +import com.optimizely.ab.optimizelyconfig.OptimizelyConfig; import org.apache.http.*; import org.apache.http.client.ClientProtocolException; import org.apache.http.client.methods.CloseableHttpResponse; @@ -212,6 +213,10 @@ public Builder withOptimizelyHttpClient(OptimizelyHttpClient httpClient) { * to {@link PollingProjectConfigManager#getConfig()}. If the timeout is exceeded then the * PollingProjectConfigManager will begin returning null immediately until the call to Poll * succeeds. + * + * @param period A timeout period + * @param timeUnit A timeout unit + * @return A HttpProjectConfigManager builder */ public Builder withBlockingTimeout(Long period, TimeUnit timeUnit) { if (timeUnit == null) { @@ -265,6 +270,8 @@ public Builder withNotificationCenter(NotificationCenter notificationCenter) { /** * HttpProjectConfigManager.Builder that builds and starts a HttpProjectConfigManager. * This is the default builder which will block until a config is available. + * + * @return {@link HttpProjectConfigManager} */ public HttpProjectConfigManager build() { return build(false); @@ -275,6 +282,7 @@ public HttpProjectConfigManager build() { * * @param defer When true, we will not wait for the configuration to be available * before returning the HttpProjectConfigManager instance. + * @return {@link HttpProjectConfigManager} */ public HttpProjectConfigManager build(boolean defer) { if (period <= 0) { diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java index 5108187ef..3d32f3971 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2019, Optimizely and contributors + * Copyright 2016-2019,2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -73,6 +73,9 @@ public class AsyncEventHandler implements EventHandler, AutoCloseable { /** * @deprecated Use the builder {@link Builder} + * + * @param queueCapacity A depth of the event queue + * @param numWorkers The number of workers */ @Deprecated public AsyncEventHandler(int queueCapacity, @@ -82,6 +85,12 @@ public AsyncEventHandler(int queueCapacity, /** * @deprecated Use the builder {@link Builder} + * + * @param queueCapacity A depth of the event queue + * @param numWorkers The number of workers + * @param maxConnections The max number of concurrent connections + * @param connectionsPerRoute The max number of concurrent connections per route + * @param validateAfter An inactivity period in milliseconds after which persistent connections must be re-validated prior to being leased to the consumer. */ @Deprecated public AsyncEventHandler(int queueCapacity, From 1ead29369607abee53b06ad899c4c24945126598 Mon Sep 17 00:00:00 2001 From: mnoman09 Date: Tue, 2 Mar 2021 06:46:45 +0500 Subject: [PATCH 045/147] Fix: Added JsonInclude as always when serializing on campaign and experiment id. (#422) --- .../com/optimizely/ab/event/internal/payload/Decision.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Decision.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Decision.java index a9e571dd1..e472d236e 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Decision.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Decision.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018-2020, Optimizely and contributors + * Copyright 2018-2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,12 +16,15 @@ */ package com.optimizely.ab.event.internal.payload; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.optimizely.ab.annotations.VisibleForTesting; public class Decision { + @JsonInclude(JsonInclude.Include.ALWAYS) @JsonProperty("campaign_id") String campaignId; + @JsonInclude(JsonInclude.Include.ALWAYS) @JsonProperty("experiment_id") String experimentId; @JsonProperty("variation_id") From d286adaf3df2c87410e00e8b95841baa12d74588 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Tue, 2 Mar 2021 13:48:57 -0800 Subject: [PATCH 046/147] chore: change maven-central signing key to base64 (#427) --- build.gradle | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 541d561d4..31608bcbf 100644 --- a/build.gradle +++ b/build.gradle @@ -137,10 +137,13 @@ configure(publishedProjects) { } signing { + // base64 for workaround travis escape chars issue + def signingKeyBase64 = System.getenv('MAVEN_SIGNING_KEY_BASE64') // skip signing for "local" version into MavenLocal for test-app - required { !version.equals("local") } + if (!signingKeyBase64?.trim()) return + byte[] decoded = signingKeyBase64.decodeBase64() + def signingKey = new String(decoded) - def signingKey = System.getenv('MAVEN_SIGNING_KEY') def signingPassword = System.getenv('MAVEN_SIGNING_PASSPHRASE') useInMemoryPgpKeys(signingKey, signingPassword) sign publishing.publications.release From 3df34592b2966ee9995d2e9e59b7f96b452bc1fc Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Tue, 2 Mar 2021 14:27:28 -0800 Subject: [PATCH 047/147] prepare for 3.8.1 (#429) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 053d8b6cc..b88a474be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Optimizely Java X SDK Changelog +## [3.8.1] +March 2nd, 2021 + +- Switch publish repository to MavenCentral (bintray/jcenter sunset) +- Fix javadoc warnings ([#426](https://github.com/optimizely/java-sdk/pull/426)) + ## [3.8.0] February 3rd, 2021 From a09e13c310469d718d4a81f028c1a09a73f8e72b Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Wed, 3 Mar 2021 14:07:00 -0800 Subject: [PATCH 048/147] fix redundant publishing from travis (#430) --- .travis.yml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.travis.yml b/.travis.yml index f53c61158..93c025e23 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,11 +13,8 @@ env: script: - "./gradlew clean" - "./gradlew exhaustiveTest" - - "if [[ -n $TRAVIS_TAG ]]; then - ./gradlew ship; - else - ./gradlew build; - fi" + - "./gradlew build" + cache: gradle: true directories: @@ -40,6 +37,7 @@ stages: - 'Integration tests' - 'Full stack production tests' - 'Test' + - 'Publish' jobs: include: @@ -75,10 +73,13 @@ jobs: - stage: 'Source Clear' if: type = cron - addons: - srcclr: true - before_install: skip install: skip before_script: skip script: skip after_success: skip + + - stage: 'Publish' + if: tag IS present + script: + - ./gradlew ship + after_success: skip From d774b3d3583dd97c192e10607ff0725cdb04d8f3 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Fri, 5 Mar 2021 15:17:24 -0800 Subject: [PATCH 049/147] fix: add configurable evictIdleConnections (#431) --- .../com/optimizely/ab/OptimizelyFactory.java | 22 +++++++++++++ .../optimizely/ab/OptimizelyHttpClient.java | 22 +++++++++++-- .../ab/config/HttpProjectConfigManager.java | 32 ++++++++++++++++++- .../optimizely/ab/OptimizelyFactoryTest.java | 23 +++++++++++++ .../ab/OptimizelyHttpClientTest.java | 24 ++++++++++++-- .../config/HttpProjectConfigManagerTest.java | 15 +++++++++ 6 files changed, 131 insertions(+), 7 deletions(-) diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java index 2ba52d6e4..5d267d41e 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java @@ -123,6 +123,28 @@ public static void setBlockingTimeout(long blockingDuration, TimeUnit blockingTi PropertyUtils.set(HttpProjectConfigManager.CONFIG_BLOCKING_UNIT, blockingTimeout.toString()); } + /** + * Convenience method for setting the evict idle connections. + * {@link HttpProjectConfigManager.Builder#withEvictIdleConnections(long, TimeUnit)} + * + * @param maxIdleTime The connection idle time duration (0 to disable eviction) + * @param maxIdleTimeUnit The connection idle time unit + */ + public static void setEvictIdleConnections(long maxIdleTime, TimeUnit maxIdleTimeUnit) { + if (maxIdleTimeUnit == null) { + logger.warn("TimeUnit cannot be null. Reverting to default configuration."); + return; + } + + if (maxIdleTime < 0) { + logger.warn("Timeout cannot be < 0. Reverting to default configuration."); + return; + } + + PropertyUtils.set(HttpProjectConfigManager.CONFIG_EVICT_DURATION, Long.toString(maxIdleTime)); + PropertyUtils.set(HttpProjectConfigManager.CONFIG_EVICT_UNIT, maxIdleTimeUnit.toString()); + } + /** * Convenience method for setting the polling interval on System properties. * {@link HttpProjectConfigManager.Builder#withPollingInterval(Long, TimeUnit)} diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyHttpClient.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyHttpClient.java index 86801396a..37c2163ac 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyHttpClient.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyHttpClient.java @@ -22,11 +22,13 @@ import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.impl.client.HttpClients; import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; import java.io.Closeable; import java.io.IOException; +import java.util.concurrent.TimeUnit; /** * Basic HttpClient wrapper to be utilized for fetching the datafile @@ -73,6 +75,9 @@ public static class Builder { private int maxPerRoute = 20; // Defines period of inactivity in milliseconds after which persistent connections must be re-validated prior to being leased to the consumer. private int validateAfterInactivity = 5000; + // force-close the connection after this idle time (with 0, eviction is disabled by default) + long evictConnectionIdleTimePeriod = 0; + TimeUnit evictConnectionIdleTimeUnit = TimeUnit.MILLISECONDS; private Builder() { @@ -93,18 +98,29 @@ public Builder withValidateAfterInactivity(int validateAfterInactivity) { return this; } + public Builder withEvictIdleConnections(long maxIdleTime, TimeUnit maxIdleTimeUnit) { + this.evictConnectionIdleTimePeriod = maxIdleTime; + this.evictConnectionIdleTimeUnit = maxIdleTimeUnit; + return this; + } + public OptimizelyHttpClient build() { PoolingHttpClientConnectionManager poolingHttpClientConnectionManager = new PoolingHttpClientConnectionManager(); poolingHttpClientConnectionManager.setMaxTotal(maxTotalConnections); poolingHttpClientConnectionManager.setDefaultMaxPerRoute(maxPerRoute); poolingHttpClientConnectionManager.setValidateAfterInactivity(validateAfterInactivity); - CloseableHttpClient closableHttpClient = HttpClients.custom() + HttpClientBuilder builder = HttpClients.custom() .setDefaultRequestConfig(HttpClientUtils.DEFAULT_REQUEST_CONFIG) .setConnectionManager(poolingHttpClientConnectionManager) .disableCookieManagement() - .useSystemProperties() - .build(); + .useSystemProperties(); + + if (evictConnectionIdleTimePeriod > 0) { + builder.evictIdleConnections(evictConnectionIdleTimePeriod, evictConnectionIdleTimeUnit); + } + + CloseableHttpClient closableHttpClient = builder.build(); return new OptimizelyHttpClient(closableHttpClient); } diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java index c771da9fa..cef13fdcd 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java @@ -27,6 +27,7 @@ import org.apache.http.client.ClientProtocolException; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.util.EntityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -46,6 +47,8 @@ public class HttpProjectConfigManager extends PollingProjectConfigManager { public static final String CONFIG_POLLING_UNIT = "http.project.config.manager.polling.unit"; public static final String CONFIG_BLOCKING_DURATION = "http.project.config.manager.blocking.duration"; public static final String CONFIG_BLOCKING_UNIT = "http.project.config.manager.blocking.unit"; + public static final String CONFIG_EVICT_DURATION = "http.project.config.manager.evict.duration"; + public static final String CONFIG_EVICT_UNIT = "http.project.config.manager.evict.unit"; public static final String CONFIG_SDK_KEY = "http.project.config.manager.sdk.key"; public static final String CONFIG_DATAFILE_AUTH_TOKEN = "http.project.config.manager.datafile.auth.token"; @@ -53,6 +56,8 @@ public class HttpProjectConfigManager extends PollingProjectConfigManager { public static final TimeUnit DEFAULT_POLLING_UNIT = TimeUnit.MINUTES; public static final long DEFAULT_BLOCKING_DURATION = 10; public static final TimeUnit DEFAULT_BLOCKING_UNIT = TimeUnit.SECONDS; + public static final long DEFAULT_EVICT_DURATION = 1; + public static final TimeUnit DEFAULT_EVICT_UNIT = TimeUnit.MINUTES; private static final Logger logger = LoggerFactory.getLogger(HttpProjectConfigManager.class); @@ -178,6 +183,10 @@ public static class Builder { long blockingTimeoutPeriod = PropertyUtils.getLong(CONFIG_BLOCKING_DURATION, DEFAULT_BLOCKING_DURATION); TimeUnit blockingTimeoutUnit = PropertyUtils.getEnum(CONFIG_BLOCKING_UNIT, TimeUnit.class, DEFAULT_BLOCKING_UNIT); + // force-close the persistent connection after this idle time + long evictConnectionIdleTimePeriod = PropertyUtils.getLong(CONFIG_EVICT_DURATION, DEFAULT_EVICT_DURATION); + TimeUnit evictConnectionIdleTimeUnit = PropertyUtils.getEnum(CONFIG_EVICT_UNIT, TimeUnit.class, DEFAULT_EVICT_UNIT); + public Builder withDatafile(String datafile) { this.datafile = datafile; return this; @@ -208,6 +217,25 @@ public Builder withOptimizelyHttpClient(OptimizelyHttpClient httpClient) { return this; } + /** + * Makes HttpClient proactively evict idle connections from theœ + * connection pool using a background thread. + * + * @see org.apache.http.impl.client.HttpClientBuilder#evictIdleConnections(long, TimeUnit) + * + * @param maxIdleTime maximum time persistent connections can stay idle while kept alive + * in the connection pool. Connections whose inactivity period exceeds this value will + * get closed and evicted from the pool. Set to 0 to disable eviction. + * @param maxIdleTimeUnit time unit for the above parameter. + * + * @return A HttpProjectConfigManager builder + */ + public Builder withEvictIdleConnections(long maxIdleTime, TimeUnit maxIdleTimeUnit) { + this.evictConnectionIdleTimePeriod = maxIdleTime; + this.evictConnectionIdleTimeUnit = maxIdleTimeUnit; + return this; + } + /** * Configure time to block before Completing the future. This timeout is used on the first call * to {@link PollingProjectConfigManager#getConfig()}. If the timeout is exceeded then the @@ -300,7 +328,9 @@ public HttpProjectConfigManager build(boolean defer) { } if (httpClient == null) { - httpClient = HttpClientUtils.getDefaultHttpClient(); + httpClient = OptimizelyHttpClient.builder() + .withEvictIdleConnections(evictConnectionIdleTimePeriod, evictConnectionIdleTimeUnit) + .build(); } if (url == null) { diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java index c860ee98b..b6d006173 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java @@ -45,6 +45,8 @@ public void setUp() { PropertyUtils.clear(HttpProjectConfigManager.CONFIG_POLLING_UNIT); PropertyUtils.clear(HttpProjectConfigManager.CONFIG_BLOCKING_DURATION); PropertyUtils.clear(HttpProjectConfigManager.CONFIG_BLOCKING_UNIT); + PropertyUtils.clear(HttpProjectConfigManager.CONFIG_EVICT_DURATION); + PropertyUtils.clear(HttpProjectConfigManager.CONFIG_EVICT_UNIT); PropertyUtils.clear(HttpProjectConfigManager.CONFIG_SDK_KEY); } @@ -152,6 +154,27 @@ public void setInvalidBlockingTimeout() { assertNull(PropertyUtils.getEnum(HttpProjectConfigManager.CONFIG_POLLING_UNIT, TimeUnit.class)); } + @Test + public void setEvictIdleConnections() { + Long duration = 2000L; + TimeUnit timeUnit = TimeUnit.SECONDS; + OptimizelyFactory.setEvictIdleConnections(duration, timeUnit); + + assertEquals(duration, PropertyUtils.getLong(HttpProjectConfigManager.CONFIG_EVICT_DURATION)); + assertEquals(timeUnit, PropertyUtils.getEnum(HttpProjectConfigManager.CONFIG_EVICT_UNIT, TimeUnit.class)); + } + + @Test + public void setInvalidEvictIdleConnections() { + OptimizelyFactory.setEvictIdleConnections(-1, TimeUnit.MICROSECONDS); + assertNull(PropertyUtils.getLong(HttpProjectConfigManager.CONFIG_EVICT_DURATION)); + assertNull(PropertyUtils.getEnum(HttpProjectConfigManager.CONFIG_EVICT_UNIT, TimeUnit.class)); + + OptimizelyFactory.setEvictIdleConnections(10, null); + assertNull(PropertyUtils.getLong(HttpProjectConfigManager.CONFIG_EVICT_DURATION)); + assertNull(PropertyUtils.getEnum(HttpProjectConfigManager.CONFIG_EVICT_UNIT, TimeUnit.class)); + } + @Test public void setSdkKey() { String expected = "sdk-key"; diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyHttpClientTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyHttpClientTest.java index 8b92e6fc1..7dc61f0f9 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyHttpClientTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyHttpClientTest.java @@ -27,7 +27,10 @@ import org.junit.Test; import java.io.IOException; +import java.util.concurrent.TimeUnit; +import static com.optimizely.ab.OptimizelyHttpClient.builder; +import static java.util.concurrent.TimeUnit.*; import static org.junit.Assert.*; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -46,24 +49,39 @@ public void tearDown() { @Test public void testDefaultConfiguration() { - OptimizelyHttpClient optimizelyHttpClient = OptimizelyHttpClient.builder().build(); + OptimizelyHttpClient optimizelyHttpClient = builder().build(); assertTrue(optimizelyHttpClient.getHttpClient() instanceof CloseableHttpClient); } @Test public void testNonDefaultConfiguration() { - OptimizelyHttpClient optimizelyHttpClient = OptimizelyHttpClient.builder() + OptimizelyHttpClient optimizelyHttpClient = builder() .withValidateAfterInactivity(1) .withMaxPerRoute(2) .withMaxTotalConnections(3) + .withEvictIdleConnections(5, MINUTES) .build(); assertTrue(optimizelyHttpClient.getHttpClient() instanceof CloseableHttpClient); } + @Test + public void testEvictTime() { + OptimizelyHttpClient.Builder builder = builder(); + long expectedPeriod = builder.evictConnectionIdleTimePeriod; + TimeUnit expectedTimeUnit = builder.evictConnectionIdleTimeUnit; + + assertEquals(expectedPeriod, 0L); + assertEquals(expectedTimeUnit, MILLISECONDS); + + builder.withEvictIdleConnections(10L, SECONDS); + assertEquals(10, builder.evictConnectionIdleTimePeriod); + assertEquals(SECONDS, builder.evictConnectionIdleTimeUnit); + } + @Test(expected = HttpHostConnectException.class) public void testProxySettings() throws IOException { - OptimizelyHttpClient optimizelyHttpClient = OptimizelyHttpClient.builder().build(); + OptimizelyHttpClient optimizelyHttpClient = builder().build(); // If this request succeeds then the proxy config was not picked up. HttpGet get = new HttpGet("https://www.optimizely.com"); diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java index fb76a27b7..c61a1f01a 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java @@ -42,6 +42,7 @@ import static com.optimizely.ab.config.HttpProjectConfigManager.*; import static java.util.concurrent.TimeUnit.SECONDS; +import static java.util.concurrent.TimeUnit.MINUTES; import static org.junit.Assert.*; import static org.mockito.Matchers.any; import static org.mockito.Mockito.*; @@ -254,6 +255,20 @@ public void testInvalidBlockingTimeout() { assertEquals(SECONDS, builder.blockingTimeoutUnit); } + @Test + public void testEvictTime() { + Builder builder = builder(); + long expectedPeriod = builder.evictConnectionIdleTimePeriod; + TimeUnit expectedTimeUnit = builder.evictConnectionIdleTimeUnit; + + assertEquals(expectedPeriod, 1L); + assertEquals(expectedTimeUnit, MINUTES); + + builder.withEvictIdleConnections(10L, SECONDS); + assertEquals(10, builder.evictConnectionIdleTimePeriod); + assertEquals(SECONDS, builder.evictConnectionIdleTimeUnit); + } + @Test @Ignore public void testGetDatafileHttpResponse2XX() throws Exception { From 399a334bc992621bba2eefc292984ace1c333593 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Mon, 8 Mar 2021 09:53:25 -0800 Subject: [PATCH 050/147] prepare for release 3.8.2 (#432) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b88a474be..61ece7a79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Optimizely Java X SDK Changelog +## [3.8.2] +March 8th, 2021 + +### Fixes +- Fix intermittent SocketTimeout exceptions while downloading datafiles. Add configurable `evictIdleConnections` to `HttpProjectConfigManager` to force close persistent connections after the idle time (evict after 1min idle time by default) ([#431](https://github.com/optimizely/java-sdk/pull/431)). + ## [3.8.1] March 2nd, 2021 From 5637fb5291ae4743c192a2f9a6ba106fb29e550c Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Mon, 8 Mar 2021 17:09:44 -0800 Subject: [PATCH 051/147] update README about MavenCentral (#433) --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ddbd5d59d..3bf5cac4f 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,19 @@ Optimizely Rollouts is free feature flags for development teams. Easily roll out The SDK is available through Bintray and is created with source and target compatibility of 1.8. The core-api and httpclient Bintray packages are [optimizely-sdk-core-api](https://bintray.com/optimizely/optimizely/optimizely-sdk-core-api) and [optimizely-sdk-httpclient](https://bintray.com/optimizely/optimizely/optimizely-sdk-httpclient) respectively. To install, place the -following in your `build.gradle` and substitute `VERSION` for the latest SDK version available via Bintray: +following in your `build.gradle` and substitute `VERSION` for the latest SDK version available via MavenCentral. + +--- +**NOTE** + +[Bintray/JCenter will be shut down](https://jfrog.com/blog/into-the-sunset-bintray-jcenter-gocenter-and-chartcenter/). The publish repository has been migrated to MavenCentral for the SDK version 3.8.1 or later. Older versions will be available in JCenter until February 1st, 2022. + +--- + ``` repositories { + mavenCentral() jcenter() } From e99f26b1b85df696760870eaad9be77ae6facbd1 Mon Sep 17 00:00:00 2001 From: mnoman09 Date: Mon, 21 Jun 2021 22:38:13 +0500 Subject: [PATCH 052/147] Feat: added SDKkey and environment in datafile (#434) * added SDKkey and environment in datafile added tests to verify changes * ignore key of sdk key and environment if null * resolved spotbugs issue * Changed environment to environmentKey Co-authored-by: mnoman09 --- .../ab/config/DatafileProjectConfig.java | 20 ++++++++++++++++++ .../optimizely/ab/config/ProjectConfig.java | 6 +++++- .../parser/DatafileGsonDeserializer.java | 10 ++++++++- .../parser/DatafileJacksonDeserializer.java | 12 ++++++++++- .../ab/config/parser/JsonConfigParser.java | 12 ++++++++++- .../config/parser/JsonSimpleConfigParser.java | 6 +++++- .../ab/optimizelyconfig/OptimizelyConfig.java | 21 ++++++++++++++++--- .../OptimizelyConfigService.java | 4 +++- .../PollingProjectConfigManagerTest.java | 4 +++- .../ab/config/ValidProjectConfigV4.java | 6 +++++- .../OptimizelyConfigServiceTest.java | 20 ++++++++++++++++-- .../OptimizelyConfigTest.java | 8 +++++-- .../config/valid-project-config-v4.json | 2 ++ .../resources/valid-project-config-v4.json | 2 ++ 14 files changed, 118 insertions(+), 15 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java index 2bc377a29..0757b6d4e 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java @@ -59,6 +59,8 @@ public class DatafileProjectConfig implements ProjectConfig { private final String accountId; private final String projectId; private final String revision; + private final String sdkKey; + private final String environmentKey; private final String version; private final boolean anonymizeIP; private final boolean sendFlagDecisions; @@ -108,6 +110,8 @@ public DatafileProjectConfig(String accountId, String projectId, String version, null, projectId, revision, + null, + null, version, attributes, audiences, @@ -127,6 +131,8 @@ public DatafileProjectConfig(String accountId, Boolean botFiltering, String projectId, String revision, + String sdkKey, + String environmentKey, String version, List attributes, List audiences, @@ -141,6 +147,8 @@ public DatafileProjectConfig(String accountId, this.projectId = projectId; this.version = version; this.revision = revision; + this.sdkKey = sdkKey; + this.environmentKey = environmentKey; this.anonymizeIP = anonymizeIP; this.sendFlagDecisions = sendFlagDecisions; this.botFiltering = botFiltering; @@ -326,6 +334,16 @@ public String getRevision() { return revision; } + @Override + public String getSdkKey() { + return sdkKey; + } + + @Override + public String getEnvironmentKey() { + return environmentKey; + } + @Override public boolean getSendFlagDecisions() { return sendFlagDecisions; } @@ -451,6 +469,8 @@ public String toString() { "accountId='" + accountId + '\'' + ", projectId='" + projectId + '\'' + ", revision='" + revision + '\'' + + ", sdkKey='" + sdkKey + '\'' + + ", environmentKey='" + environmentKey + '\'' + ", version='" + version + '\'' + ", anonymizeIP=" + anonymizeIP + ", botFiltering=" + botFiltering + diff --git a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java index 5a85fbd4e..a6222e8b2 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2020, Optimizely and contributors + * Copyright 2016-2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -55,6 +55,10 @@ Experiment getExperimentForKey(@Nonnull String experimentKey, String getRevision(); + String getSdkKey(); + + String getEnvironmentKey(); + boolean getSendFlagDecisions(); boolean getAnonymizeIP(); diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileGsonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileGsonDeserializer.java index 99ab71b78..26fe47330 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileGsonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileGsonDeserializer.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2020, Optimizely and contributors + * Copyright 2016-2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -87,6 +87,8 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa List featureFlags = null; List rollouts = null; Boolean botFiltering = null; + String sdkKey = null; + String environmentKey = null; boolean sendFlagDecisions = false; if (datafileVersion >= Integer.parseInt(DatafileProjectConfig.Version.V4.toString())) { Type featureFlagsType = new TypeToken>() { @@ -95,6 +97,10 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa Type rolloutsType = new TypeToken>() { }.getType(); rollouts = context.deserialize(jsonObject.get("rollouts").getAsJsonArray(), rolloutsType); + if (jsonObject.has("sdkKey")) + sdkKey = jsonObject.get("sdkKey").getAsString(); + if (jsonObject.has("environmentKey")) + environmentKey = jsonObject.get("environmentKey").getAsString(); if (jsonObject.has("botFiltering")) botFiltering = jsonObject.get("botFiltering").getAsBoolean(); if (jsonObject.has("sendFlagDecisions")) @@ -108,6 +114,8 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa botFiltering, projectId, revision, + sdkKey, + environmentKey, version, attributes, audiences, diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileJacksonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileJacksonDeserializer.java index 06ae5b1a9..4cded2ecb 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileJacksonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileJacksonDeserializer.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2020, Optimizely and contributors + * Copyright 2016-2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -63,11 +63,19 @@ public DatafileProjectConfig deserialize(JsonParser parser, DeserializationConte List featureFlags = null; List rollouts = null; + String sdkKey = null; + String environmentKey = null; Boolean botFiltering = null; boolean sendFlagDecisions = false; if (datafileVersion >= Integer.parseInt(DatafileProjectConfig.Version.V4.toString())) { featureFlags = JacksonHelpers.arrayNodeToList(node.get("featureFlags"), FeatureFlag.class, codec); rollouts = JacksonHelpers.arrayNodeToList(node.get("rollouts"), Rollout.class, codec); + if (node.hasNonNull("sdkKey")) { + sdkKey = node.get("sdkKey").textValue(); + } + if (node.hasNonNull("environmentKey")) { + environmentKey = node.get("environmentKey").textValue(); + } if (node.hasNonNull("botFiltering")) { botFiltering = node.get("botFiltering").asBoolean(); } @@ -83,6 +91,8 @@ public DatafileProjectConfig deserialize(JsonParser parser, DeserializationConte botFiltering, projectId, revision, + sdkKey, + environmentKey, version, attributes, audiences, diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java index 5f707cb69..c33f30a68 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2019, 2020, Optimizely and contributors + * Copyright 2016-2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -72,11 +72,17 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse List featureFlags = null; List rollouts = null; + String sdkKey = null; + String environmentKey = null; Boolean botFiltering = null; boolean sendFlagDecisions = false; if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { featureFlags = parseFeatureFlags(rootObject.getJSONArray("featureFlags")); rollouts = parseRollouts(rootObject.getJSONArray("rollouts")); + if (rootObject.has("sdkKey")) + sdkKey = rootObject.getString("sdkKey"); + if (rootObject.has("environmentKey")) + environmentKey = rootObject.getString("environmentKey"); if (rootObject.has("botFiltering")) botFiltering = rootObject.getBoolean("botFiltering"); if (rootObject.has("sendFlagDecisions")) @@ -90,6 +96,8 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse botFiltering, projectId, revision, + sdkKey, + environmentKey, version, attributes, audiences, @@ -100,6 +108,8 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse groups, rollouts ); + } catch (RuntimeException e) { + throw new ConfigParseException("Unable to parse datafile: " + json, e); } catch (Exception e) { throw new ConfigParseException("Unable to parse datafile: " + json, e); } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java index b5238a356..751e651ca 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2019, 2020, Optimizely and contributors + * Copyright 2016-2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -50,6 +50,8 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse String accountId = (String) rootObject.get("accountId"); String projectId = (String) rootObject.get("projectId"); String revision = (String) rootObject.get("revision"); + String sdkKey = (String) rootObject.get("sdkKey"); + String environmentKey = (String) rootObject.get("environmentKey"); String version = (String) rootObject.get("version"); int datafileVersion = Integer.parseInt(version); @@ -97,6 +99,8 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse botFiltering, projectId, revision, + sdkKey, + environmentKey, version, attributes, audiences, diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfig.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfig.java index 3e1ab5d5b..c1640ff44 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfig.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2020, Optimizely, Inc. and contributors * + * Copyright 2020-2021, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -16,31 +16,40 @@ package com.optimizely.ab.optimizelyconfig; +import com.fasterxml.jackson.annotation.JsonInclude; + import java.util.*; /** * Interface for OptimizleyConfig */ +@JsonInclude(JsonInclude.Include.NON_NULL) public class OptimizelyConfig { private Map experimentsMap; private Map featuresMap; private String revision; + private String sdkKey; + private String environmentKey; private String datafile; public OptimizelyConfig(Map experimentsMap, Map featuresMap, - String revision) { - this(experimentsMap, featuresMap, revision, null); + String revision, String sdkKey, String environmentKey) { + this(experimentsMap, featuresMap, revision, sdkKey, environmentKey, null); } public OptimizelyConfig(Map experimentsMap, Map featuresMap, String revision, + String sdkKey, + String environmentKey, String datafile) { this.experimentsMap = experimentsMap; this.featuresMap = featuresMap; this.revision = revision; + this.sdkKey = sdkKey; + this.environmentKey = environmentKey; this.datafile = datafile; } @@ -56,6 +65,12 @@ public String getRevision() { return revision; } + public String getSdkKey() { return sdkKey; } + + public String getEnvironmentKey() { + return environmentKey; + } + public String getDatafile() { return datafile; } diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java index f739ae549..af1965ce1 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2020, Optimizely, Inc. and contributors * + * Copyright 2020-2021, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -32,6 +32,8 @@ public OptimizelyConfigService(ProjectConfig projectConfig) { experimentsMap, getFeaturesMap(experimentsMap), projectConfig.getRevision(), + projectConfig.getSdkKey(), + projectConfig.getEnvironmentKey(), projectConfig.toDatafile() ); } diff --git a/core-api/src/test/java/com/optimizely/ab/config/PollingProjectConfigManagerTest.java b/core-api/src/test/java/com/optimizely/ab/config/PollingProjectConfigManagerTest.java index 5aebc884a..390c9b874 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/PollingProjectConfigManagerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/PollingProjectConfigManagerTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019-2020, Optimizely and contributors + * Copyright 2019-2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -156,6 +156,8 @@ public void testSetOptimizelyConfig(){ testProjectConfigManager.setConfig(projectConfig); assertEquals("1480511547", testProjectConfigManager.getOptimizelyConfig().getRevision()); + assertEquals("ValidProjectConfigV4", testProjectConfigManager.getOptimizelyConfig().getSdkKey()); + assertEquals("production", testProjectConfigManager.getOptimizelyConfig().getEnvironmentKey()); // cached config because project config is null testProjectConfigManager.setConfig(null); diff --git a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java index 5a922452f..f8ea02231 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java +++ b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java @@ -1,6 +1,6 @@ /** * - * Copyright 2017-2020, Optimizely and contributors + * Copyright 2017-2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -36,6 +36,8 @@ public class ValidProjectConfigV4 { private static final boolean BOT_FILTERING = true; private static final String PROJECT_ID = "3918735994"; private static final String REVISION = "1480511547"; + private static final String SDK_KEY = "ValidProjectConfigV4"; + private static final String ENVIRONMENT_KEY = "production"; private static final String VERSION = "4"; private static final Boolean SEND_FLAG_DECISIONS = true; @@ -1434,6 +1436,8 @@ public static ProjectConfig generateValidProjectConfigV4() { BOT_FILTERING, PROJECT_ID, REVISION, + SDK_KEY, + ENVIRONMENT_KEY, VERSION, attributes, audiences, diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java index 4ceb0a67c..52bc06181 100644 --- a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2020, Optimizely, Inc. and contributors * + * Copyright 2020-2021, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -52,6 +52,18 @@ public void testRevision() { assertEquals(expectedConfig.getRevision(), revision); } + @Test + public void testSdkKey() { + String sdkKey = optimizelyConfigService.getConfig().getSdkKey(); + assertEquals(expectedConfig.getSdkKey(), sdkKey); + } + + @Test + public void testEnvironmentKey() { + String environmentKey = optimizelyConfigService.getConfig().getEnvironmentKey(); + assertEquals(expectedConfig.getEnvironmentKey(), environmentKey); + } + @Test public void testGetFeaturesMap() { Map optimizelyExperimentMap = optimizelyConfigService.getExperimentsMap(); @@ -152,6 +164,8 @@ private ProjectConfig generateOptimizelyConfig() { true, "3918735994", "1480511547", + "ValidProjectConfigV4", + "production", "4", asList( new Attribute( @@ -510,7 +524,9 @@ OptimizelyConfig getExpectedConfig() { return new OptimizelyConfig( optimizelyExperimentMap, optimizelyFeatureMap, - "1480511547" + "1480511547", + "ValidProjectConfigV4", + "production" ); } } diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigTest.java index 3b9848a74..13b703799 100644 --- a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigTest.java +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigTest.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2020, Optimizely, Inc. and contributors * + * Copyright 2020-2021, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -30,9 +30,13 @@ public void testOptimizelyConfig() { OptimizelyConfig optimizelyConfig = new OptimizelyConfig( generateExperimentMap(), generateFeatureMap(), - "101" + "101", + "testingSdkKey", + "development" ); assertEquals("101", optimizelyConfig.getRevision()); + assertEquals("testingSdkKey", optimizelyConfig.getSdkKey()); + assertEquals("development", optimizelyConfig.getEnvironmentKey()); // verify the experiments map Map optimizelyExperimentMap = generateExperimentMap(); assertEquals(optimizelyExperimentMap.size(), optimizelyConfig.getExperimentsMap().size()); diff --git a/core-api/src/test/resources/config/valid-project-config-v4.json b/core-api/src/test/resources/config/valid-project-config-v4.json index 88b5f815b..01c927a5c 100644 --- a/core-api/src/test/resources/config/valid-project-config-v4.json +++ b/core-api/src/test/resources/config/valid-project-config-v4.json @@ -5,6 +5,8 @@ "sendFlagDecisions": true, "projectId": "3918735994", "revision": "1480511547", + "sdkKey": "ValidProjectConfigV4", + "environmentKey": "production", "version": "4", "audiences": [ { diff --git a/core-httpclient-impl/src/test/resources/valid-project-config-v4.json b/core-httpclient-impl/src/test/resources/valid-project-config-v4.json index 4f58f4c66..5d46cbbb5 100644 --- a/core-httpclient-impl/src/test/resources/valid-project-config-v4.json +++ b/core-httpclient-impl/src/test/resources/valid-project-config-v4.json @@ -4,6 +4,8 @@ "botFiltering": true, "projectId": "3918735994", "revision": "1480511547", + "sdkKey": "ValidProjectConfigV4", + "environmentKey": "production", "version": "4", "audiences": [ { From 6ce25817b4755dc00c9a09d6513c23dff7a16b57 Mon Sep 17 00:00:00 2001 From: Jake Brown Date: Thu, 12 Aug 2021 16:57:51 -0400 Subject: [PATCH 053/147] Audiences, Attributes and Events implementation (#438) ## Summary - Audiences Implementation - Audience Support added in OptimizelyConfigService - Modification of Experiments to support serialize functionality from Conditions - Attributes and Events added to OptimizelyConfig - Cleanup of parsing functions to reuse code - Additional test cases added - Add Deprecation for OptimizelyFeature.experimentsMap ## Test plan - FSC ## Issues - "OASIS-7813" --- .../com/optimizely/ab/config/Experiment.java | 101 +++++++- .../ab/config/audience/AndCondition.java | 17 ++ .../config/audience/AudienceIdCondition.java | 8 + .../ab/config/audience/Condition.java | 4 + .../ab/config/audience/EmptyCondition.java | 7 + .../ab/config/audience/NotCondition.java | 15 ++ .../ab/config/audience/NullCondition.java | 8 + .../ab/config/audience/OrCondition.java | 17 ++ .../ab/config/audience/UserAttribute.java | 26 ++- .../parser/ConditionJacksonDeserializer.java | 5 +- .../ab/internal/ConditionUtils.java | 27 +-- .../optimizelyconfig/OptimizelyAttribute.java | 53 +++++ .../optimizelyconfig/OptimizelyAudience.java | 63 +++++ .../ab/optimizelyconfig/OptimizelyConfig.java | 40 +++- .../OptimizelyConfigService.java | 220 ++++++++++++++---- .../ab/optimizelyconfig/OptimizelyEvent.java | 61 +++++ .../OptimizelyExperiment.java | 9 +- .../optimizelyconfig/OptimizelyFeature.java | 35 ++- .../optimizely/ab/config/ExperimentTest.java | 205 ++++++++++++++++ .../AudienceConditionEvaluationTest.java | 11 + .../OptimizelyAttributeTest.java | 39 ++++ .../OptimizelyConfigServiceTest.java | 134 ++++++++++- .../OptimizelyConfigTest.java | 17 +- .../optimizelyconfig/OptimizelyEventTest.java | 40 ++++ .../OptimizelyExperimentTest.java | 3 +- .../OptimizelyFeatureTest.java | 11 +- 26 files changed, 1071 insertions(+), 105 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyAttribute.java create mode 100644 core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyAudience.java create mode 100644 core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyEvent.java create mode 100644 core-api/src/test/java/com/optimizely/ab/config/ExperimentTest.java create mode 100644 core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyAttributeTest.java create mode 100644 core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyEventTest.java diff --git a/core-api/src/main/java/com/optimizely/ab/config/Experiment.java b/core-api/src/main/java/com/optimizely/ab/config/Experiment.java index e77becd39..11530735c 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/Experiment.java +++ b/core-api/src/main/java/com/optimizely/ab/config/Experiment.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2019, Optimizely and contributors + * Copyright 2016-2019, 2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,8 +18,7 @@ import com.fasterxml.jackson.annotation.*; import com.optimizely.ab.annotations.VisibleForTesting; -import com.optimizely.ab.config.audience.AudienceIdCondition; -import com.optimizely.ab.config.audience.Condition; +import com.optimizely.ab.config.audience.*; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -43,6 +42,10 @@ public class Experiment implements IdKeyMapped { private final String layerId; private final String groupId; + private final String AND = "AND"; + private final String OR = "OR"; + private final String NOT = "NOT"; + private final List audienceIds; private final Condition audienceConditions; private final List variations; @@ -173,6 +176,98 @@ public boolean isLaunched() { return status.equals(ExperimentStatus.LAUNCHED.toString()); } + public String serializeConditions(Map audiencesMap) { + Condition condition = this.audienceConditions; + return condition instanceof EmptyCondition ? "" : this.serialize(condition, audiencesMap); + } + + private String getNameFromAudienceId(String audienceId, Map audiencesMap) { + StringBuilder audienceName = new StringBuilder(); + if (audiencesMap != null && audiencesMap.get(audienceId) != null) { + audienceName.append("\"" + audiencesMap.get(audienceId) + "\""); + } else { + audienceName.append("\"" + audienceId + "\""); + } + return audienceName.toString(); + } + + private String getOperandOrAudienceId(Condition condition, Map audiencesMap) { + if (condition != null) { + if (condition instanceof AudienceIdCondition) { + return this.getNameFromAudienceId(condition.getOperandOrId(), audiencesMap); + } else { + return condition.getOperandOrId(); + } + } else { + return ""; + } + } + + public String serialize(Condition condition, Map audiencesMap) { + StringBuilder stringBuilder = new StringBuilder(); + List conditions; + + String operand = this.getOperandOrAudienceId(condition, audiencesMap); + switch (operand){ + case (AND): + conditions = ((AndCondition) condition).getConditions(); + stringBuilder.append(this.getNameOrNextCondition(operand, conditions, audiencesMap)); + break; + case (OR): + conditions = ((OrCondition) condition).getConditions(); + stringBuilder.append(this.getNameOrNextCondition(operand, conditions, audiencesMap)); + break; + case (NOT): + stringBuilder.append(operand + " "); + Condition notCondition = ((NotCondition) condition).getCondition(); + if (notCondition instanceof AudienceIdCondition) { + stringBuilder.append(serialize(notCondition, audiencesMap)); + } else { + stringBuilder.append("(" + serialize(notCondition, audiencesMap) + ")"); + } + break; + default: + stringBuilder.append(operand); + break; + } + + return stringBuilder.toString(); + } + + public String getNameOrNextCondition(String operand, List conditions, Map audiencesMap) { + StringBuilder stringBuilder = new StringBuilder(); + int index = 0; + if (conditions.isEmpty()) { + return ""; + } else if (conditions.size() == 1) { + return serialize(conditions.get(0), audiencesMap); + } else { + for (Condition con : conditions) { + index++; + if (index + 1 <= conditions.size()) { + if (con instanceof AudienceIdCondition) { + String audienceName = this.getNameFromAudienceId(((AudienceIdCondition) con).getAudienceId(), + audiencesMap); + stringBuilder.append( audienceName + " "); + } else { + stringBuilder.append("(" + serialize(con, audiencesMap) + ") "); + } + stringBuilder.append(operand); + stringBuilder.append(" "); + } else { + if (con instanceof AudienceIdCondition) { + String audienceName = this.getNameFromAudienceId(((AudienceIdCondition) con).getAudienceId(), + audiencesMap); + stringBuilder.append(audienceName); + } else { + stringBuilder.append("(" + serialize(con, audiencesMap) + ")"); + } + } + } + } + return stringBuilder.toString(); + } + @Override public String toString() { return "Experiment{" + diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java index 8b458d059..dca3cefc8 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java @@ -23,6 +23,7 @@ import javax.annotation.concurrent.Immutable; import java.util.List; import java.util.Map; +import java.util.StringJoiner; /** * Represents an 'And' conditions condition operation. @@ -30,6 +31,7 @@ public class AndCondition implements Condition { private final List conditions; + private static final String OPERAND = "AND"; public AndCondition(@Nonnull List conditions) { this.conditions = conditions; @@ -67,6 +69,21 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { return true; // otherwise, return true } + @Override + public String getOperandOrId() { + return OPERAND; + } + + @Override + public String toJson() { + StringJoiner s = new StringJoiner(", ", "[", "]"); + s.add("\"and\""); + for (int i = 0; i < conditions.size(); i++) { + s.add(conditions.get(i).toJson()); + } + return s.toString(); + } + @Override public String toString() { StringBuilder s = new StringBuilder(); diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java index c4f052ebb..e07757016 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java @@ -64,6 +64,11 @@ public String getAudienceId() { return audienceId; } + @Override + public String getOperandOrId() { + return audienceId; + } + @Nullable @Override public Boolean evaluate(ProjectConfig config, Map attributes) { @@ -101,4 +106,7 @@ public int hashCode() { public String toString() { return audienceId; } + + @Override + public String toJson() { return null; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java index 772d2b03e..11b7165b9 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java @@ -28,4 +28,8 @@ public interface Condition { @Nullable Boolean evaluate(ProjectConfig config, Map attributes); + + String toJson(); + + String getOperandOrId(); } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java index 8f8aedeae..9bb355a13 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java @@ -27,4 +27,11 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { return true; } + @Override + public String toJson() { return null; } + + @Override + public String getOperandOrId() { + return null; + } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java index b7f45f2ac..8c17b4ef2 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java @@ -23,6 +23,7 @@ import javax.annotation.Nonnull; import java.util.Map; +import java.util.StringJoiner; /** * Represents a 'Not' conditions condition operation. @@ -31,6 +32,7 @@ public class NotCondition implements Condition { private final Condition condition; + private static final String OPERAND = "NOT"; public NotCondition(@Nonnull Condition condition) { this.condition = condition; @@ -47,6 +49,19 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { return (conditionEval == null ? null : !conditionEval); } + @Override + public String getOperandOrId() { + return OPERAND; + } + + @Override + public String toJson() { + StringJoiner s = new StringJoiner(", ","[","]"); + s.add("\"not\""); + s.add(condition.toJson()); + return s.toString(); + } + @Override public String toString() { StringBuilder s = new StringBuilder(); diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java index fcf5100db..10633aed9 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java @@ -26,4 +26,12 @@ public class NullCondition implements Condition { public Boolean evaluate(ProjectConfig config, Map attributes) { return null; } + + @Override + public String toJson() { return null; } + + @Override + public String getOperandOrId() { + return null; + } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java index 70572a9a9..71c8c9e76 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java @@ -23,6 +23,7 @@ import javax.annotation.concurrent.Immutable; import java.util.List; import java.util.Map; +import java.util.StringJoiner; /** * Represents an 'Or' conditions condition operation. @@ -30,6 +31,7 @@ @Immutable public class OrCondition implements Condition { private final List conditions; + private static final String OPERAND = "OR"; public OrCondition(@Nonnull List conditions) { this.conditions = conditions; @@ -65,6 +67,21 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { return false; } + @Override + public String getOperandOrId() { + return OPERAND; + } + + @Override + public String toJson() { + StringJoiner s = new StringJoiner(", ", "[", "]"); + s.add("\"or\""); + for (int i = 0; i < conditions.size(); i++) { + s.add(conditions.get(i).toJson()); + } + return s.toString(); + } + @Override public String toString() { StringBuilder s = new StringBuilder(); diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java b/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java index 277f2f184..ed029f89c 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java @@ -119,19 +119,39 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { } @Override - public String toString() { + public String getOperandOrId() { + return null; + } + + public String getValueStr() { final String valueStr; if (value == null) { valueStr = "null"; } else if (value instanceof String) { - valueStr = String.format("'%s'", value); + valueStr = String.format("%s", value); } else { valueStr = value.toString(); } + return valueStr; + } + + @Override + public String toJson() { + StringBuilder attributes = new StringBuilder(); + if (name != null) attributes.append("{\"name\":\"" + name + "\""); + if (type != null) attributes.append(", \"type\":\"" + type + "\""); + if (match != null) attributes.append(", \"match\":\"" + match + "\""); + attributes.append(", \"value\":" + ((value instanceof String) ? ("\"" + getValueStr() + "\"") : getValueStr()) + "}"); + + return attributes.toString(); + } + + @Override + public String toString() { return "{name='" + name + "\'" + ", type='" + type + "\'" + ", match='" + match + "\'" + - ", value=" + valueStr + + ", value=" + ((value instanceof String) ? ("'" + getValueStr() + "'") : getValueStr()) + "}"; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/ConditionJacksonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/ConditionJacksonDeserializer.java index ca57ac0af..f443d9d07 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/ConditionJacksonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/ConditionJacksonDeserializer.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018-2019, Optimizely and contributors + * Copyright 2018-2019, 2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -121,9 +121,6 @@ protected static Condition parseConditions(Class clazz, ObjectMapper obje case "and": condition = new AndCondition(conditions); break; - case "or": - condition = new OrCondition(conditions); - break; case "not": condition = new NotCondition(conditions.isEmpty() ? new NullCondition() : conditions.get(0)); break; diff --git a/core-api/src/main/java/com/optimizely/ab/internal/ConditionUtils.java b/core-api/src/main/java/com/optimizely/ab/internal/ConditionUtils.java index d9af5b58b..32ab45cc4 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/ConditionUtils.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/ConditionUtils.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018-2019,2021, Optimizely and contributors + * Copyright 2018-2019, 2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -147,23 +147,7 @@ static public Condition parseConditions(Class clazz, List rawObje conditions.add(parseConditions(clazz, obj)); } - Condition condition; - switch (operand) { - case "and": - condition = new AndCondition(conditions); - break; - case "or": - condition = new OrCondition(conditions); - break; - case "not": - condition = new NotCondition(conditions.isEmpty() ? new NullCondition() : conditions.get(0)); - break; - default: - condition = new OrCondition(conditions); - break; - } - - return condition; + return buildCondition(operand, conditions); } static public String operand(Object object) { @@ -210,14 +194,15 @@ static public Condition parseConditions(Class clazz, org.json.JSONArray c conditions.add(parseConditions(clazz, obj)); } + return buildCondition(operand, conditions); + } + + private static Condition buildCondition(String operand, List conditions) { Condition condition; switch (operand) { case "and": condition = new AndCondition(conditions); break; - case "or": - condition = new OrCondition(conditions); - break; case "not": condition = new NotCondition(conditions.isEmpty() ? new NullCondition() : conditions.get(0)); break; diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyAttribute.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyAttribute.java new file mode 100644 index 000000000..2c142bc86 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyAttribute.java @@ -0,0 +1,53 @@ +/**************************************************************************** + * Copyright 2021, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; + +import com.optimizely.ab.config.IdKeyMapped; + +/** + * Represents the Attribute's map {@link OptimizelyConfig} + */ +public class OptimizelyAttribute implements IdKeyMapped { + + private String id; + private String key; + + public OptimizelyAttribute(String id, + String key) { + this.id = id; + this.key = key; + } + + public String getId() { return id; } + + public String getKey() { return key; } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) return false; + if (obj == this) return true; + OptimizelyAttribute optimizelyAttribute = (OptimizelyAttribute) obj; + return id.equals(optimizelyAttribute.getId()) && + key.equals(optimizelyAttribute.getKey()); + } + + @Override + public int hashCode() { + int hash = id.hashCode(); + hash = 31 * hash + key.hashCode(); + return hash; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyAudience.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyAudience.java new file mode 100644 index 000000000..d874b900e --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyAudience.java @@ -0,0 +1,63 @@ +/**************************************************************************** + * Copyright 2021, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; + +import com.optimizely.ab.config.IdKeyMapped; +import com.optimizely.ab.config.audience.Condition; + +import java.util.List; + +/** + * Represents the Audiences list {@link OptimizelyConfig} + */ +public class OptimizelyAudience{ + + private String id; + private String name; + private String conditions; + + public OptimizelyAudience(String id, + String name, + String conditions) { + this.id = id; + this.name = name; + this.conditions = conditions; + } + + public String getId() { return id; } + + public String getName() { return name; } + + public String getConditions() { return conditions; } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) return false; + if (obj == this) return true; + OptimizelyAudience optimizelyAudience = (OptimizelyAudience) obj; + return id.equals(optimizelyAudience.getId()) && + name.equals(optimizelyAudience.getName()) && + conditions.equals(optimizelyAudience.getConditions()); + } + + @Override + public int hashCode() { + int hash = id.hashCode(); + hash = 31 * hash + conditions.hashCode(); + return hash; + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfig.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfig.java index c1640ff44..7fa890b66 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfig.java @@ -17,6 +17,8 @@ import com.fasterxml.jackson.annotation.JsonInclude; +import com.optimizely.ab.config.Attribute; +import com.optimizely.ab.config.EventType; import java.util.*; @@ -25,31 +27,40 @@ */ @JsonInclude(JsonInclude.Include.NON_NULL) public class OptimizelyConfig { - + private Map experimentsMap; private Map featuresMap; + private List attributes; + private List events; + private List audiences; private String revision; private String sdkKey; private String environmentKey; private String datafile; - public OptimizelyConfig(Map experimentsMap, - Map featuresMap, - String revision, String sdkKey, String environmentKey) { - this(experimentsMap, featuresMap, revision, sdkKey, environmentKey, null); - } - public OptimizelyConfig(Map experimentsMap, Map featuresMap, String revision, String sdkKey, String environmentKey, + List attributes, + List events, + List audiences, String datafile) { + + // This experimentsMap is for experiments of legacy projects only. + // For flag projects, experiment keys are not guaranteed to be unique + // across multiple flags, so this map may not include all experiments + // when keys conflict. this.experimentsMap = experimentsMap; + this.featuresMap = featuresMap; this.revision = revision; - this.sdkKey = sdkKey; - this.environmentKey = environmentKey; + this.sdkKey = sdkKey == null ? "" : sdkKey; + this.environmentKey = environmentKey == null ? "" : environmentKey; + this.attributes = attributes; + this.events = events; + this.audiences = audiences; this.datafile = datafile; } @@ -61,6 +72,12 @@ public Map getFeaturesMap() { return featuresMap; } + public List getAttributes() { return attributes; } + + public List getEvents() { return events; } + + public List getAudiences() { return audiences; } + public String getRevision() { return revision; } @@ -82,7 +99,10 @@ public boolean equals(Object obj) { OptimizelyConfig optimizelyConfig = (OptimizelyConfig) obj; return revision.equals(optimizelyConfig.getRevision()) && experimentsMap.equals(optimizelyConfig.getExperimentsMap()) && - featuresMap.equals(optimizelyConfig.getFeaturesMap()); + featuresMap.equals(optimizelyConfig.getFeaturesMap()) && + attributes.equals(optimizelyConfig.getAttributes()) && + events.equals(optimizelyConfig.getEvents()) && + audiences.equals(optimizelyConfig.getAudiences()); } @Override diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java index af1965ce1..60aef9ce3 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java @@ -17,23 +17,59 @@ import com.optimizely.ab.annotations.VisibleForTesting; import com.optimizely.ab.config.*; +import com.optimizely.ab.config.audience.Audience; + import java.util.*; public class OptimizelyConfigService { private ProjectConfig projectConfig; private OptimizelyConfig optimizelyConfig; + private List audiences; + private Map audiencesMap; + private Map> featureIdToVariablesMap = new HashMap<>(); + private Map experimentMapByExperimentId = new HashMap<>(); public OptimizelyConfigService(ProjectConfig projectConfig) { this.projectConfig = projectConfig; + this.audiences = getAudiencesList(projectConfig.getTypedAudiences(), projectConfig.getAudiences()); + this.audiencesMap = getAudiencesMap(this.audiences); + + List optimizelyAttributes = new ArrayList<>(); + List optimizelyEvents = new ArrayList<>(); Map experimentsMap = getExperimentsMap(); + + if (projectConfig.getAttributes() != null) { + for (Attribute attribute : projectConfig.getAttributes()) { + OptimizelyAttribute copyAttribute = new OptimizelyAttribute( + attribute.getId(), + attribute.getKey() + ); + optimizelyAttributes.add(copyAttribute); + } + } + + if (projectConfig.getEventTypes() != null) { + for (EventType event : projectConfig.getEventTypes()) { + OptimizelyEvent copyEvent = new OptimizelyEvent( + event.getId(), + event.getKey(), + event.getExperimentIds() + ); + optimizelyEvents.add(copyEvent); + } + } + optimizelyConfig = new OptimizelyConfig( experimentsMap, getFeaturesMap(experimentsMap), projectConfig.getRevision(), projectConfig.getSdkKey(), projectConfig.getEnvironmentKey(), + optimizelyAttributes, + optimizelyEvents, + this.audiences, projectConfig.toDatafile() ); } @@ -60,6 +96,7 @@ Map> generateFeatureKeyToVariablesMap() { Map> featureVariableIdMap = new HashMap<>(); for (FeatureFlag featureFlag : featureFlags) { featureVariableIdMap.put(featureFlag.getKey(), featureFlag.getVariables()); + featureIdToVariablesMap.put(featureFlag.getId(), featureFlag.getVariables()); } return featureVariableIdMap; } @@ -73,33 +110,39 @@ String getExperimentFeatureKey(String experimentId) { @VisibleForTesting Map getExperimentsMap() { List experiments = projectConfig.getExperiments(); + if (experiments == null) { return Collections.emptyMap(); } Map featureExperimentMap = new HashMap<>(); + for (Experiment experiment : experiments) { - featureExperimentMap.put(experiment.getKey(), new OptimizelyExperiment( + OptimizelyExperiment optimizelyExperiment = new OptimizelyExperiment( experiment.getId(), experiment.getKey(), - getVariationsMap(experiment.getVariations(), experiment.getId()) - )); + getVariationsMap(experiment.getVariations(), experiment.getId(), null), + experiment.serializeConditions(this.audiencesMap) + ); + + featureExperimentMap.put(experiment.getKey(), optimizelyExperiment); + experimentMapByExperimentId.put(experiment.getId(), optimizelyExperiment); } return featureExperimentMap; } @VisibleForTesting - Map getVariationsMap(List variations, String experimentId) { + Map getVariationsMap(List variations, String experimentId, String featureId) { if (variations == null) { return Collections.emptyMap(); } - Boolean isFeatureExperiment = this.getExperimentFeatureKey(experimentId) != null; + Map variationKeyMap = new HashMap<>(); for (Variation variation : variations) { variationKeyMap.put(variation.getKey(), new OptimizelyVariation( variation.getId(), variation.getKey(), - isFeatureExperiment ? variation.getFeatureEnabled() : null, - getMergedVariablesMap(variation, experimentId) + variation.getFeatureEnabled(), + getMergedVariablesMap(variation, experimentId, featureId) )); } return variationKeyMap; @@ -112,36 +155,41 @@ Map getVariationsMap(List variations, St * 3. If Variation does not contain a variable, then all `id`, `key`, `type` and defaultValue as `value` is used from feature varaible and added to variation. */ @VisibleForTesting - Map getMergedVariablesMap(Variation variation, String experimentId) { + Map getMergedVariablesMap(Variation variation, String experimentId, String featureId) { String featureKey = this.getExperimentFeatureKey(experimentId); - if (featureKey != null) { - // Map containing variables list for every feature key used for merging variation and feature variables. - Map> featureKeyToVariablesMap = generateFeatureKeyToVariablesMap(); - - // Generate temp map of all the available variable values from variation. - Map tempVariableIdMap = getFeatureVariableUsageInstanceMap(variation.getFeatureVariableUsageInstances()); - - // Iterate over all the variables available in associated feature. - // Use value from variation variable if variable is available in variation and feature is enabled, otherwise use defaultValue from feature variable. - List featureVariables = featureKeyToVariablesMap.get(featureKey); - if (featureVariables == null) { - return Collections.emptyMap(); - } + Map> featureKeyToVariablesMap = generateFeatureKeyToVariablesMap(); + if (featureKey == null && featureId == null) { + return Collections.emptyMap(); + } - Map featureVariableKeyMap = new HashMap<>(); - for (FeatureVariable featureVariable : featureVariables) { - featureVariableKeyMap.put(featureVariable.getKey(), new OptimizelyVariable( - featureVariable.getId(), - featureVariable.getKey(), - featureVariable.getType(), - variation.getFeatureEnabled() && tempVariableIdMap.get(featureVariable.getId()) != null - ? tempVariableIdMap.get(featureVariable.getId()).getValue() - : featureVariable.getDefaultValue() - )); - } - return featureVariableKeyMap; + // Generate temp map of all the available variable values from variation. + Map tempVariableIdMap = getFeatureVariableUsageInstanceMap(variation.getFeatureVariableUsageInstances()); + + // Iterate over all the variables available in associated feature. + // Use value from variation variable if variable is available in variation and feature is enabled, otherwise use defaultValue from feature variable. + List featureVariables; + + if (featureId != null) { + featureVariables = featureIdToVariablesMap.get(featureId); + } else { + featureVariables = featureKeyToVariablesMap.get(featureKey); } - return Collections.emptyMap(); + if (featureVariables == null) { + return Collections.emptyMap(); + } + + Map featureVariableKeyMap = new HashMap<>(); + for (FeatureVariable featureVariable : featureVariables) { + featureVariableKeyMap.put(featureVariable.getKey(), new OptimizelyVariable( + featureVariable.getId(), + featureVariable.getKey(), + featureVariable.getType(), + variation.getFeatureEnabled() && tempVariableIdMap.get(featureVariable.getId()) != null + ? tempVariableIdMap.get(featureVariable.getId()).getValue() + : featureVariable.getDefaultValue() + )); + } + return featureVariableKeyMap; } @VisibleForTesting @@ -172,16 +220,52 @@ Map getFeaturesMap(Map Map optimizelyFeatureKeyMap = new HashMap<>(); for (FeatureFlag featureFlag : featureFlags) { - optimizelyFeatureKeyMap.put(featureFlag.getKey(), new OptimizelyFeature( + Map experimentsMapForFeature = + getExperimentsMapForFeature(featureFlag.getExperimentIds(), allExperimentsMap); + + List experimentRules = + new ArrayList(experimentsMapForFeature.values()); + List deliveryRules = + this.getDeliveryRules(featureFlag.getRolloutId(), featureFlag.getId()); + + OptimizelyFeature optimizelyFeature = new OptimizelyFeature( featureFlag.getId(), featureFlag.getKey(), - getExperimentsMapForFeature(featureFlag.getExperimentIds(), allExperimentsMap), - getFeatureVariablesMap(featureFlag.getVariables()) - )); + experimentsMapForFeature, + getFeatureVariablesMap(featureFlag.getVariables()), + experimentRules, + deliveryRules + ); + + optimizelyFeatureKeyMap.put(featureFlag.getKey(), optimizelyFeature); } return optimizelyFeatureKeyMap; } + List getDeliveryRules(String rolloutId, String featureId) { + + List deliveryRules = new ArrayList(); + + Rollout rollout = projectConfig.getRolloutIdMapping().get(rolloutId); + + if (rollout != null) { + List rolloutExperiments = rollout.getExperiments(); + for (Experiment experiment: rolloutExperiments) { + OptimizelyExperiment optimizelyExperiment = new OptimizelyExperiment( + experiment.getId(), + experiment.getKey(), + this.getVariationsMap(experiment.getVariations(), experiment.getId(), featureId), + experiment.serializeConditions(this.audiencesMap) + ); + + deliveryRules.add(optimizelyExperiment); + } + return deliveryRules; + } + + return Collections.emptyList(); + } + @VisibleForTesting Map getExperimentsMapForFeature(List experimentIds, Map allExperimentsMap) { if (experimentIds == null) { @@ -190,8 +274,8 @@ Map getExperimentsMapForFeature(List exper Map optimizelyExperimentKeyMap = new HashMap<>(); for (String experimentId : experimentIds) { - String experimentKey = projectConfig.getExperimentIdMapping().get(experimentId).getKey(); - optimizelyExperimentKeyMap.put(experimentKey, allExperimentsMap.get(experimentKey)); + Experiment experiment = projectConfig.getExperimentIdMapping().get(experimentId); + optimizelyExperimentKeyMap.put(experiment.getKey(), experimentMapByExperimentId.get(experiment.getId())); } return optimizelyExperimentKeyMap; @@ -215,4 +299,60 @@ Map getFeatureVariablesMap(List fea return featureVariableKeyMap; } + + @VisibleForTesting + List getAudiencesList(List typedAudiences, List audiences) { + /* + * This method merges typedAudiences with audiences from the Project + * config. Precedence is given to typedAudiences over audiences. + * + * Returns: + * A new list with the merged audiences as OptimizelyAudience objects. + * */ + List audiencesList = new ArrayList<>(); + Map idLookupMap = new HashMap<>(); + if (typedAudiences != null) { + for (Audience audience : typedAudiences) { + OptimizelyAudience optimizelyAudience = new OptimizelyAudience( + audience.getId(), + audience.getName(), + audience.getConditions().toJson() + ); + audiencesList.add(optimizelyAudience); + idLookupMap.put(audience.getId(), audience.getId()); + } + } + + if (audiences != null) { + for (Audience audience : audiences) { + if (!idLookupMap.containsKey(audience.getId()) && !audience.getId().equals("$opt_dummy_audience")) { + OptimizelyAudience optimizelyAudience = new OptimizelyAudience( + audience.getId(), + audience.getName(), + audience.getConditions().toJson() + ); + audiencesList.add(optimizelyAudience); + } + } + } + + return audiencesList; + } + + @VisibleForTesting + Map getAudiencesMap(List optimizelyAudiences) { + Map audiencesMap = new HashMap<>(); + + // Build audienceMap as [id:name] + if (optimizelyAudiences != null) { + for (OptimizelyAudience audience : optimizelyAudiences) { + audiencesMap.put( + audience.getId(), + audience.getName() + ); + } + } + + return audiencesMap; + } } diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyEvent.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyEvent.java new file mode 100644 index 000000000..9edda8700 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyEvent.java @@ -0,0 +1,61 @@ +/**************************************************************************** + * Copyright 2021, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; + +import com.optimizely.ab.config.IdKeyMapped; + +import java.util.List; + +/** + * Represents the Events's map {@link OptimizelyConfig} + */ +public class OptimizelyEvent implements IdKeyMapped { + + private String id; + private String key; + private List experimentIds; + + public OptimizelyEvent(String id, + String key, + List experimentIds) { + this.id = id; + this.key = key; + this.experimentIds = experimentIds; + } + + public String getId() { return id; } + + public String getKey() { return key; } + + public List getExperimentIds() { return experimentIds; } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) return false; + if (obj == this) return true; + OptimizelyEvent optimizelyEvent = (OptimizelyEvent) obj; + return id.equals(optimizelyEvent.getId()) && + key.equals(optimizelyEvent.getKey()) && + experimentIds.equals(optimizelyEvent.getExperimentIds()); + } + + @Override + public int hashCode() { + int hash = id.hashCode(); + hash = 31 * hash + experimentIds.hashCode(); + return hash; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyExperiment.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyExperiment.java index fd66e9aab..0f5b9e193 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyExperiment.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyExperiment.java @@ -26,12 +26,14 @@ public class OptimizelyExperiment implements IdKeyMapped { private String id; private String key; + private String audiences = ""; private Map variationsMap; - public OptimizelyExperiment(String id, String key, Map variationsMap) { + public OptimizelyExperiment(String id, String key, Map variationsMap, String audiences) { this.id = id; this.key = key; this.variationsMap = variationsMap; + this.audiences = audiences; } public String getId() { @@ -42,6 +44,8 @@ public String getKey() { return key; } + public String getAudiences() { return audiences; } + public Map getVariationsMap() { return variationsMap; } @@ -53,7 +57,8 @@ public boolean equals(Object obj) { OptimizelyExperiment optimizelyExperiment = (OptimizelyExperiment) obj; return id.equals(optimizelyExperiment.getId()) && key.equals(optimizelyExperiment.getKey()) && - variationsMap.equals(optimizelyExperiment.getVariationsMap()); + variationsMap.equals(optimizelyExperiment.getVariationsMap()) && + audiences.equals(optimizelyExperiment.getAudiences()); } @Override diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeature.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeature.java index 954f7b14e..1f8359e62 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeature.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeature.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2020, Optimizely, Inc. and contributors * + * Copyright 2020-2021, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -17,6 +17,8 @@ import com.optimizely.ab.config.IdKeyMapped; +import java.util.Collections; +import java.util.List; import java.util.Map; /** @@ -27,17 +29,28 @@ public class OptimizelyFeature implements IdKeyMapped { private String id; private String key; + private List deliveryRules; + private List experimentRules; + + /** + * @deprecated use {@link #experimentRules} and {@link #deliveryRules} instead + */ + @Deprecated private Map experimentsMap; private Map variablesMap; public OptimizelyFeature(String id, - String key, - Map experimentsMap, - Map variablesMap) { + String key, + Map experimentsMap, + Map variablesMap, + List experimentRules, + List deliveryRules) { this.id = id; this.key = key; this.experimentsMap = experimentsMap; this.variablesMap = variablesMap; + this.experimentRules = experimentRules; + this.deliveryRules = deliveryRules; } public String getId() { @@ -56,6 +69,10 @@ public Map getVariablesMap() { return variablesMap; } + public List getExperimentRules() { return experimentRules; } + + public List getDeliveryRules() { return deliveryRules; } + @Override public boolean equals(Object obj) { if (obj == null || getClass() != obj.getClass()) return false; @@ -64,13 +81,19 @@ public boolean equals(Object obj) { return id.equals(optimizelyFeature.getId()) && key.equals(optimizelyFeature.getKey()) && experimentsMap.equals(optimizelyFeature.getExperimentsMap()) && - variablesMap.equals(optimizelyFeature.getVariablesMap()); + variablesMap.equals(optimizelyFeature.getVariablesMap()) && + experimentRules.equals(optimizelyFeature.getExperimentRules()) && + deliveryRules.equals(optimizelyFeature.getDeliveryRules()); } @Override public int hashCode() { int result = id.hashCode(); - result = 31 * result + experimentsMap.hashCode() + variablesMap.hashCode(); + result = 31 * result + + experimentsMap.hashCode() + + variablesMap.hashCode() + + experimentRules.hashCode() + + deliveryRules.hashCode(); return result; } } diff --git a/core-api/src/test/java/com/optimizely/ab/config/ExperimentTest.java b/core-api/src/test/java/com/optimizely/ab/config/ExperimentTest.java new file mode 100644 index 000000000..334e76067 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/ExperimentTest.java @@ -0,0 +1,205 @@ +/** + * + * Copyright 2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config; + +import com.optimizely.ab.config.audience.*; +import org.junit.Test; +import static org.junit.Assert.*; + +import java.util.*; + +public class ExperimentTest { + + @Test + public void testStringifyConditionScenarios() { + List audienceConditionsScenarios = getAudienceConditionsList(); + Map expectedScenarioStringsMap = getExpectedScenariosMap(); + Map audiencesMap = new HashMap<>(); + audiencesMap.put("1", "us"); + audiencesMap.put("2", "female"); + audiencesMap.put("3", "adult"); + audiencesMap.put("11", "fr"); + audiencesMap.put("12", "male"); + audiencesMap.put("13", "kid"); + + if (expectedScenarioStringsMap.size() == audienceConditionsScenarios.size()) { + for (int i = 0; i < audienceConditionsScenarios.size() - 1; i++) { + Experiment experiment = makeMockExperimentWithStatus(Experiment.ExperimentStatus.RUNNING, + audienceConditionsScenarios.get(i)); + String audiences = experiment.serializeConditions(audiencesMap); + assertEquals(expectedScenarioStringsMap.get(i+1), audiences); + } + } + + } + + public Map getExpectedScenariosMap() { + Map expectedScenarioStringsMap = new HashMap<>(); + expectedScenarioStringsMap.put(1, ""); + expectedScenarioStringsMap.put(2, "\"us\" OR \"female\""); + expectedScenarioStringsMap.put(3, "\"us\" AND \"female\" AND \"adult\""); + expectedScenarioStringsMap.put(4, "NOT \"us\""); + expectedScenarioStringsMap.put(5, "\"us\""); + expectedScenarioStringsMap.put(6, "\"us\""); + expectedScenarioStringsMap.put(7, "\"us\""); + expectedScenarioStringsMap.put(8, "\"us\" OR \"female\""); + expectedScenarioStringsMap.put(9, "(\"us\" OR \"female\") AND \"adult\""); + expectedScenarioStringsMap.put(10, "(\"us\" OR (\"female\" AND \"adult\")) AND (\"fr\" AND (\"male\" OR \"kid\"))"); + expectedScenarioStringsMap.put(11, "NOT (\"us\" AND \"female\")"); + expectedScenarioStringsMap.put(12, "\"us\" OR \"100000\""); + expectedScenarioStringsMap.put(13, ""); + + return expectedScenarioStringsMap; + } + + public List getAudienceConditionsList() { + AudienceIdCondition one = new AudienceIdCondition("1"); + AudienceIdCondition two = new AudienceIdCondition("2"); + AudienceIdCondition three = new AudienceIdCondition("3"); + AudienceIdCondition eleven = new AudienceIdCondition("11"); + AudienceIdCondition twelve = new AudienceIdCondition("12"); + AudienceIdCondition thirteen = new AudienceIdCondition("13"); + + // Scenario 1 - [] + EmptyCondition scenario1 = new EmptyCondition(); + + // Scenario 2 - ["or", "1", "2"] + List scenario2List = new ArrayList<>(); + scenario2List.add(one); + scenario2List.add(two); + OrCondition scenario2 = new OrCondition(scenario2List); + + // Scenario 3 - ["and", "1", "2", "3"] + List scenario3List = new ArrayList<>(); + scenario3List.add(one); + scenario3List.add(two); + scenario3List.add(three); + AndCondition scenario3 = new AndCondition(scenario3List); + + // Scenario 4 - ["not", "1"] + NotCondition scenario4 = new NotCondition(one); + + // Scenario 5 - ["or", "1"] + List scenario5List = new ArrayList<>(); + scenario5List.add(one); + OrCondition scenario5 = new OrCondition(scenario5List); + + // Scenario 6 - ["and", "1"] + List scenario6List = new ArrayList<>(); + scenario6List.add(one); + AndCondition scenario6 = new AndCondition(scenario6List); + + // Scenario 7 - ["1"] + AudienceIdCondition scenario7 = one; + + // Scenario 8 - ["1", "2"] + // Defaults to Or in Datafile Parsing resulting in an OrCondition + // Same as Scenario 2 + + OrCondition scenario8 = scenario2; + + // Scenario 9 - ["and", ["or", "1", "2"], "3"] + List Scenario9List = new ArrayList<>(); + Scenario9List.add(scenario2); + Scenario9List.add(three); + AndCondition scenario9 = new AndCondition(Scenario9List); + + // Scenario 10 - ["and", ["or", "1", ["and", "2", "3"]], ["and", "11, ["or", "12", "13"]]] + List scenario10List = new ArrayList<>(); + + List or1213List = new ArrayList<>(); + or1213List.add(twelve); + or1213List.add(thirteen); + OrCondition or1213 = new OrCondition(or1213List); + + List and11Or1213List = new ArrayList<>(); + and11Or1213List.add(eleven); + and11Or1213List.add(or1213); + AndCondition and11Or1213 = new AndCondition(and11Or1213List); + + List and23List = new ArrayList<>(); + and23List.add(two); + and23List.add(three); + AndCondition and23 = new AndCondition(and23List); + + List or1And23List = new ArrayList<>(); + or1And23List.add(one); + or1And23List.add(and23); + OrCondition or1And23 = new OrCondition(or1And23List); + + scenario10List.add(or1And23); + scenario10List.add(and11Or1213); + AndCondition scenario10 = new AndCondition(scenario10List); + + // Scenario 11 - ["not", ["and", "1", "2"]] + List and12List = new ArrayList<>(); + and12List.add(one); + and12List.add(two); + AndCondition and12 = new AndCondition(and12List); + + NotCondition scenario11 = new NotCondition(and12); + + // Scenario 12 - ["or", "1", "100000"] + List scenario12List = new ArrayList<>(); + scenario12List.add(one); + AudienceIdCondition unknownAudience = new AudienceIdCondition("100000"); + scenario12List.add(unknownAudience); + + OrCondition scenario12 = new OrCondition(scenario12List); + + // Scenario 13 - ["and", ["and", invalidAudienceIdCondition]] which becomes + // the scenario of ["and", "and"] and results in empty string. + AudienceIdCondition invalidAudience = new AudienceIdCondition("5"); + List invalidIdList = new ArrayList<>(); + invalidIdList.add(invalidAudience); + AndCondition andCondition = new AndCondition(invalidIdList); + List andInvalidAudienceId = new ArrayList<>(); + andInvalidAudienceId.add(andCondition); + AndCondition scenario13 = new AndCondition(andInvalidAudienceId); + + + List conditionTestScenarios = new ArrayList<>(); + conditionTestScenarios.add(scenario1); + conditionTestScenarios.add(scenario2); + conditionTestScenarios.add(scenario3); + conditionTestScenarios.add(scenario4); + conditionTestScenarios.add(scenario5); + conditionTestScenarios.add(scenario6); + conditionTestScenarios.add(scenario7); + conditionTestScenarios.add(scenario8); + conditionTestScenarios.add(scenario9); + conditionTestScenarios.add(scenario10); + conditionTestScenarios.add(scenario11); + conditionTestScenarios.add(scenario12); + conditionTestScenarios.add(scenario13); + + return conditionTestScenarios; + } + + private Experiment makeMockExperimentWithStatus(Experiment.ExperimentStatus status, Condition audienceConditions) { + return new Experiment("12345", + "mockExperimentKey", + status.toString(), + "layerId", + Collections.emptyList(), + audienceConditions, + Collections.emptyList(), + Collections.emptyMap(), + Collections.emptyList() + ); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java index 0a6e41ddc..80d4ef9d9 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java @@ -56,6 +56,17 @@ public void initialize() { testTypedUserAttributes.put("null_val", null); } + /** + * Verify that UserAttribute.toJson returns a json represented string of conditions. + */ + @Test + public void userAttributeConditionsToJson() throws Exception { + UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "true", "safari"); + String expectedConditionJsonString = "{\"name\":\"browser_type\", \"type\":\"custom_attribute\", \"match\":\"true\", \"value\":\"safari\"}"; + assertEquals(testInstance.toJson(), expectedConditionJsonString); + } + + /** * Verify that UserAttribute.evaluate returns true on exact-matching visitor attribute data. */ diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyAttributeTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyAttributeTest.java new file mode 100644 index 000000000..904d5e2d7 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyAttributeTest.java @@ -0,0 +1,39 @@ +/**************************************************************************** + * Copyright 2021, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class OptimizelyAttributeTest { + + @Test + public void testOptimizelyAttribute() { + OptimizelyAttribute optimizelyAttribute1 = new OptimizelyAttribute( + "5", + "test_attribute" + ); + OptimizelyAttribute optimizelyAttribute2 = new OptimizelyAttribute( + "5", + "test_attribute" + ); + assertEquals("5", optimizelyAttribute1.getId()); + assertEquals("test_attribute", optimizelyAttribute1.getKey()); + assertEquals(optimizelyAttribute1, optimizelyAttribute2); + assertEquals(optimizelyAttribute1.hashCode(), optimizelyAttribute2.hashCode()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java index 52bc06181..e52436b33 100644 --- a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java @@ -124,7 +124,7 @@ public void testGetFeatureVariableUsageInstanceMap() { @Test public void testGetVariationsMap() { Map optimizelyVariationMap = - optimizelyConfigService.getVariationsMap(projectConfig.getExperiments().get(1).getVariations(), "3262035800"); + optimizelyConfigService.getVariationsMap(projectConfig.getExperiments().get(1).getVariations(), "3262035800", null); assertEquals(expectedConfig.getExperimentsMap().get("multivariate_experiment").getVariationsMap().size(), optimizelyVariationMap.size()); assertEquals(expectedConfig.getExperimentsMap().get("multivariate_experiment").getVariationsMap(), optimizelyVariationMap); } @@ -149,13 +149,30 @@ public void testGenerateFeatureKeyToVariablesMap() { @Test public void testGetMergedVariablesMap() { Variation variation = projectConfig.getExperiments().get(1).getVariations().get(1); - Map optimizelyVariableMap = optimizelyConfigService.getMergedVariablesMap(variation, "3262035800"); + Map optimizelyVariableMap = optimizelyConfigService.getMergedVariablesMap(variation, "3262035800", null); Map expectedOptimizelyVariableMap = expectedConfig.getExperimentsMap().get("multivariate_experiment").getVariationsMap().get("Feorge").getVariablesMap(); assertEquals(expectedOptimizelyVariableMap.size(), optimizelyVariableMap.size()); assertEquals(expectedOptimizelyVariableMap, optimizelyVariableMap); } + @Test + public void testGetAudiencesMap() { + Map actualAudiencesMap = optimizelyConfigService.getAudiencesMap( + asList( + new OptimizelyAudience( + "123456", + "test_audience_1", + "[\"and\", [\"or\", \"1\", \"2\"], \"3\"]" + ) + ) + ); + + Map expectedAudiencesMap = optimizelyConfigService.getAudiencesMap(expectedConfig.getAudiences()); + + assertEquals(expectedAudiencesMap, actualAudiencesMap); + } + private ProjectConfig generateOptimizelyConfig() { return new DatafileProjectConfig( "2360254204", @@ -385,7 +402,8 @@ OptimizelyConfig getExpectedConfig() { }} ) ); - }} + }}, + "" ) ); optimizelyExperimentMap.put( @@ -412,7 +430,8 @@ OptimizelyConfig getExpectedConfig() { Collections.emptyMap() ) ); - }} + }}, + "" ) ); @@ -485,7 +504,8 @@ OptimizelyConfig getExpectedConfig() { }} ) ); - }} + }}, + "" ) ); }}, @@ -508,7 +528,73 @@ OptimizelyConfig getExpectedConfig() { "arry" ) ); - }} + }}, + asList( + new OptimizelyExperiment( + "3262035800", + "multivariate_experiment", + new HashMap() {{ + put( + "Feorge", + new OptimizelyVariation( + "3631049532", + "Feorge", + true, + new HashMap() {{ + put( + "first_letter", + new OptimizelyVariable( + "675244127", + "first_letter", + "string", + "F" + ) + ); + put( + "rest_of_name", + new OptimizelyVariable( + "4052219963", + "rest_of_name", + "string", + "eorge" + ) + ); + }} + ) + ); + put( + "Fred", + new OptimizelyVariation( + "1880281238", + "Fred", + true, + new HashMap() {{ + put( + "first_letter", + new OptimizelyVariable( + "675244127", + "first_letter", + "string", + "F" + ) + ); + put( + "rest_of_name", + new OptimizelyVariable( + "4052219963", + "rest_of_name", + "string", + "red" + ) + ); + }} + ) + ); + }}, + "" + ) + ), + Collections.emptyList() ) ); optimizelyFeatureMap.put( @@ -517,7 +603,9 @@ OptimizelyConfig getExpectedConfig() { "4195505407", "boolean_feature", Collections.emptyMap(), - Collections.emptyMap() + Collections.emptyMap(), + Collections.emptyList(), + Collections.emptyList() ) ); @@ -526,7 +614,37 @@ OptimizelyConfig getExpectedConfig() { optimizelyFeatureMap, "1480511547", "ValidProjectConfigV4", - "production" + "production", + asList( + new OptimizelyAttribute( + "553339214", + "house" + ), + new OptimizelyAttribute( + "58339410", + "nationality" + ) + ), + asList( + new OptimizelyEvent( + "3785620495", + "basic_event", + asList("1323241596", "2738374745", "3042640549", "3262035800", "3072915611") + ), + new OptimizelyEvent( + "3195631717", + "event_with_paused_experiment", + asList("2667098701") + ) + ), + asList( + new OptimizelyAudience( + "123456", + "test_audience_1", + "[\"and\", [\"or\", \"1\", \"2\"], \"3\"]" + ) + ), + null ); } } diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigTest.java index 13b703799..58acadd3f 100644 --- a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigTest.java +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigTest.java @@ -17,6 +17,7 @@ import org.junit.Test; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import static com.optimizely.ab.optimizelyconfig.OptimizelyExperimentTest.generateVariationMap; @@ -32,7 +33,11 @@ public void testOptimizelyConfig() { generateFeatureMap(), "101", "testingSdkKey", - "development" + "development", + null, + null, + null, + null ); assertEquals("101", optimizelyConfig.getRevision()); assertEquals("testingSdkKey", optimizelyConfig.getSdkKey()); @@ -53,12 +58,14 @@ private Map generateExperimentMap() { optimizelyExperimentMap.put("test_exp_1", new OptimizelyExperiment( "33", "test_exp_1", - generateVariationMap() + generateVariationMap(), + "" )); optimizelyExperimentMap.put("test_exp_2", new OptimizelyExperiment( "34", "test_exp_2", - generateVariationMap() + generateVariationMap(), + "" )); return optimizelyExperimentMap; } @@ -69,7 +76,9 @@ private Map generateFeatureMap() { "42", "test_feature_1", generateExperimentMap(), - generateVariablesMap() + generateVariablesMap(), + Collections.emptyList(), + Collections.emptyList() )); return optimizelyFeatureMap; } diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyEventTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyEventTest.java new file mode 100644 index 000000000..5bd5d9a4c --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyEventTest.java @@ -0,0 +1,40 @@ +/**************************************************************************** + * Copyright 2020-2021, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static java.util.Arrays.asList; + +public class OptimizelyEventTest { + @Test + public void testOptimizelyEvent() { + OptimizelyEvent optimizelyEvent1 = new OptimizelyEvent( + "5", + "test_event", + asList("123","234","345") + ); + OptimizelyEvent optimizelyEvent2 = new OptimizelyEvent( + "5", + "test_event", + asList("123","234","345") + ); + assertEquals("5", optimizelyEvent1.getId()); + assertEquals("test_event", optimizelyEvent1.getKey()); + assertEquals(optimizelyEvent1, optimizelyEvent2); + assertEquals(optimizelyEvent1.hashCode(), optimizelyEvent2.hashCode()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyExperimentTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyExperimentTest.java index ec7cebb79..954a90f29 100644 --- a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyExperimentTest.java +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyExperimentTest.java @@ -29,7 +29,8 @@ public void testOptimizelyExperiment() { OptimizelyExperiment optimizelyExperiment = new OptimizelyExperiment( "31", "test_exp", - generateVariationMap() + generateVariationMap(), + "" ); assertEquals("31", optimizelyExperiment.getId()); assertEquals("test_exp", optimizelyExperiment.getKey()); diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeatureTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeatureTest.java index 732266a98..a6789311b 100644 --- a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeatureTest.java +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeatureTest.java @@ -17,6 +17,7 @@ import org.junit.Test; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import static com.optimizely.ab.optimizelyconfig.OptimizelyVariationTest.generateVariablesMap; @@ -31,7 +32,9 @@ public void testOptimizelyFeature() { "41", "test_feature", generateExperimentMap(), - generateVariablesMap() + generateVariablesMap(), + Collections.emptyList(), + Collections.emptyList() ); assertEquals("41", optimizelyFeature.getId()); assertEquals("test_feature", optimizelyFeature.getKey()); @@ -50,12 +53,14 @@ static Map generateExperimentMap() { optimizelyExperimentMap.put("test_exp_1", new OptimizelyExperiment ( "32", "test_exp_1", - generateVariationMap() + generateVariationMap(), + "" )); optimizelyExperimentMap.put("test_exp_2", new OptimizelyExperiment ( "33", "test_exp_2", - generateVariationMap() + generateVariationMap(), + "" )); return optimizelyExperimentMap; } From 803103f73b1f55a31e2c5a86044f6545352f7da8 Mon Sep 17 00:00:00 2001 From: Jake Brown Date: Mon, 16 Aug 2021 14:25:34 -0400 Subject: [PATCH 054/147] Update deprecated warning to OptimizelyFeature, getExperimentsMap(). (#440) ## Summary - Addition of deprecated warning for OptimizelyFeature.getExperimentsMap() This function and member of OptimizelyFeature should no longer be used. ExperimentRules and DeliveryRules including their getters are to be used in its place. ## Test plan - FSC and IDE verification of Deprecation ## Issues - N/A --- .../com/optimizely/ab/optimizelyconfig/OptimizelyFeature.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeature.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeature.java index 1f8359e62..77a1f67fd 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeature.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeature.java @@ -61,6 +61,10 @@ public String getKey() { return key; } + /** + * @deprecated use {@link #getExperimentRules()} and {@link #getDeliveryRules()} instead + */ + @Deprecated public Map getExperimentsMap() { return experimentsMap; } From 70a42c6659b51e95760f1532fe272abe6fde1ea5 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Fri, 20 Aug 2021 10:49:46 -0700 Subject: [PATCH 055/147] chore: add snapshot publish to travis (#442) --- .travis.yml | 7 +++++++ build.gradle | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 93c025e23..2735eacaf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -38,6 +38,7 @@ stages: - 'Full stack production tests' - 'Test' - 'Publish' + - 'Snapshot' jobs: include: @@ -83,3 +84,9 @@ jobs: script: - ./gradlew ship after_success: skip + + - stage: 'Snapshot' + if: env(SNAPSHOT) = true and type = api + script: + - TRAVIS_TAG=BB-SNAPSHOT ./gradlew ship + after_success: skip diff --git a/build.gradle b/build.gradle index 31608bcbf..e0e14fa9d 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,8 @@ allprojects { if (travis_defined_version != null) { version = travis_defined_version } + + ext.isReleaseVersion = !version.endsWith("SNAPSHOT") } def publishedProjects = subprojects.findAll { it.name != 'java-quickstart' } @@ -127,7 +129,9 @@ configure(publishedProjects) { } repositories { maven { - url "https://oss.sonatype.org/service/local/staging/deploy/maven2" + def releaseUrl = "https://oss.sonatype.org/service/local/staging/deploy/maven2" + def snapshotUrl = "https://oss.sonatype.org/content/repositories/snapshots" + url = isReleaseVersion ? releaseUrl : snapshotUrl credentials { username System.getenv('MAVEN_CENTRAL_USERNAME') password System.getenv('MAVEN_CENTRAL_PASSWORD') From 5de0a7b132b49bd5a94ffd99017f81e643960496 Mon Sep 17 00:00:00 2001 From: Jake Brown Date: Mon, 23 Aug 2021 12:12:53 -0400 Subject: [PATCH 056/147] Update conditions to use StringBuilder instead of StringJoiner (#443) - Changing StringJoiner to StringBuilder for compatibility with Android - Additional test cases for coverage and to verify json format of condition string --- .../ab/config/audience/AndCondition.java | 9 ++-- .../ab/config/audience/NotCondition.java | 7 +-- .../ab/config/audience/OrCondition.java | 9 ++-- .../AudienceConditionEvaluationTest.java | 51 +++++++++++++++++++ 4 files changed, 67 insertions(+), 9 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java index dca3cefc8..f6561a65c 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java @@ -76,11 +76,14 @@ public String getOperandOrId() { @Override public String toJson() { - StringJoiner s = new StringJoiner(", ", "[", "]"); - s.add("\"and\""); + StringBuilder s = new StringBuilder(); + s.append("[\"and\", "); for (int i = 0; i < conditions.size(); i++) { - s.add(conditions.get(i).toJson()); + s.append(conditions.get(i).toJson()); + if (i < conditions.size() - 1) + s.append(", "); } + s.append("]"); return s.toString(); } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java index 8c17b4ef2..cabc07812 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java @@ -56,9 +56,10 @@ public String getOperandOrId() { @Override public String toJson() { - StringJoiner s = new StringJoiner(", ","[","]"); - s.add("\"not\""); - s.add(condition.toJson()); + StringBuilder s = new StringBuilder(); + s.append("[\"not\", "); + s.append(condition.toJson()); + s.append("]"); return s.toString(); } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java index 71c8c9e76..293687f66 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java @@ -74,11 +74,14 @@ public String getOperandOrId() { @Override public String toJson() { - StringJoiner s = new StringJoiner(", ", "[", "]"); - s.add("\"or\""); + StringBuilder s = new StringBuilder(); + s.append("[\"or\", "); for (int i = 0; i < conditions.size(); i++) { - s.add(conditions.get(i).toJson()); + s.append(conditions.get(i).toJson()); + if (i < conditions.size() - 1) + s.append(", "); } + s.append("]"); return s.toString(); } diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java index 80d4ef9d9..481be0f23 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java @@ -22,6 +22,7 @@ import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.mockito.internal.matchers.Or; import java.math.BigInteger; import java.util.*; @@ -56,6 +57,21 @@ public void initialize() { testTypedUserAttributes.put("null_val", null); } + @Test + public void nullConditionTest() throws Exception { + NullCondition nullCondition = new NullCondition(); + assertEquals(null, nullCondition.toJson()); + assertEquals(null, nullCondition.getOperandOrId()); + } + + @Test + public void emptyConditionTest() throws Exception { + EmptyCondition emptyCondition = new EmptyCondition(); + assertEquals(null, emptyCondition.toJson()); + assertEquals(null, emptyCondition.getOperandOrId()); + assertEquals(true, emptyCondition.evaluate(null, null)); + } + /** * Verify that UserAttribute.toJson returns a json represented string of conditions. */ @@ -66,6 +82,41 @@ public void userAttributeConditionsToJson() throws Exception { assertEquals(testInstance.toJson(), expectedConditionJsonString); } + /** + * Verify that AndCondition.toJson returns a json represented string of conditions. + */ + @Test + public void andConditionsToJsonWithComma() throws Exception { + UserAttribute testInstance1 = new UserAttribute("browser_type", "custom_attribute", "true", "safari"); + UserAttribute testInstance2 = new UserAttribute("browser_type", "custom_attribute", "true", "safari"); + String expectedConditionJsonString = "[\"and\", [\"or\", {\"name\":\"browser_type\", \"type\":\"custom_attribute\", \"match\":\"true\", \"value\":\"safari\"}, {\"name\":\"browser_type\", \"type\":\"custom_attribute\", \"match\":\"true\", \"value\":\"safari\"}]]"; + List userConditions = new ArrayList<>(); + userConditions.add(testInstance1); + userConditions.add(testInstance2); + OrCondition orCondition = new OrCondition(userConditions); + List orConditions = new ArrayList<>(); + orConditions.add(orCondition); + AndCondition andCondition = new AndCondition(orConditions); + assertEquals(andCondition.toJson(), expectedConditionJsonString); + } + + /** + * Verify that orCondition.toJson returns a json represented string of conditions. + */ + @Test + public void orConditionsToJsonWithComma() throws Exception { + UserAttribute testInstance1 = new UserAttribute("browser_type", "custom_attribute", "true", "safari"); + UserAttribute testInstance2 = new UserAttribute("browser_type", "custom_attribute", "true", "safari"); + String expectedConditionJsonString = "[\"or\", [\"and\", {\"name\":\"browser_type\", \"type\":\"custom_attribute\", \"match\":\"true\", \"value\":\"safari\"}, {\"name\":\"browser_type\", \"type\":\"custom_attribute\", \"match\":\"true\", \"value\":\"safari\"}]]"; + List userConditions = new ArrayList<>(); + userConditions.add(testInstance1); + userConditions.add(testInstance2); + AndCondition andCondition = new AndCondition(userConditions); + List andConditions = new ArrayList<>(); + andConditions.add(andCondition); + OrCondition orCondition = new OrCondition(andConditions); + assertEquals(orCondition.toJson(), expectedConditionJsonString); + } /** * Verify that UserAttribute.evaluate returns true on exact-matching visitor attribute data. From 6107eb9667fa776864e3cb44ccce5b3be1d297f4 Mon Sep 17 00:00:00 2001 From: Jake Brown Date: Wed, 25 Aug 2021 13:43:54 -0400 Subject: [PATCH 057/147] Create experimentRules array directly instead of from Map Values (#444) --- .../OptimizelyConfigService.java | 16 ++++++++++------ .../OptimizelyConfigServiceTest.java | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java index 60aef9ce3..8937d8572 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java @@ -26,6 +26,7 @@ public class OptimizelyConfigService { private ProjectConfig projectConfig; private OptimizelyConfig optimizelyConfig; private List audiences; + private List experimentRules; private Map audiencesMap; private Map> featureIdToVariablesMap = new HashMap<>(); private Map experimentMapByExperimentId = new HashMap<>(); @@ -221,10 +222,8 @@ Map getFeaturesMap(Map Map optimizelyFeatureKeyMap = new HashMap<>(); for (FeatureFlag featureFlag : featureFlags) { Map experimentsMapForFeature = - getExperimentsMapForFeature(featureFlag.getExperimentIds(), allExperimentsMap); + getExperimentsMapForFeature(featureFlag.getExperimentIds()); - List experimentRules = - new ArrayList(experimentsMapForFeature.values()); List deliveryRules = this.getDeliveryRules(featureFlag.getRolloutId(), featureFlag.getId()); @@ -267,17 +266,22 @@ List getDeliveryRules(String rolloutId, String featureId) } @VisibleForTesting - Map getExperimentsMapForFeature(List experimentIds, Map allExperimentsMap) { + Map getExperimentsMapForFeature(List experimentIds) { if (experimentIds == null) { return Collections.emptyMap(); } + List experimentRulesList = new ArrayList<>(); + Map optimizelyExperimentKeyMap = new HashMap<>(); for (String experimentId : experimentIds) { - Experiment experiment = projectConfig.getExperimentIdMapping().get(experimentId); - optimizelyExperimentKeyMap.put(experiment.getKey(), experimentMapByExperimentId.get(experiment.getId())); + OptimizelyExperiment optimizelyExperiment = experimentMapByExperimentId.get(experimentId); + optimizelyExperimentKeyMap.put(optimizelyExperiment.getKey(), optimizelyExperiment); + experimentRulesList.add(optimizelyExperiment); } + this.experimentRules = experimentRulesList; + return optimizelyExperimentKeyMap; } diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java index e52436b33..426422ea3 100644 --- a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java @@ -87,7 +87,7 @@ public void testGetFeatureVariablesMap() { public void testGetExperimentsMapForFeature() { List experimentIds = projectConfig.getFeatureFlags().get(1).getExperimentIds(); Map optimizelyFeatureExperimentMap = - optimizelyConfigService.getExperimentsMapForFeature(experimentIds, optimizelyConfigService.getExperimentsMap()); + optimizelyConfigService.getExperimentsMapForFeature(experimentIds); assertEquals(expectedConfig.getFeaturesMap().get("multi_variate_feature").getExperimentsMap().size(), optimizelyFeatureExperimentMap.size()); } From 674caded4c87a2f15bf4ee05779cfba9a2329bdd Mon Sep 17 00:00:00 2001 From: Jake Brown Date: Thu, 26 Aug 2021 14:57:54 -0400 Subject: [PATCH 058/147] Add support for custom CloseableHttpClient for Proxy Support (#441) ## Summary - Adding additional newDefaultInstance method to support custom CloseableHttpClient object - Allows for customs object to support Proxy Settings Customer support ## Test plan - FSC and TestCase added ## Issues - "OASIS-7899" --- .../com/optimizely/ab/OptimizelyFactory.java | 20 ++++++++++++++-- .../optimizely/ab/OptimizelyFactoryTest.java | 24 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java index 5d267d41e..2e888e9bb 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java @@ -23,6 +23,7 @@ import com.optimizely.ab.event.EventHandler; import com.optimizely.ab.internal.PropertyUtils; import com.optimizely.ab.notification.NotificationCenter; +import org.apache.http.impl.client.CloseableHttpClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -247,11 +248,26 @@ public static Optimizely newDefaultInstance(String sdkKey, String fallback) { * @return A new Optimizely instance */ public static Optimizely newDefaultInstance(String sdkKey, String fallback, String datafileAccessToken) { + return newDefaultInstance(sdkKey, fallback, datafileAccessToken, null); + } + + /** + * Returns a new Optimizely instance with authenticated datafile support. + * + * @param sdkKey SDK key used to build the ProjectConfigManager. + * @param fallback Fallback datafile string used by the ProjectConfigManager to be immediately available. + * @param datafileAccessToken Token for authenticated datafile access. + * @param customHttpClient Customizable CloseableHttpClient to build OptimizelyHttpClient. + * @return A new Optimizely instance + */ + public static Optimizely newDefaultInstance(String sdkKey, String fallback, String datafileAccessToken, CloseableHttpClient customHttpClient) { NotificationCenter notificationCenter = new NotificationCenter(); - - HttpProjectConfigManager.Builder builder = HttpProjectConfigManager.builder() + OptimizelyHttpClient optimizelyHttpClient = new OptimizelyHttpClient(customHttpClient); + HttpProjectConfigManager.Builder builder; + builder = HttpProjectConfigManager.builder() .withDatafile(fallback) .withNotificationCenter(notificationCenter) + .withOptimizelyHttpClient(customHttpClient == null ? null : optimizelyHttpClient) .withSdkKey(sdkKey); if (datafileAccessToken != null) { diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java index b6d006173..07c2c0634 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java @@ -23,6 +23,12 @@ import com.optimizely.ab.event.BatchEventProcessor; import com.optimizely.ab.internal.PropertyUtils; import com.optimizely.ab.notification.NotificationCenter; +import org.apache.http.HttpHost; +import org.apache.http.conn.routing.HttpRoutePlanner; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.impl.conn.DefaultProxyRoutePlanner; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -237,6 +243,24 @@ public void newDefaultInstanceWithDatafileAccessToken() throws Exception { assertTrue(optimizely.isValid()); } + @Test + public void newDefaultInstanceWithDatafileAccessTokenAndCustomHttpClient() throws Exception { + // Add custom Proxy and Port here + int port = 443; + String proxyHostName = "someProxy.com"; + HttpHost proxyHost = new HttpHost(proxyHostName, port); + + HttpRoutePlanner routePlanner = new DefaultProxyRoutePlanner(proxyHost); + + HttpClientBuilder clientBuilder = HttpClients.custom(); + clientBuilder = clientBuilder.setRoutePlanner(routePlanner); + + CloseableHttpClient httpClient = clientBuilder.build(); + String datafileString = Resources.toString(Resources.getResource("valid-project-config-v4.json"), Charsets.UTF_8); + optimizely = OptimizelyFactory.newDefaultInstance("sdk-key", datafileString, "auth-token", httpClient); + assertTrue(optimizely.isValid()); + } + @Test public void newDefaultInstanceWithProjectConfig() throws Exception { optimizely = OptimizelyFactory.newDefaultInstance(() -> null); From b30bbb80a35226eada27f73d16f2a625df3ed2b3 Mon Sep 17 00:00:00 2001 From: Jake Brown Date: Thu, 16 Sep 2021 13:22:39 -0400 Subject: [PATCH 059/147] chore: Prepare for 3.9.0 Release (#446) ## Summary - Prepare for 3.9.0 release --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61ece7a79..c878c888e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Optimizely Java X SDK Changelog +## [3.9.0] +September 16th, 2021 + +### New Features +* Added new public properties to OptimizelyConfig. [#434] (https://github.com/optimizely/java-sdk/pull/434), [#438] (https://github.com/optimizely/java-sdk/pull/438) + - sdkKey + - environmentKey + - attributes + - events + - audiences and audiences in OptimizelyExperiment + - experimentRules + - deliveryRules +* OptimizelyFeature.experimentsMap of OptimizelyConfig is now deprecated. Please use OptimizelyFeature.experiment_rules and OptimizelyFeature.delivery_rules. [#440] (https://github.com/optimizely/java-sdk/pull/440) +* For more information please refer to Optimizely documentation: [https://docs.developers.optimizely.com/full-stack/docs/optimizelyconfig-java] + +* Added custom closeableHttpClient for custom proxy support. [#441] (https://github.com/optimizely/java-sdk/pull/441) + ## [3.8.2] March 8th, 2021 From 629744565f6136510e4e06b9b30391db357e8b85 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Mon, 4 Oct 2021 10:24:01 -0700 Subject: [PATCH 060/147] chore: clean up datafile version log message (#447) --- .../optimizely/ab/config/PollingProjectConfigManager.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java b/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java index 0208d2986..b03aeabc0 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java +++ b/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java @@ -100,7 +100,11 @@ void setConfig(ProjectConfig projectConfig) { return; } - logger.info("New datafile set with revision: {}. Old revision: {}", projectConfig.getRevision(), previousRevision); + if (oldProjectConfig == null) { + logger.info("New datafile set with revision: {}.", projectConfig.getRevision()); + } else { + logger.info("New datafile set with revision: {}. Old revision: {}", projectConfig.getRevision(), previousRevision); + } currentProjectConfig.set(projectConfig); currentOptimizelyConfig.set(new OptimizelyConfigService(projectConfig).getConfig()); From e25985f373b870e57bd27998c1d78c5ecb5c422d Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Mon, 4 Oct 2021 10:48:38 -0700 Subject: [PATCH 061/147] add a missing bug-fix note (#448) --- CHANGELOG.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c878c888e..b88169576 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,13 @@ March 8th, 2021 ## [3.8.1] March 2nd, 2021 -- Switch publish repository to MavenCentral (bintray/jcenter sunset) -- Fix javadoc warnings ([#426](https://github.com/optimizely/java-sdk/pull/426)) +Switch publish repository to MavenCentral (bintray/jcenter sunset) +### Fixes +- Fix app crashing when the rollout length is zero ([#423](https://github.com/optimizely/java-sdk/pull/423)). +- Fix javadoc warnings ([#426](https://github.com/optimizely/java-sdk/pull/426)). + + ## [3.8.0] February 3rd, 2021 From 04d379a2f7c650b424ffff04e172ca2c5c2b6e36 Mon Sep 17 00:00:00 2001 From: Jake Brown Date: Thu, 4 Nov 2021 09:44:09 -0400 Subject: [PATCH 062/147] feat(ForcedDecisions): add forced-decisions APIs to OptimizelyUserContext (#451) ## Summary Add a set of new APIs for forced-decisions to OptimizelyUserContext: - setForcedDecision - getForcedDecision - removeForcedDecision - removeAllForcedDecisions ## Test plan - unit tests for the new APIs - FSC tests with new test cases --- .../java/com/optimizely/ab/Optimizely.java | 66 ++- .../ab/OptimizelyDecisionContext.java | 49 ++ .../ab/OptimizelyForcedDecision.java | 31 ++ .../optimizely/ab/OptimizelyUserContext.java | 156 +++++- .../ab/bucketing/DecisionService.java | 280 +++++++--- .../ab/config/DatafileProjectConfig.java | 44 +- .../optimizely/ab/config/ProjectConfig.java | 2 + .../ab/event/internal/UserEventFactory.java | 6 +- .../ab/OptimizelyDecisionContextTest.java | 44 ++ .../ab/OptimizelyForcedDecisionTest.java | 30 + .../com/optimizely/ab/OptimizelyTest.java | 59 +- .../ab/OptimizelyUserContextTest.java | 520 ++++++++++++++++++ .../ab/bucketing/DecisionServiceTest.java | 234 ++++---- .../ab/config/DatafileProjectConfigTest.java | 23 +- 14 files changed, 1287 insertions(+), 257 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/OptimizelyDecisionContext.java create mode 100644 core-api/src/main/java/com/optimizely/ab/OptimizelyForcedDecision.java create mode 100644 core-api/src/test/java/com/optimizely/ab/OptimizelyDecisionContextTest.java create mode 100644 core-api/src/test/java/com/optimizely/ab/OptimizelyForcedDecisionTest.java diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 8a7034d0e..c53095692 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -376,7 +376,7 @@ public void track(@Nonnull String eventName, @Nonnull public Boolean isFeatureEnabled(@Nonnull String featureKey, @Nonnull String userId) { - return isFeatureEnabled(featureKey, userId, Collections.emptyMap()); + return isFeatureEnabled(featureKey, userId, Collections.emptyMap()); } /** @@ -424,7 +424,7 @@ private Boolean isFeatureEnabled(@Nonnull ProjectConfig projectConfig, Map copiedAttributes = copyAttributes(attributes); FeatureDecision.DecisionSource decisionSource = FeatureDecision.DecisionSource.ROLLOUT; - FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, copiedAttributes, projectConfig).getResult(); + FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, createUserContext(userId, copiedAttributes), projectConfig).getResult(); Boolean featureEnabled = false; SourceInfo sourceInfo = new RolloutSourceInfo(); if (featureDecision.decisionSource != null) { @@ -733,7 +733,7 @@ T getFeatureVariableValueForType(@Nonnull String featureKey, String variableValue = variable.getDefaultValue(); Map copiedAttributes = copyAttributes(attributes); - FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, copiedAttributes, projectConfig).getResult(); + FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, createUserContext(userId, copiedAttributes), projectConfig).getResult(); Boolean featureEnabled = false; if (featureDecision.variation != null) { if (featureDecision.variation.getFeatureEnabled()) { @@ -824,6 +824,7 @@ Object convertStringToType(String variableValue, String type) { * @param userId The ID of the user. * @return An OptimizelyJSON instance for all variable values. * Null if the feature could not be found. + * */ @Nullable public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, @@ -839,6 +840,7 @@ public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, * @param attributes The user's attributes. * @return An OptimizelyJSON instance for all variable values. * Null if the feature could not be found. + * */ @Nullable public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, @@ -866,7 +868,7 @@ public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, } Map copiedAttributes = copyAttributes(attributes); - FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, copiedAttributes, projectConfig).getResult(); + FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, createUserContext(userId, copiedAttributes), projectConfig, Collections.emptyList()).getResult(); Boolean featureEnabled = false; Variation variation = featureDecision.variation; @@ -922,9 +924,10 @@ public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, * @param attributes The user's attributes. * @return List of the feature keys that are enabled for the user if the userId is empty it will * return Empty List. + * */ public List getEnabledFeatures(@Nonnull String userId, @Nonnull Map attributes) { - List enabledFeaturesList = new ArrayList(); + List enabledFeaturesList = new ArrayList(); if (!validateUserId(userId)) { return enabledFeaturesList; } @@ -951,7 +954,7 @@ public List getEnabledFeatures(@Nonnull String userId, @Nonnull MapemptyMap()); + return getVariation(experiment, userId, Collections.emptyMap()); } @Nullable @@ -967,8 +970,7 @@ private Variation getVariation(@Nonnull ProjectConfig projectConfig, @Nonnull String userId, @Nonnull Map attributes) throws UnknownExperimentException { Map copiedAttributes = copyAttributes(attributes); - Variation variation = decisionService.getVariation(experiment, userId, copiedAttributes, projectConfig).getResult(); - + Variation variation = decisionService.getVariation(experiment, createUserContext(userId, copiedAttributes), projectConfig).getResult(); String notificationType = NotificationCenter.DecisionNotificationType.AB_TEST.toString(); if (projectConfig.getExperimentFeatureKeyMapping().get(experiment.getId()) != null) { @@ -1145,7 +1147,7 @@ public OptimizelyConfig getOptimizelyConfig() { * @return An OptimizelyUserContext associated with this OptimizelyClient. */ public OptimizelyUserContext createUserContext(@Nonnull String userId, - @Nonnull Map attributes) { + @Nonnull Map attributes) { if (userId == null) { logger.warn("The userId parameter must be nonnull."); return null; @@ -1179,14 +1181,24 @@ OptimizelyDecision decide(@Nonnull OptimizelyUserContext user, DecisionReasons decisionReasons = DefaultDecisionReasons.newInstance(allOptions); Map copiedAttributes = new HashMap<>(attributes); - DecisionResponse decisionVariation = decisionService.getVariationForFeature( - flag, - userId, - copiedAttributes, - projectConfig, - allOptions); - FeatureDecision flagDecision = decisionVariation.getResult(); - decisionReasons.merge(decisionVariation.getReasons()); + FeatureDecision flagDecision; + + // Check Forced Decision + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flag.getKey(), null); + DecisionResponse forcedDecisionVariation = user.findValidatedForcedDecision(optimizelyDecisionContext); + decisionReasons.merge(forcedDecisionVariation.getReasons()); + if (forcedDecisionVariation.getResult() != null) { + flagDecision = new FeatureDecision(null, forcedDecisionVariation.getResult(), FeatureDecision.DecisionSource.FEATURE_TEST); + } else { + // Regular decision + DecisionResponse decisionVariation = decisionService.getVariationForFeature( + flag, + user, + projectConfig, + allOptions); + flagDecision = decisionVariation.getResult(); + decisionReasons.merge(decisionVariation.getReasons()); + } Boolean flagEnabled = false; if (flagDecision.variation != null) { @@ -1332,6 +1344,26 @@ private DecisionResponse> getDecisionVariableMap(@Nonnull Fe return new DecisionResponse(valuesMap, reasons); } + /** + * Gets a variation based on flagKey and variationKey + * + * @param flagKey The flag key for the variation + * @param variationKey The variation key for the variation + * @return Returns a variation based on flagKey and variationKey, otherwise null + */ + public Variation getFlagVariationByKey(String flagKey, String variationKey) { + Map> flagVariationsMap = getProjectConfig().getFlagVariationsMap(); + if (flagVariationsMap.containsKey(flagKey)) { + List variations = flagVariationsMap.get(flagKey); + for (Variation variation : variations) { + if (variation.getKey().equals(variationKey)) { + return variation; + } + } + } + return null; + } + /** * Helper method which makes separate copy of attributesMap variable and returns it * diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyDecisionContext.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyDecisionContext.java new file mode 100644 index 000000000..4c4159301 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyDecisionContext.java @@ -0,0 +1,49 @@ +/** + * + * Copyright 2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public class OptimizelyDecisionContext { + public static final String OPTI_NULL_RULE_KEY = "$opt-null-rule-key"; + public static final String OPTI_KEY_DIVIDER = "-$opt$-"; + + private String flagKey; + private String ruleKey; + + public OptimizelyDecisionContext(@Nonnull String flagKey, @Nullable String ruleKey) { + this.flagKey = flagKey; + this.ruleKey = ruleKey; + } + + public String getFlagKey() { + return flagKey; + } + + public String getRuleKey() { + return ruleKey != null ? ruleKey : OPTI_NULL_RULE_KEY; + } + + public String getKey() { + StringBuilder keyBuilder = new StringBuilder(); + keyBuilder.append(flagKey); + keyBuilder.append(OPTI_KEY_DIVIDER); + keyBuilder.append(getRuleKey()); + return keyBuilder.toString(); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyForcedDecision.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyForcedDecision.java new file mode 100644 index 000000000..d73a86c83 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyForcedDecision.java @@ -0,0 +1,31 @@ +/** + * + * Copyright 2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import javax.annotation.Nonnull; + +public class OptimizelyForcedDecision { + private String variationKey; + + public OptimizelyForcedDecision(@Nonnull String variationKey) { + this.variationKey = variationKey; + } + + public String getVariationKey() { + return variationKey; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java index f9cff6f44..0a785c550 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -16,19 +16,20 @@ */ package com.optimizely.ab; -import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; -import com.optimizely.ab.optimizelydecision.OptimizelyDecision; +import com.optimizely.ab.config.Variation; +import com.optimizely.ab.optimizelydecision.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; import javax.annotation.Nullable; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; public class OptimizelyUserContext { + // OptimizelyForcedDecisionsKey mapped to variationKeys + Map forcedDecisionsMap; + @Nonnull private final String userId; @@ -42,7 +43,20 @@ public class OptimizelyUserContext { public OptimizelyUserContext(@Nonnull Optimizely optimizely, @Nonnull String userId, - @Nonnull Map attributes) { + @Nonnull Map attributes) { + this.optimizely = optimizely; + this.userId = userId; + if (attributes != null) { + this.attributes = Collections.synchronizedMap(new HashMap<>(attributes)); + } else { + this.attributes = Collections.synchronizedMap(new HashMap<>()); + } + } + + public OptimizelyUserContext(@Nonnull Optimizely optimizely, + @Nonnull String userId, + @Nonnull Map attributes, + @Nullable Map forcedDecisionsMap) { this.optimizely = optimizely; this.userId = userId; if (attributes != null) { @@ -50,6 +64,9 @@ public OptimizelyUserContext(@Nonnull Optimizely optimizely, } else { this.attributes = Collections.synchronizedMap(new HashMap<>()); } + if (forcedDecisionsMap != null) { + this.forcedDecisionsMap = new ConcurrentHashMap<>(forcedDecisionsMap); + } } public OptimizelyUserContext(@Nonnull Optimizely optimizely, @Nonnull String userId) { @@ -69,7 +86,7 @@ public Optimizely getOptimizely() { } public OptimizelyUserContext copy() { - return new OptimizelyUserContext(optimizely, userId, attributes); + return new OptimizelyUserContext(optimizely, userId, attributes, forcedDecisionsMap); } /** @@ -172,6 +189,129 @@ public void trackEvent(@Nonnull String eventName) throws UnknownEventTypeExcepti trackEvent(eventName, Collections.emptyMap()); } + /** + * Set a forced decision + * + * @param optimizelyDecisionContext The OptimizelyDecisionContext containing flagKey and ruleKey + * @param optimizelyForcedDecision The OptimizelyForcedDecision containing the variationKey + * @return Returns a boolean, Ture if successfully set, otherwise false + */ + public Boolean setForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDecisionContext, + @Nonnull OptimizelyForcedDecision optimizelyForcedDecision) { + if (optimizely.getOptimizelyConfig() == null) { + logger.error("Optimizely SDK not ready."); + return false; + } + // Check if the forcedDecisionsMap has been initialized yet or not + if (forcedDecisionsMap == null ){ + // Thread-safe implementation of HashMap + forcedDecisionsMap = new ConcurrentHashMap<>(); + } + forcedDecisionsMap.put(optimizelyDecisionContext.getKey(), optimizelyForcedDecision); + return true; + } + + /** + * Get a forced decision + * + * @param optimizelyDecisionContext The OptimizelyDecisionContext containing flagKey and ruleKey + * @return Returns a variationKey for a given forced decision + */ + @Nullable + public OptimizelyForcedDecision getForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDecisionContext) { + if (optimizely.getOptimizelyConfig() == null) { + logger.error("Optimizely SDK not ready."); + return null; + } + return findForcedDecision(optimizelyDecisionContext); + } + + /** + * Finds a forced decision + * + * @param optimizelyDecisionContext The OptimizelyDecisionContext containing flagKey and ruleKey + * @return Returns a variationKey relating to the found forced decision, otherwise null + */ + @Nullable + public OptimizelyForcedDecision findForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDecisionContext) { + if (forcedDecisionsMap != null) { + return forcedDecisionsMap.get(optimizelyDecisionContext.getKey()); + } + return null; + } + + /** + * Remove a forced decision + * + * @param optimizelyDecisionContext The OptimizelyDecisionContext containing flagKey and ruleKey + * @return Returns a boolean, true if successfully removed, otherwise false + */ + public boolean removeForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDecisionContext) { + if (optimizely.getOptimizelyConfig() == null) { + logger.error("Optimizely SDK not ready."); + return false; + } + + try { + if (forcedDecisionsMap != null) { + if (forcedDecisionsMap.remove(optimizelyDecisionContext.getKey()) != null) { + return true; + } + } + } catch (Exception e) { + logger.error("Unable to remove forced-decision - " + e); + } + + return false; + } + + /** + * Remove all forced decisions + * + * @return Returns a boolean, True if successfully, otherwise false + */ + public boolean removeAllForcedDecisions() { + if (optimizely.getProjectConfig() == null) { + logger.error("Optimizely SDK not ready."); + return false; + } + // Clear both maps for with and without ruleKey + if (forcedDecisionsMap != null) { + forcedDecisionsMap.clear(); + } + return true; + } + + /** + * Find a validated forced decision + * + * @param optimizelyDecisionContext The OptimizelyDecisionContext containing flagKey and ruleKey + * @return Returns a DecisionResponse structure of type Variation, otherwise null result with reasons + */ + public DecisionResponse findValidatedForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDecisionContext) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + OptimizelyForcedDecision optimizelyForcedDecision = findForcedDecision(optimizelyDecisionContext); + String variationKey = optimizelyForcedDecision != null ? optimizelyForcedDecision.getVariationKey() : null; + if (variationKey != null) { + Variation variation = optimizely.getFlagVariationByKey(optimizelyDecisionContext.getFlagKey(), variationKey); + String ruleKey = optimizelyDecisionContext.getRuleKey(); + String flagKey = optimizelyDecisionContext.getFlagKey(); + String info; + String target = ruleKey != OptimizelyDecisionContext.OPTI_NULL_RULE_KEY ? String.format("flag (%s), rule (%s)", flagKey, ruleKey) : String.format("flag (%s)", flagKey); + if (variation != null) { + info = String.format("Variation (%s) is mapped to %s and user (%s) in the forced decision map.", variationKey, target, userId); + logger.debug(info); + reasons.addInfo(info); + return new DecisionResponse(variation, reasons); + } else { + info = String.format("Invalid variation is mapped to %s and user (%s) in the forced decision map.", target, userId); + logger.debug(info); + reasons.addInfo(info); + } + } + return new DecisionResponse<>(null, reasons); + } + // Utils @Override diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java index c6a267f5b..e386c360d 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java @@ -15,7 +15,9 @@ ***************************************************************************/ package com.optimizely.ab.bucketing; +import com.optimizely.ab.OptimizelyDecisionContext; import com.optimizely.ab.OptimizelyRuntimeException; +import com.optimizely.ab.OptimizelyUserContext; import com.optimizely.ab.config.*; import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.internal.ControlAttribute; @@ -26,13 +28,9 @@ import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import javax.annotation.Nonnull; import javax.annotation.Nullable; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.EXPERIMENT; @@ -83,16 +81,14 @@ public DecisionService(@Nonnull Bucketer bucketer, * Get a {@link Variation} of an {@link Experiment} for a user to be allocated into. * * @param experiment The Experiment the user will be bucketed into. - * @param userId The userId of the user. - * @param filteredAttributes The user's attributes. This should be filtered to just attributes in the Datafile. + * @param user The current OptimizelyUserContext * @param projectConfig The current projectConfig * @param options An array of decision options * @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) and the decision reasons */ @Nonnull public DecisionResponse getVariation(@Nonnull Experiment experiment, - @Nonnull String userId, - @Nonnull Map filteredAttributes, + @Nonnull OptimizelyUserContext user, @Nonnull ProjectConfig projectConfig, @Nonnull List options) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); @@ -104,13 +100,13 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, } // look for forced bucketing first. - DecisionResponse decisionVariation = getForcedVariation(experiment, userId); + DecisionResponse decisionVariation = getForcedVariation(experiment, user.getUserId()); reasons.merge(decisionVariation.getReasons()); Variation variation = decisionVariation.getResult(); // check for whitelisting if (variation == null) { - decisionVariation = getWhitelistedVariation(experiment, userId); + decisionVariation = getWhitelistedVariation(experiment, user.getUserId()); reasons.merge(decisionVariation.getReasons()); variation = decisionVariation.getResult(); } @@ -125,7 +121,7 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, if (userProfileService != null && !ignoreUPS) { try { - Map userProfileMap = userProfileService.lookup(userId); + Map userProfileMap = userProfileService.lookup(user.getUserId()); if (userProfileMap == null) { String message = reasons.addInfo("We were unable to get a user profile map from the UserProfileService."); logger.info(message); @@ -151,14 +147,14 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, return new DecisionResponse(variation, reasons); } } else { // if we could not find a user profile, make a new one - userProfile = new UserProfile(userId, new HashMap()); + userProfile = new UserProfile(user.getUserId(), new HashMap()); } } - DecisionResponse decisionMeetAudience = ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, experiment, filteredAttributes, EXPERIMENT, experiment.getKey()); + DecisionResponse decisionMeetAudience = ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, experiment, user.getAttributes(), EXPERIMENT, experiment.getKey()); reasons.merge(decisionMeetAudience.getReasons()); if (decisionMeetAudience.getResult()) { - String bucketingId = getBucketingId(userId, filteredAttributes); + String bucketingId = getBucketingId(user.getUserId(), user.getAttributes()); decisionVariation = bucketer.bucket(experiment, bucketingId, projectConfig); reasons.merge(decisionVariation.getReasons()); @@ -175,42 +171,85 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, return new DecisionResponse(variation, reasons); } - String message = reasons.addInfo("User \"%s\" does not meet conditions to be in experiment \"%s\".", userId, experiment.getKey()); + String message = reasons.addInfo("User \"%s\" does not meet conditions to be in experiment \"%s\".", user.getUserId(), experiment.getKey()); logger.info(message); return new DecisionResponse(null, reasons); } @Nonnull public DecisionResponse getVariation(@Nonnull Experiment experiment, - @Nonnull String userId, - @Nonnull Map filteredAttributes, + @Nonnull OptimizelyUserContext user, @Nonnull ProjectConfig projectConfig) { - return getVariation(experiment, userId, filteredAttributes, projectConfig, Collections.emptyList()); + return getVariation(experiment, user, projectConfig, Collections.emptyList()); } /** * Get the variation the user is bucketed into for the FeatureFlag * * @param featureFlag The feature flag the user wants to access. - * @param userId User Identifier - * @param filteredAttributes A map of filtered attributes. + * @param user The current OptimizelyuserContext * @param projectConfig The current projectConfig * @param options An array of decision options * @return A {@link DecisionResponse} including a {@link FeatureDecision} and the decision reasons */ @Nonnull public DecisionResponse getVariationForFeature(@Nonnull FeatureFlag featureFlag, - @Nonnull String userId, - @Nonnull Map filteredAttributes, + @Nonnull OptimizelyUserContext user, @Nonnull ProjectConfig projectConfig, @Nonnull List options) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + DecisionResponse decisionVariationResponse = getVariationFromExperiment(projectConfig, featureFlag, user, options); + reasons.merge(decisionVariationResponse.getReasons()); + + FeatureDecision decision = decisionVariationResponse.getResult(); + if (decision != null) { + return new DecisionResponse(decision, reasons); + } + + DecisionResponse decisionFeatureResponse = getVariationForFeatureInRollout(featureFlag, user, projectConfig); + reasons.merge(decisionFeatureResponse.getReasons()); + decision = decisionFeatureResponse.getResult(); + + String message; + if (decision.variation == null) { + message = reasons.addInfo("The user \"%s\" was not bucketed into a rollout for feature flag \"%s\".", + user.getUserId(), featureFlag.getKey()); + } else { + message = reasons.addInfo("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".", + user.getUserId(), featureFlag.getKey()); + } + logger.info(message); + + return new DecisionResponse(decision, reasons); + } + + @Nonnull + public DecisionResponse getVariationForFeature(@Nonnull FeatureFlag featureFlag, + @Nonnull OptimizelyUserContext user, + @Nonnull ProjectConfig projectConfig) { + return getVariationForFeature(featureFlag, user, projectConfig, Collections.emptyList()); + } + + /** + * + * @param projectConfig The ProjectConfig. + * @param featureFlag The feature flag the user wants to access. + * @param user The current OptimizelyUserContext. + * @param options An array of decision options + * @return A {@link DecisionResponse} including a {@link FeatureDecision} and the decision reasons + */ + @Nonnull + DecisionResponse getVariationFromExperiment(@Nonnull ProjectConfig projectConfig, + @Nonnull FeatureFlag featureFlag, + @Nonnull OptimizelyUserContext user, + @Nonnull List options) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); if (!featureFlag.getExperimentIds().isEmpty()) { for (String experimentId : featureFlag.getExperimentIds()) { Experiment experiment = projectConfig.getExperimentIdMapping().get(experimentId); - DecisionResponse decisionVariation = getVariation(experiment, userId, filteredAttributes, projectConfig, options); + DecisionResponse decisionVariation = getVariationFromExperimentRule(projectConfig, featureFlag.getKey(), experiment, user, options); reasons.merge(decisionVariation.getReasons()); Variation variation = decisionVariation.getResult(); @@ -225,28 +264,8 @@ public DecisionResponse getVariationForFeature(@Nonnull Feature logger.info(message); } - DecisionResponse decisionFeature = getVariationForFeatureInRollout(featureFlag, userId, filteredAttributes, projectConfig); - reasons.merge(decisionFeature.getReasons()); - FeatureDecision featureDecision = decisionFeature.getResult(); - - if (featureDecision.variation == null) { - String message = reasons.addInfo("The user \"%s\" was not bucketed into a rollout for feature flag \"%s\".", - userId, featureFlag.getKey()); - logger.info(message); - } else { - String message = reasons.addInfo("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".", - userId, featureFlag.getKey()); - logger.info(message); - } - return new DecisionResponse(featureDecision, reasons); - } + return new DecisionResponse(null, reasons); - @Nonnull - public DecisionResponse getVariationForFeature(@Nonnull FeatureFlag featureFlag, - @Nonnull String userId, - @Nonnull Map filteredAttributes, - @Nonnull ProjectConfig projectConfig) { - return getVariationForFeature(featureFlag, userId, filteredAttributes, projectConfig,Collections.emptyList()); } /** @@ -255,15 +274,13 @@ public DecisionResponse getVariationForFeature(@Nonnull Feature * Fall back onto the everyone else rule if the user is ever excluded from a rule due to traffic allocation. * * @param featureFlag The feature flag the user wants to access. - * @param userId User Identifier - * @param filteredAttributes A map of filtered attributes. + * @param user The current OptimizelyUserContext * @param projectConfig The current projectConfig * @return A {@link DecisionResponse} including a {@link FeatureDecision} and the decision reasons */ @Nonnull DecisionResponse getVariationForFeatureInRollout(@Nonnull FeatureFlag featureFlag, - @Nonnull String userId, - @Nonnull Map filteredAttributes, + @Nonnull OptimizelyUserContext user, @Nonnull ProjectConfig projectConfig) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); @@ -286,51 +303,33 @@ DecisionResponse getVariationForFeatureInRollout(@Nonnull Featu if (rolloutRulesLength == 0) { return new DecisionResponse(new FeatureDecision(null, null, null), reasons); } - String bucketingId = getBucketingId(userId, filteredAttributes); - - Variation variation; - DecisionResponse decisionMeetAudience; - DecisionResponse decisionVariation; - for (int i = 0; i < rolloutRulesLength - 1; i++) { - Experiment rolloutRule = rollout.getExperiments().get(i); - - decisionMeetAudience = ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, rolloutRule, filteredAttributes, RULE, Integer.toString(i + 1)); - reasons.merge(decisionMeetAudience.getReasons()); - if (decisionMeetAudience.getResult()) { - decisionVariation = bucketer.bucket(rolloutRule, bucketingId, projectConfig); - reasons.merge(decisionVariation.getReasons()); - variation = decisionVariation.getResult(); - if (variation == null) { - break; - } - return new DecisionResponse( - new FeatureDecision(rolloutRule, variation, FeatureDecision.DecisionSource.ROLLOUT), - reasons); - } else { - String message = reasons.addInfo("User \"%s\" does not meet conditions for targeting rule \"%d\".", userId, i + 1); - logger.debug(message); - } - } - // get last rule which is the fall back rule - Experiment finalRule = rollout.getExperiments().get(rolloutRulesLength - 1); + int index = 0; + while (index < rolloutRulesLength) { - decisionMeetAudience = ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, finalRule, filteredAttributes, RULE, "Everyone Else"); - reasons.merge(decisionMeetAudience.getReasons()); - if (decisionMeetAudience.getResult()) { - decisionVariation = bucketer.bucket(finalRule, bucketingId, projectConfig); - variation = decisionVariation.getResult(); - reasons.merge(decisionVariation.getReasons()); + DecisionResponse decisionVariationResponse = getVariationFromDeliveryRule( + projectConfig, + featureFlag.getKey(), + rollout.getExperiments(), + index, + user + ); + reasons.merge(decisionVariationResponse.getReasons()); + AbstractMap.SimpleEntry response = decisionVariationResponse.getResult(); + Variation variation = response.getKey(); + Boolean skipToEveryoneElse = response.getValue(); if (variation != null) { - String message = reasons.addInfo("User \"%s\" meets conditions for targeting rule \"Everyone Else\".", userId); - logger.debug(message); - return new DecisionResponse( - new FeatureDecision(finalRule, variation, FeatureDecision.DecisionSource.ROLLOUT), - reasons); + Experiment rule = rollout.getExperiments().get(index); + FeatureDecision featureDecision = new FeatureDecision(rule, variation, FeatureDecision.DecisionSource.ROLLOUT); + return new DecisionResponse(featureDecision, reasons); } + + // The last rule is special for "Everyone Else" + index = skipToEveryoneElse ? (rolloutRulesLength - 1) : (index + 1); } + return new DecisionResponse(new FeatureDecision(null, null, null), reasons); } @@ -509,7 +508,7 @@ public boolean setForcedVariation(@Nonnull Experiment experiment, ConcurrentHashMap experimentToVariation; if (!forcedVariationMapping.containsKey(userId)) { - forcedVariationMapping.putIfAbsent(userId, new ConcurrentHashMap()); + forcedVariationMapping.putIfAbsent(userId, new ConcurrentHashMap()); } experimentToVariation = forcedVariationMapping.get(userId); @@ -581,6 +580,34 @@ public DecisionResponse getForcedVariation(@Nonnull Experiment experi return new DecisionResponse(null, reasons); } + + public DecisionResponse getVariationFromExperimentRule(@Nonnull ProjectConfig projectConfig, + @Nonnull String flagKey, + @Nonnull Experiment rule, + @Nonnull OptimizelyUserContext user, + @Nonnull List options) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + + String ruleKey = rule != null ? rule.getKey() : null; + // Check Forced-Decision + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + DecisionResponse forcedDecisionResponse = user.findValidatedForcedDecision(optimizelyDecisionContext); + + reasons.merge(forcedDecisionResponse.getReasons()); + + Variation variation = forcedDecisionResponse.getResult(); + if (variation != null) { + return new DecisionResponse(variation, reasons); + } + //regular decision + DecisionResponse decisionResponse = getVariation(rule, user, projectConfig, options); + reasons.merge(decisionResponse.getReasons()); + + variation = decisionResponse.getResult(); + + return new DecisionResponse(variation, reasons); + } + /** * Helper function to check that the provided userId is valid * @@ -591,4 +618,81 @@ private boolean validateUserId(String userId) { return (userId != null); } + /** + * + * @param projectConfig The Project config + * @param flagKey The flag key for the feature flag + * @param rules The experiments belonging to a rollout + * @param ruleIndex The index of the rule + * @param user The OptimizelyUserContext + * @return Returns a DecisionResponse Object containing a AbstractMap.SimpleEntry + * where the Variation is the result and the Boolean is the skipToEveryoneElse. + */ + DecisionResponse getVariationFromDeliveryRule(@Nonnull ProjectConfig projectConfig, + @Nonnull String flagKey, + @Nonnull List rules, + @Nonnull int ruleIndex, + @Nonnull OptimizelyUserContext user) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + + Boolean skipToEveryoneElse = false; + AbstractMap.SimpleEntry variationToSkipToEveryoneElsePair; + // Check forced-decisions first + Experiment rule = rules.get(ruleIndex); + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, rule.getKey()); + DecisionResponse forcedDecisionResponse = user.findValidatedForcedDecision(optimizelyDecisionContext); + reasons.merge(forcedDecisionResponse.getReasons()); + + Variation variation = forcedDecisionResponse.getResult(); + if (variation != null) { + variationToSkipToEveryoneElsePair = new AbstractMap.SimpleEntry<>(variation, false); + return new DecisionResponse(variationToSkipToEveryoneElsePair, reasons); + } + + // Handle a regular decision + String bucketingId = getBucketingId(user.getUserId(), user.getAttributes()); + Boolean everyoneElse = (ruleIndex == rules.size() - 1); + String loggingKey = everyoneElse ? "Everyone Else" : String.valueOf(ruleIndex + 1); + + Variation bucketedVariation = null; + + DecisionResponse audienceDecisionResponse = ExperimentUtils.doesUserMeetAudienceConditions( + projectConfig, + rule, + user.getAttributes(), + RULE, + String.valueOf(ruleIndex + 1) + ); + + reasons.merge(audienceDecisionResponse.getReasons()); + String message; + if (audienceDecisionResponse.getResult()) { + message = reasons.addInfo("User \"%s\" meets conditions for targeting rule \"%s\".", user.getUserId(), loggingKey); + reasons.addInfo(message); + logger.debug(message); + + DecisionResponse decisionResponse = bucketer.bucket(rule, bucketingId, projectConfig); + reasons.merge(decisionResponse.getReasons()); + bucketedVariation = decisionResponse.getResult(); + + if (bucketedVariation != null) { + message = reasons.addInfo("User \"%s\" bucketed for targeting rule \"%s\".", user.getUserId(), loggingKey); + logger.debug(message); + reasons.addInfo(message); + } else if (!everyoneElse) { + message = reasons.addInfo("User \"%s\" is not bucketed for targeting rule \"%s\".", user.getUserId(), loggingKey); + logger.debug(message); + reasons.addInfo(message); + // Skip the rest of rollout rules to the everyone-else rule if audience matches but not bucketed. + skipToEveryoneElse = true; + } + } else { + message = reasons.addInfo("User \"%s\" does not meet conditions for targeting rule \"%d\".", user.getUserId(), ruleIndex + 1); + reasons.addInfo(message); + logger.debug(message); + } + variationToSkipToEveryoneElsePair = new AbstractMap.SimpleEntry<>(bucketedVariation, skipToEveryoneElse); + return new DecisionResponse(variationToSkipToEveryoneElsePair, reasons); + } + } diff --git a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java index 0757b6d4e..831563826 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java @@ -31,9 +31,7 @@ import javax.annotation.CheckForNull; import javax.annotation.Nonnull; import javax.annotation.Nullable; -import javax.annotation.concurrent.Immutable; import java.util.*; -import java.util.concurrent.ConcurrentHashMap; /** * DatafileProjectConfig is an implementation of ProjectConfig that is backed by a @@ -80,6 +78,9 @@ public class DatafileProjectConfig implements ProjectConfig { private final Map experimentKeyMapping; private final Map featureKeyMapping; + // Key to Entity mappings for Forced Decisions + private final Map> flagVariationsMap; + // id to entity mappings private final Map audienceIdMapping; private final Map experimentIdMapping; @@ -209,8 +210,42 @@ public DatafileProjectConfig(String accountId, // Generate experiment to featureFlag list mapping to identify if experiment is AB-Test experiment or Feature-Test Experiment. this.experimentFeatureKeyMapping = ProjectConfigUtils.generateExperimentFeatureMapping(this.featureFlags); + + flagVariationsMap = new HashMap<>(); + if (featureFlags != null) { + for (FeatureFlag flag : featureFlags) { + Map variationIdToVariationsMap = new HashMap<>(); + for (Experiment rule : getAllRulesForFlag(flag)) { + for (Variation variation : rule.getVariations()) { + if(!variationIdToVariationsMap.containsKey(variation.getId())) { + variationIdToVariationsMap.put(variation.getId(), variation); + } + } + } + // Grab all the variations from the flag experiments and rollouts and add to flagVariationsMap + flagVariationsMap.put(flag.getKey(), new ArrayList<>(variationIdToVariationsMap.values())); + } + } + } + + /** + * Helper method to grab all rules for a flag + * @param flag The flag to grab all the rules from + * @return Returns a list of Experiments as rules + */ + private List getAllRulesForFlag(FeatureFlag flag) { + List rules = new ArrayList<>(); + Rollout rollout = rolloutIdMapping.get(flag.getRolloutId()); + for (String experimentId : flag.getExperimentIds()) { + rules.add(experimentIdMapping.get(experimentId)); + } + if (rollout != null) { + rules.addAll(rollout.getExperiments()); + } + return rules; } + /** * Helper method to retrieve the {@link Experiment} for the given experiment key. * If {@link RaiseExceptionErrorHandler} is provided, either an experiment is returned, @@ -463,6 +498,11 @@ public Map> getExperimentFeatureKeyMapping() { return experimentFeatureKeyMapping; } + @Override + public Map> getFlagVariationsMap() { + return flagVariationsMap; + } + @Override public String toString() { return "ProjectConfig{" + diff --git a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java index a6222e8b2..9c3321708 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java @@ -103,6 +103,8 @@ Experiment getExperimentForKey(@Nonnull String experimentKey, Map> getExperimentFeatureKeyMapping(); + Map> getFlagVariationsMap(); + @Override String toString(); diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java b/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java index 20d771033..9c44f455b 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java @@ -54,9 +54,9 @@ public static ImpressionEvent createImpressionEvent(@Nonnull ProjectConfig proje if (variation != null) { variationKey = variation.getKey(); variationID = variation.getId(); - layerID = activatedExperiment.getLayerId(); - experimentId = activatedExperiment.getId(); - experimentKey = activatedExperiment.getKey(); + layerID = activatedExperiment != null ? activatedExperiment.getLayerId() : ""; + experimentId = activatedExperiment != null ? activatedExperiment.getId() : ""; + experimentKey = activatedExperiment != null ? activatedExperiment.getKey() : ""; } UserContext userContext = new UserContext.Builder() diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyDecisionContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyDecisionContextTest.java new file mode 100644 index 000000000..daaf59d61 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyDecisionContextTest.java @@ -0,0 +1,44 @@ +/** + * + * Copyright 2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import org.junit.Test; +import static junit.framework.TestCase.assertEquals; + +public class OptimizelyDecisionContextTest { + + @Test + public void initializeOptimizelyDecisionContextWithFlagKeyAndRuleKey() { + String flagKey = "test-flag-key"; + String ruleKey = "1029384756"; + String expectedKey = flagKey + OptimizelyDecisionContext.OPTI_KEY_DIVIDER + ruleKey; + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + assertEquals(flagKey, optimizelyDecisionContext.getFlagKey()); + assertEquals(ruleKey, optimizelyDecisionContext.getRuleKey()); + assertEquals(expectedKey, optimizelyDecisionContext.getKey()); + } + + @Test + public void initializeOptimizelyDecisionContextWithFlagKey() { + String flagKey = "test-flag-key"; + String expectedKey = flagKey + OptimizelyDecisionContext.OPTI_KEY_DIVIDER + OptimizelyDecisionContext.OPTI_NULL_RULE_KEY; + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + assertEquals(flagKey, optimizelyDecisionContext.getFlagKey()); + assertEquals(OptimizelyDecisionContext.OPTI_NULL_RULE_KEY, optimizelyDecisionContext.getRuleKey()); + assertEquals(expectedKey, optimizelyDecisionContext.getKey()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyForcedDecisionTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyForcedDecisionTest.java new file mode 100644 index 000000000..90c0f9e50 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyForcedDecisionTest.java @@ -0,0 +1,30 @@ +/** + * + * Copyright 2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import org.junit.Test; +import static junit.framework.TestCase.assertEquals; + +public class OptimizelyForcedDecisionTest { + + @Test + public void initializeOptimizelyForcedDecision() { + String variationKey = "test-variation-key"; + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + assertEquals(variationKey, optimizelyForcedDecision.getVariationKey()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java index a0c0541ac..0a9c334f8 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -1716,8 +1716,7 @@ public void getEnabledFeaturesWithNoFeatureEnabled() throws Exception { FeatureDecision featureDecision = new FeatureDecision(null, null, FeatureDecision.DecisionSource.ROLLOUT); doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); int notificationId = optimizely.addDecisionNotificationHandler( decisionNotification -> { }); @@ -1833,8 +1832,7 @@ public void isFeatureEnabledWithListenerUserInExperimentFeatureOff() throws Exce FeatureDecision featureDecision = new FeatureDecision(activatedExperiment, variation, FeatureDecision.DecisionSource.FEATURE_TEST); doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); @@ -2897,8 +2895,7 @@ public void getFeatureVariableValueReturnsDefaultValueWhenFeatureEnabledIsFalse( FeatureDecision featureDecision = new FeatureDecision(multivariateExperiment, VARIATION_MULTIVARIATE_EXPERIMENT_GRED, FeatureDecision.DecisionSource.FEATURE_TEST); doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE), + optimizely.createUserContext(genericUserId, Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), validProjectConfig ); @@ -3110,8 +3107,7 @@ public void isFeatureEnabledReturnsFalseWhenFeatureKeyIsNull() throws Exception verify(mockDecisionService, never()).getVariationForFeature( any(FeatureFlag.class), - any(String.class), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); } @@ -3132,8 +3128,7 @@ public void isFeatureEnabledReturnsFalseWhenUserIdIsNull() throws Exception { verify(mockDecisionService, never()).getVariationForFeature( any(FeatureFlag.class), - any(String.class), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); } @@ -3156,8 +3151,7 @@ public void isFeatureEnabledReturnsFalseWhenFeatureFlagKeyIsInvalid() throws Exc verify(mockDecisionService, never()).getVariation( any(Experiment.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); } @@ -3179,8 +3173,7 @@ public void isFeatureEnabledReturnsFalseWhenUserIsNotBucketedIntoAnyVariation() FeatureDecision featureDecision = new FeatureDecision(null, null, null); doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); @@ -3195,8 +3188,7 @@ public void isFeatureEnabledReturnsFalseWhenUserIsNotBucketedIntoAnyVariation() verify(mockDecisionService).getVariationForFeature( eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), - eq(genericUserId), - eq(Collections.emptyMap()), + eq(optimizely.createUserContext(genericUserId, Collections.emptyMap())), eq(validProjectConfig) ); } @@ -3221,8 +3213,7 @@ public void isFeatureEnabledReturnsTrueButDoesNotSendWhenUserIsBucketedIntoVaria FeatureDecision featureDecision = new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.ROLLOUT); doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), - eq(genericUserId), - eq(Collections.emptyMap()), + eq(optimizely.createUserContext(genericUserId, Collections.emptyMap())), eq(validProjectConfig) ); @@ -3242,8 +3233,7 @@ public void isFeatureEnabledReturnsTrueButDoesNotSendWhenUserIsBucketedIntoVaria verify(mockDecisionService).getVariationForFeature( eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), - eq(genericUserId), - eq(Collections.emptyMap()), + eq(optimizely.createUserContext(genericUserId, Collections.emptyMap())), eq(validProjectConfig) ); } @@ -3306,8 +3296,7 @@ public void isFeatureEnabledTrueWhenFeatureEnabledOfVariationIsTrue() throws Exc FeatureDecision featureDecision = new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.ROLLOUT); doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), - eq(genericUserId), - eq(Collections.emptyMap()), + eq(optimizely.createUserContext(genericUserId, Collections.emptyMap())), eq(validProjectConfig) ); @@ -3336,8 +3325,7 @@ public void isFeatureEnabledFalseWhenFeatureEnabledOfVariationIsFalse() throws E FeatureDecision featureDecision = new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.ROLLOUT); doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), - eq(genericUserId), - eq(Collections.emptyMap()), + eq(spyOptimizely.createUserContext(genericUserId, Collections.emptyMap())), eq(validProjectConfig) ); @@ -3366,8 +3354,7 @@ public void isFeatureEnabledReturnsFalseAndDispatchesWhenUserIsBucketedIntoAnExp FeatureDecision featureDecision = new FeatureDecision(activatedExperiment, variation, FeatureDecision.DecisionSource.FEATURE_TEST); doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); @@ -3422,8 +3409,7 @@ public void isFeatureEnabledWithInvalidDatafile() throws Exception { // make sure we didn't even attempt to bucket the user verify(mockDecisionService, never()).getVariationForFeature( any(FeatureFlag.class), - anyString(), - anyMap(), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); } @@ -3512,13 +3498,11 @@ public void getEnabledFeatureWithMockDecisionService() throws Exception { FeatureDecision featureDecision = new FeatureDecision(null, null, FeatureDecision.DecisionSource.ROLLOUT); doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); - List featureFlags = optimizely.getEnabledFeatures(genericUserId, - Collections.emptyMap()); + List featureFlags = optimizely.getEnabledFeatures(genericUserId, Collections.emptyMap()); assertTrue(featureFlags.isEmpty()); eventHandler.expectImpression(null, "", genericUserId); @@ -4671,4 +4655,15 @@ public void createUserContext_multiple() { assertTrue(user2.getAttributes().isEmpty()); } + @Test + public void getFlagVariationByKey() throws IOException { + String flagKey = "double_single_variable_feature"; + String variationKey = "pi_variation"; + Optimizely optimizely = Optimizely.builder().withDatafile(validConfigJsonV4()).build(); + Variation variation = optimizely.getFlagVariationByKey(flagKey, variationKey); + + assertNotNull(variation); + assertEquals(variationKey, variation.getKey()); + } + } diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java index 0ac8beedb..6197d878b 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -26,6 +26,7 @@ import com.optimizely.ab.event.internal.payload.DecisionMetadata; import com.optimizely.ab.notification.NotificationCenter; import com.optimizely.ab.optimizelydecision.DecisionMessage; +import com.optimizely.ab.optimizelydecision.DecisionResponse; import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import com.optimizely.ab.optimizelydecision.OptimizelyDecision; import com.optimizely.ab.optimizelyjson.OptimizelyJSON; @@ -1190,6 +1191,525 @@ public void decideReasons_missingAttributeValue() { )); } + @Test + public void setForcedDecisionWithRuleKeyTest() { + String flagKey = "55555"; + String ruleKey = "77777"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + String foundVariationKey = optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey(); + assertEquals(variationKey, foundVariationKey); + } + + @Test + public void setForcedDecisionsWithRuleKeyTest() { + String flagKey = "feature_2"; + String ruleKey = "exp_no_audience"; + String ruleKey2 = "88888"; + String variationKey = "33333"; + String variationKey2 = "variation_with_traffic"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + OptimizelyDecisionContext optimizelyDecisionContext2 = new OptimizelyDecisionContext(flagKey, ruleKey2); + OptimizelyForcedDecision optimizelyForcedDecision2 = new OptimizelyForcedDecision(variationKey2); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext2, optimizelyForcedDecision2); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + assertEquals(variationKey2, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext2).getVariationKey()); + + // Update first forcedDecision + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision2); + assertEquals(variationKey2, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + + // Test to confirm decide uses proper FD + OptimizelyDecision decision = optimizelyUserContext.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("Variation (%s) is mapped to flag (%s), rule (%s) and user (%s) in the forced decision map.", variationKey2, flagKey, ruleKey, userId) + )); + } + + @Test + public void setForcedDecisionWithoutRuleKeyTest() { + String flagKey = "55555"; + String variationKey = "33333"; + String updatedVariationKey = "55555"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + OptimizelyForcedDecision updatedOptimizelyForcedDecision = new OptimizelyForcedDecision(updatedVariationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + + // Update forcedDecision + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, updatedOptimizelyForcedDecision); + assertEquals(updatedVariationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + } + + + @Test + public void setForcedDecisionWithoutRuleKeyTestSdkNotReady() { + String flagKey = "55555"; + String variationKey = "33333"; + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + assertFalse(optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision)); + } + + @Test + public void getForcedVariationWithRuleKey() { + String flagKey = "55555"; + String ruleKey = "77777"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + } + + @Test + public void failedGetForcedDecisionWithRuleKey() { + String flagKey = "55555"; + String invalidFlagKey = "11"; + String ruleKey = "77777"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyDecisionContext invalidOptimizelyDecisionContext = new OptimizelyDecisionContext(invalidFlagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertNull(optimizelyUserContext.getForcedDecision(invalidOptimizelyDecisionContext)); + } + + @Test + public void getForcedVariationWithoutRuleKey() { + String flagKey = "55555"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + } + + @Test + public void getForcedVariationWithoutRuleKeySdkNotReady() { + String flagKey = "55555"; + String variationKey = "33333"; + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertNull(optimizelyUserContext.getForcedDecision(optimizelyDecisionContext)); + } + + @Test + public void failedGetForcedDecisionWithoutRuleKey() { + String flagKey = "55555"; + String invalidFlagKey = "11"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyDecisionContext invalidOptimizelyDecisionContext = new OptimizelyDecisionContext(invalidFlagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertNull(optimizelyUserContext.getForcedDecision(invalidOptimizelyDecisionContext)); + } + + @Test + public void removeForcedDecisionWithRuleKey() { + String flagKey = "55555"; + String ruleKey = "77777"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertTrue(optimizelyUserContext.removeForcedDecision(optimizelyDecisionContext)); + } + + @Test + public void removeForcedDecisionWithoutRuleKey() { + String flagKey = "55555"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertTrue(optimizelyUserContext.removeForcedDecision(optimizelyDecisionContext)); + } + + @Test + public void removeForcedDecisionWithNullRuleKeyAfterAddingWithRuleKey() { + String flagKey = "flag2"; + String ruleKey = "default-rollout-3045-20390585493"; + String variationKey = "variation2"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyDecisionContext optimizelyDecisionContextNonNull = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertFalse(optimizelyUserContext.removeForcedDecision(optimizelyDecisionContextNonNull)); + } + + @Test + public void removeForcedDecisionWithIncorrectFlagKey() { + String flagKey = "55555"; + String variationKey = "variation2"; + String incorrectFlagKey = "flag1"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyDecisionContext incorrectOptimizelyDecisionContext = new OptimizelyDecisionContext(incorrectFlagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertFalse(optimizelyUserContext.removeForcedDecision(incorrectOptimizelyDecisionContext)); + } + + @Test + public void removeForcedDecisionWithoutRuleKeySdkNotReady() { + String flagKey = "flag2"; + String variationKey = "33333"; + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertFalse(optimizelyUserContext.removeForcedDecision(optimizelyDecisionContext)); + } + + @Test + public void removeForcedDecisionWithIncorrectFlagKeyButSimilarRuleKey() { + String flagKey = "flag2"; + String incorrectFlagKey = "flag3"; + String ruleKey = "default-rollout-3045-20390585493"; + String variationKey = "variation2"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyDecisionContext similarOptimizelyDecisionContext = new OptimizelyDecisionContext(incorrectFlagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertFalse(optimizelyUserContext.removeForcedDecision(similarOptimizelyDecisionContext)); + } + + @Test + public void removeAllForcedDecisions() { + String flagKey = "55555"; + String ruleKey = "77777"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertTrue(optimizelyUserContext.removeAllForcedDecisions()); + } + + @Test + public void removeAllForcedDecisionsSdkNotReady() { + String flagKey = "55555"; + String ruleKey = "77777"; + String variationKey = "33333"; + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertFalse(optimizelyUserContext.removeAllForcedDecisions()); + } + + @Test + public void findValidatedForcedDecisionWithRuleKey() { + String ruleKey = "77777"; + String flagKey = "feature_2"; + String variationKey = "variation_no_traffic"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + DecisionResponse response = optimizelyUserContext.findValidatedForcedDecision(optimizelyDecisionContext); + Variation variation = response.getResult(); + assertEquals(variationKey, variation.getKey()); + } + + @Test + public void findValidatedForcedDecisionWithoutRuleKey() { + String flagKey = "feature_2"; + String variationKey = "variation_no_traffic"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + DecisionResponse response = optimizelyUserContext.findValidatedForcedDecision(optimizelyDecisionContext); + Variation variation = response.getResult(); + assertEquals(variationKey, variation.getKey()); + } + + @Test + public void setForcedDecisionsAndCallDecide() { + String flagKey = "feature_2"; + String ruleKey = "exp_no_audience"; + String variationKey = "variation_with_traffic"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + + // Test to confirm decide uses proper FD + OptimizelyDecision decision = optimizelyUserContext.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertNotNull(decision); + assertTrue(decision.getReasons().contains( + String.format("Variation (%s) is mapped to flag (%s), rule (%s) and user (%s) in the forced decision map.", variationKey, flagKey, ruleKey, userId) + )); + } + /******************************************[START DECIDE TESTS WITH FDs]******************************************/ + @Test + public void setForcedDecisionsAndCallDecideFlagToDecision() { + String flagKey = "feature_1"; + String variationKey = "a"; + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + + optimizely.addDecisionNotificationHandler( + decisionNotification -> { + Assert.assertEquals(decisionNotification.getDecisionInfo().get(DECISION_EVENT_DISPATCHED), true); + isListenerCalled = true; + }); + + isListenerCalled = false; + + // Test to confirm decide uses proper FD + OptimizelyDecision decision = optimizelyUserContext.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(isListenerCalled); + + String variationId = "10389729780"; + String experimentId = ""; + + + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey("") + .setRuleType("feature-test") + .setVariationKey(variationKey) + .setEnabled(true) + .build(); + + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap(), metadata); + + assertNotNull(decision); + assertTrue(decision.getReasons().contains( + String.format("Variation (%s) is mapped to flag (%s) and user (%s) in the forced decision map.", variationKey, flagKey, userId) + )); + } + @Test + public void setForcedDecisionsAndCallDecideExperimentRuleToDecision() { + String flagKey = "feature_1"; + String ruleKey = "exp_with_audience"; + String variationKey = "a"; + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + + optimizely.addDecisionNotificationHandler( + decisionNotification -> { + Assert.assertEquals(decisionNotification.getDecisionInfo().get(DECISION_EVENT_DISPATCHED), true); + isListenerCalled = true; + }); + + isListenerCalled = false; + + // Test to confirm decide uses proper FD + OptimizelyDecision decision = optimizelyUserContext.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(isListenerCalled); + + String variationId = "10389729780"; + String experimentId = "10390977673"; + + + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap()); + + assertNotNull(decision); + assertTrue(decision.getReasons().contains( + String.format("Variation (%s) is mapped to flag (%s), rule (%s) and user (%s) in the forced decision map.", variationKey, flagKey, ruleKey, userId) + )); + } + + @Test + public void setForcedDecisionsAndCallDecideDeliveryRuleToDecision() { + String flagKey = "feature_1"; + String ruleKey = "3332020515"; + String variationKey = "3324490633"; + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + + optimizely.addDecisionNotificationHandler( + decisionNotification -> { + Assert.assertEquals(decisionNotification.getDecisionInfo().get(DECISION_EVENT_DISPATCHED), true); + isListenerCalled = true; + }); + + isListenerCalled = false; + + // Test to confirm decide uses proper FD + OptimizelyDecision decision = optimizelyUserContext.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(isListenerCalled); + + String variationId = "3324490633"; + String experimentId = "3332020515"; + + + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap()); + + assertNotNull(decision); + assertTrue(decision.getReasons().contains( + String.format("Variation (%s) is mapped to flag (%s), rule (%s) and user (%s) in the forced decision map.", variationKey, flagKey, ruleKey, userId) + )); + } + /********************************************[END DECIDE TESTS WITH FDs]******************************************/ // utils Map createUserProfileMap(String experimentId, String variationId) { diff --git a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java index 59dc47b22..eddbf0178 100644 --- a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java @@ -16,6 +16,8 @@ package com.optimizely.ab.bucketing; import ch.qos.logback.classic.Level; +import com.optimizely.ab.Optimizely; +import com.optimizely.ab.OptimizelyUserContext; import com.optimizely.ab.config.*; import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.internal.ControlAttribute; @@ -29,10 +31,7 @@ import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.*; import static com.optimizely.ab.config.ValidProjectConfigV4.*; @@ -62,6 +61,8 @@ public class DecisionServiceTest { private Variation whitelistedVariation; private DecisionService decisionService; + private Optimizely optimizely; + @Rule public LogbackVerifier logbackVerifier = new LogbackVerifier(); @@ -74,13 +75,14 @@ public void setUp() throws Exception { whitelistedVariation = whitelistedExperiment.getVariationKeyToVariationMap().get("vtag1"); Bucketer bucketer = new Bucketer(); decisionService = spy(new DecisionService(bucketer, mockErrorHandler, null)); + this.optimizely = Optimizely.builder().build(); } //========= getVariation tests =========/ /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * gives precedence to forced variation bucketing over audience evaluation. */ @Test @@ -89,19 +91,24 @@ public void getVariationWhitelistedPrecedesAudienceEval() throws Exception { Variation expectedVariation = experiment.getVariations().get(0); // user excluded without audiences and whitelisting - assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig).getResult()); + assertNull(decisionService.getVariation( + experiment, + optimizely.createUserContext( + genericUserId, + Collections.emptyMap()), + validProjectConfig).getResult()); logbackVerifier.expectMessage(Level.INFO, "User \"" + whitelistedUserId + "\" is forced in variation \"vtag1\"."); // no attributes provided for a experiment that has an audience - assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.emptyMap(), validProjectConfig).getResult(), is(expectedVariation)); + assertThat(decisionService.getVariation(experiment, optimizely.createUserContext(whitelistedUserId, Collections.emptyMap()), validProjectConfig).getResult(), is(expectedVariation)); verify(decisionService).getWhitelistedVariation(eq(experiment), eq(whitelistedUserId)); verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), any(ProjectConfig.class)); } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * gives precedence to forced variation bucketing over whitelisting. */ @Test @@ -111,23 +118,23 @@ public void getForcedVariationBeforeWhitelisting() throws Exception { Variation expectedVariation = experiment.getVariations().get(1); // user excluded without audiences and whitelisting - assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig).getResult()); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext(genericUserId, Collections.emptyMap()), validProjectConfig).getResult()); // set the runtimeForcedVariation decisionService.setForcedVariation(experiment, whitelistedUserId, expectedVariation.getKey()); // no attributes provided for a experiment that has an audience - assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.emptyMap(), validProjectConfig).getResult(), is(expectedVariation)); + assertThat(decisionService.getVariation(experiment, optimizely.createUserContext(whitelistedUserId, Collections.emptyMap()), validProjectConfig).getResult(), is(expectedVariation)); //verify(decisionService).getForcedVariation(experiment.getKey(), whitelistedUserId); verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), any(ProjectConfig.class)); assertEquals(decisionService.getWhitelistedVariation(experiment, whitelistedUserId).getResult(), whitelistVariation); assertTrue(decisionService.setForcedVariation(experiment, whitelistedUserId, null)); assertNull(decisionService.getForcedVariation(experiment, whitelistedUserId).getResult()); - assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.emptyMap(), validProjectConfig).getResult(), is(whitelistVariation)); + assertThat(decisionService.getVariation(experiment, optimizely.createUserContext(whitelistedUserId, Collections.emptyMap()), validProjectConfig).getResult(), is(whitelistVariation)); } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * gives precedence to forced variation bucketing over audience evaluation. */ @Test @@ -136,12 +143,12 @@ public void getVariationForcedPrecedesAudienceEval() throws Exception { Variation expectedVariation = experiment.getVariations().get(1); // user excluded without audiences and whitelisting - assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig).getResult()); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext(genericUserId, Collections.emptyMap()), validProjectConfig).getResult()); // set the runtimeForcedVariation decisionService.setForcedVariation(experiment, genericUserId, expectedVariation.getKey()); // no attributes provided for a experiment that has an audience - assertThat(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig).getResult(), is(expectedVariation)); + assertThat(decisionService.getVariation(experiment, optimizely.createUserContext(genericUserId, Collections.emptyMap()), validProjectConfig).getResult(), is(expectedVariation)); verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), eq(validProjectConfig)); assertEquals(decisionService.setForcedVariation(experiment, genericUserId, null), true); @@ -149,7 +156,7 @@ public void getVariationForcedPrecedesAudienceEval() throws Exception { } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * gives precedence to forced variation bucketing over user profile. */ @Test @@ -165,22 +172,22 @@ public void getVariationForcedBeforeUserProfile() throws Exception { DecisionService decisionService = spy(new DecisionService(new Bucketer(), mockErrorHandler, userProfileService)); // ensure that normal users still get excluded from the experiment when they fail audience evaluation - assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig).getResult()); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext(genericUserId, Collections.emptyMap()), validProjectConfig).getResult()); // ensure that a user with a saved user profile, sees the same variation regardless of audience evaluation assertEquals(variation, - decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), validProjectConfig).getResult()); + decisionService.getVariation(experiment, optimizely.createUserContext(userProfileId, Collections.emptyMap()), validProjectConfig).getResult()); Variation forcedVariation = experiment.getVariations().get(1); decisionService.setForcedVariation(experiment, userProfileId, forcedVariation.getKey()); assertEquals(forcedVariation, - decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), validProjectConfig).getResult()); + decisionService.getVariation(experiment, optimizely.createUserContext(userProfileId, Collections.emptyMap()), validProjectConfig).getResult()); assertTrue(decisionService.setForcedVariation(experiment, userProfileId, null)); assertNull(decisionService.getForcedVariation(experiment, userProfileId).getResult()); } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * gives precedence to user profile over audience evaluation. */ @Test @@ -196,16 +203,16 @@ public void getVariationEvaluatesUserProfileBeforeAudienceTargeting() throws Exc DecisionService decisionService = spy(new DecisionService(new Bucketer(), mockErrorHandler, userProfileService)); // ensure that normal users still get excluded from the experiment when they fail audience evaluation - assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig).getResult()); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext(genericUserId, Collections.emptyMap()), validProjectConfig).getResult()); // ensure that a user with a saved user profile, sees the same variation regardless of audience evaluation assertEquals(variation, - decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), validProjectConfig).getResult()); + decisionService.getVariation(experiment, optimizely.createUserContext(userProfileId, Collections.emptyMap()), validProjectConfig).getResult()); } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * gives a null variation on a Experiment that is not running. Set the forced variation. * And, test to make sure that after setting forced variation, the getVariation still returns * null. @@ -217,7 +224,7 @@ public void getVariationOnNonRunningExperimentWithForcedVariation() { Variation variation = experiment.getVariations().get(0); // ensure that the not running variation returns null with no forced variation set. - assertNull(decisionService.getVariation(experiment, "userId", Collections.emptyMap(), validProjectConfig).getResult()); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext("userId", Collections.emptyMap()), validProjectConfig).getResult()); // we call getVariation 3 times on an experiment that is not running. logbackVerifier.expectMessage(Level.INFO, @@ -228,12 +235,12 @@ public void getVariationOnNonRunningExperimentWithForcedVariation() { // ensure that a user with a forced variation set // still gets back a null variation if the variation is not running. - assertNull(decisionService.getVariation(experiment, "userId", Collections.emptyMap(), validProjectConfig).getResult()); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext("userId", Collections.emptyMap()), validProjectConfig).getResult()); // set the forced variation back to null assertTrue(decisionService.setForcedVariation(experiment, "userId", null)); // test one more time that the getVariation returns null for the experiment that is not running. - assertNull(decisionService.getVariation(experiment, "userId", Collections.emptyMap(), validProjectConfig).getResult()); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext("userid", Collections.emptyMap()), validProjectConfig).getResult()); } @@ -241,7 +248,7 @@ public void getVariationOnNonRunningExperimentWithForcedVariation() { //========== get Variation for Feature tests ==========// /** - * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns null when the {@link FeatureFlag} is not used in any experiments or rollouts. */ @Test @@ -263,8 +270,7 @@ public void getVariationForFeatureReturnsNullWhenFeatureFlagExperimentIdsIsEmpty FeatureDecision featureDecision = decisionService.getVariationForFeature( emptyFeatureFlag, - genericUserId, - Collections.emptyMap(), + optimizely.createUserContext(genericUserId, Collections.emptyMap()), validProjectConfig).getResult(); assertNull(featureDecision.variation); assertNull(featureDecision.decisionSource); @@ -275,7 +281,7 @@ public void getVariationForFeatureReturnsNullWhenFeatureFlagExperimentIdsIsEmpty } /** - * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns null when the user is not bucketed into any experiments or rollouts for the {@link FeatureFlag}. */ @Test @@ -286,24 +292,21 @@ public void getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperiment // do not bucket to any experiments doReturn(DecisionResponse.nullNoReasons()).when(decisionService).getVariation( any(Experiment.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class), anyObject() ); // do not bucket to any rollouts doReturn(DecisionResponse.responseNoReasons(new FeatureDecision(null, null, null))).when(decisionService).getVariationForFeatureInRollout( any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); // try to get a variation back from the decision service for the feature flag FeatureDecision featureDecision = decisionService.getVariationForFeature( spyFeatureFlag, - genericUserId, - Collections.emptyMap(), + optimizely.createUserContext(genericUserId, Collections.emptyMap()), validProjectConfig ).getResult(); assertNull(featureDecision.variation); @@ -314,11 +317,11 @@ public void getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperiment FEATURE_MULTI_VARIATE_FEATURE_KEY + "\"."); verify(spyFeatureFlag, times(2)).getExperimentIds(); - verify(spyFeatureFlag, times(1)).getKey(); + verify(spyFeatureFlag, times(2)).getKey(); } /** - * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns the variation of the experiment a user gets bucketed into for an experiment. */ @Test @@ -328,36 +331,33 @@ public void getVariationForFeatureReturnsVariationReturnedFromGetVariation() { doReturn(DecisionResponse.nullNoReasons()).when(decisionService).getVariation( eq(ValidProjectConfigV4.EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class), anyObject() ); doReturn(DecisionResponse.responseNoReasons(ValidProjectConfigV4.VARIATION_MUTEX_GROUP_EXP_2_VAR_1)).when(decisionService).getVariation( eq(ValidProjectConfigV4.EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class), anyObject() ); FeatureDecision featureDecision = decisionService.getVariationForFeature( spyFeatureFlag, - genericUserId, - Collections.emptyMap(), + optimizely.createUserContext(genericUserId, Collections.emptyMap()), v4ProjectConfig ).getResult(); assertEquals(ValidProjectConfigV4.VARIATION_MUTEX_GROUP_EXP_2_VAR_1, featureDecision.variation); assertEquals(FeatureDecision.DecisionSource.FEATURE_TEST, featureDecision.decisionSource); verify(spyFeatureFlag, times(2)).getExperimentIds(); - verify(spyFeatureFlag, never()).getKey(); + verify(spyFeatureFlag, times(2)).getKey(); } /** * Verify that when getting a {@link Variation} for a {@link FeatureFlag} in - * {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map, ProjectConfig)}, + * {@link DecisionService#getVariationForFeature(FeatureFlag, OptimizelyUserContext, ProjectConfig)}, * check first if the user is bucketed to an {@link Experiment} * then check if the user is not bucketed to an experiment, * check for a {@link Rollout}. @@ -376,8 +376,7 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() doReturn(DecisionResponse.responseNoReasons(experimentVariation)) .when(decisionService).getVariation( eq(featureExperiment), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class), anyObject() ); @@ -386,16 +385,14 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() doReturn(DecisionResponse.responseNoReasons(new FeatureDecision(rolloutExperiment, rolloutVariation, FeatureDecision.DecisionSource.ROLLOUT))) .when(decisionService).getVariationForFeatureInRollout( eq(featureFlag), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); // make sure we get the right variation back FeatureDecision featureDecision = decisionService.getVariationForFeature( featureFlag, - genericUserId, - Collections.emptyMap(), + optimizely.createUserContext(genericUserId, Collections.emptyMap()), v4ProjectConfig ).getResult(); assertEquals(experimentVariation, featureDecision.variation); @@ -404,16 +401,14 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() // make sure we do not even check for rollout bucketing verify(decisionService, never()).getVariationForFeatureInRollout( any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); // make sure we ask for experiment bucketing once verify(decisionService, times(1)).getVariation( any(Experiment.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class), anyObject() ); @@ -421,7 +416,7 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() /** * Verify that when getting a {@link Variation} for a {@link FeatureFlag} in - * {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map, ProjectConfig)}, + * {@link DecisionService#getVariationForFeature(FeatureFlag, OptimizelyUserContext, ProjectConfig)}, * check first if the user is bucketed to an {@link Rollout} * if the user is not bucketed to an experiment. */ @@ -438,8 +433,7 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails doReturn(DecisionResponse.nullNoReasons()) .when(decisionService).getVariation( eq(featureExperiment), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class), anyObject() ); @@ -448,16 +442,14 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails doReturn(DecisionResponse.responseNoReasons(new FeatureDecision(rolloutExperiment, rolloutVariation, FeatureDecision.DecisionSource.ROLLOUT))) .when(decisionService).getVariationForFeatureInRollout( eq(featureFlag), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); // make sure we get the right variation back FeatureDecision featureDecision = decisionService.getVariationForFeature( featureFlag, - genericUserId, - Collections.emptyMap(), + optimizely.createUserContext(genericUserId, Collections.emptyMap()), v4ProjectConfig ).getResult(); assertEquals(rolloutVariation, featureDecision.variation); @@ -466,16 +458,14 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails // make sure we do not even check for rollout bucketing verify(decisionService, times(1)).getVariationForFeatureInRollout( any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); // make sure we ask for experiment bucketing once verify(decisionService, times(1)).getVariation( any(Experiment.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class), anyObject() ); @@ -490,7 +480,7 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails //========== getVariationForFeatureInRollout tests ==========// /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns null when trying to bucket a user into a {@link FeatureFlag} * that does not have a {@link Rollout} attached. */ @@ -503,8 +493,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenFeatureIsNotAttachedTo FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( mockFeatureFlag, - genericUserId, - Collections.emptyMap(), + optimizely.createUserContext(genericUserId, Collections.emptyMap()), validProjectConfig ).getResult(); assertNull(featureDecision.variation); @@ -517,7 +506,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenFeatureIsNotAttachedTo } /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * return null when a user is excluded from every rule of a rollout due to traffic allocation. */ @Test @@ -533,10 +522,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserIsExcludedFromAllT FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - Collections.singletonMap( - ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE - ), + optimizely.createUserContext(genericUserId, Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), v4ProjectConfig ).getResult(); assertNull(featureDecision.variation); @@ -549,7 +535,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserIsExcludedFromAllT } /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns null when a user is excluded from every rule of a rollout due to targeting * and also fails traffic allocation in the everyone else rollout. */ @@ -562,8 +548,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserFailsAllAudiencesA FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - Collections.emptyMap(), + optimizely.createUserContext(genericUserId, Collections.emptyMap()), v4ProjectConfig ).getResult(); assertNull(featureDecision.variation); @@ -574,7 +559,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserFailsAllAudiencesA } /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns the variation of "Everyone Else" rule * when the user fails targeting for all rules, but is bucketed into the "Everyone Else" rule. */ @@ -594,8 +579,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsAllAudie FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - Collections.emptyMap(), + optimizely.createUserContext(genericUserId, Collections.emptyMap()), v4ProjectConfig ).getResult(); logbackVerifier.expectMessage(Level.DEBUG, "Evaluating audiences for rule \"1\": [3468206642]."); @@ -614,7 +598,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsAllAudie } /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns the variation of "Everyone Else" rule * when the user passes targeting for a rule, but was failed the traffic allocation for that rule, * and is bucketed successfully into the "Everyone Else" rule. @@ -636,10 +620,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - Collections.singletonMap( - ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE - ), + optimizely.createUserContext(genericUserId, Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), v4ProjectConfig ).getResult(); assertEquals(expectedVariation, featureDecision.variation); @@ -652,7 +633,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI } /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns the variation of "Everyone Else" rule * when the user passes targeting for a rule, but was failed the traffic allocation for that rule, * and is bucketed successfully into the "Everyone Else" rule. @@ -679,15 +660,14 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - DatafileProjectConfigTestUtils.createMapOfObjects( + optimizely.createUserContext(genericUserId, DatafileProjectConfigTestUtils.createMapOfObjects( DatafileProjectConfigTestUtils.createListOfObjects( ATTRIBUTE_HOUSE_KEY, ATTRIBUTE_NATIONALITY_KEY ), DatafileProjectConfigTestUtils.createListOfObjects( AUDIENCE_GRYFFINDOR_VALUE, AUDIENCE_ENGLISH_CITIZENS_VALUE ) - ), + )), v4ProjectConfig ).getResult(); assertEquals(expectedVariation, featureDecision.variation); @@ -698,7 +678,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI } /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns the variation of "English Citizens" rule * when the user fails targeting for previous rules, but passes targeting and traffic for Rule 3. */ @@ -718,10 +698,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTargetin FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - Collections.singletonMap( - ATTRIBUTE_NATIONALITY_KEY, AUDIENCE_ENGLISH_CITIZENS_VALUE - ), + optimizely.createUserContext(genericUserId, Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, AUDIENCE_ENGLISH_CITIZENS_VALUE)), v4ProjectConfig ).getResult(); assertEquals(englishCitizenVariation, featureDecision.variation); @@ -735,6 +712,55 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTargetin verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); } + @Test + public void getVariationFromDeliveryRuleTest() { + int index = 3; + List rules = ROLLOUT_2.getExperiments(); + Experiment experiment = ROLLOUT_2.getExperiments().get(index); + Variation expectedVariation = null; + for (Variation variation : experiment.getVariations()) { + if (variation.getKey().equals("3137445031")) { + expectedVariation = variation; + } + } + DecisionResponse decisionResponse = decisionService.getVariationFromDeliveryRule( + v4ProjectConfig, + FEATURE_FLAG_MULTI_VARIATE_FEATURE.getKey(), + rules, + index, + optimizely.createUserContext(genericUserId, Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, AUDIENCE_ENGLISH_CITIZENS_VALUE)) + ); + + Variation variation = (Variation) decisionResponse.getResult().getKey(); + Boolean skipToEveryoneElse = (Boolean) decisionResponse.getResult().getValue(); + assertNotNull(decisionResponse.getResult()); + assertNotNull(variation); + assertNotNull(expectedVariation); + assertEquals(expectedVariation, variation); + assertFalse(skipToEveryoneElse); + } + + @Test + public void getVariationFromExperimentRuleTest() { + int index = 3; + Experiment experiment = ROLLOUT_2.getExperiments().get(index); + Variation expectedVariation = null; + for (Variation variation : experiment.getVariations()) { + if (variation.getKey().equals("3137445031")) { + expectedVariation = variation; + } + } + DecisionResponse decisionResponse = decisionService.getVariationFromExperimentRule( + v4ProjectConfig, + FEATURE_FLAG_MULTI_VARIATE_FEATURE.getKey(), + experiment, + optimizely.createUserContext(genericUserId, Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, AUDIENCE_ENGLISH_CITIZENS_VALUE)), + Collections.emptyList() + ); + + assertEquals(expectedVariation, decisionResponse.getResult()); + } + //========= white list tests ==========/ /** @@ -811,7 +837,7 @@ public void bucketReturnsVariationStoredInUserProfile() throws Exception { // ensure user with an entry in the user profile is bucketed into the corresponding stored variation assertEquals(variation, - decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), noAudienceProjectConfig).getResult()); + decisionService.getVariation(experiment, optimizely.createUserContext(userProfileId, Collections.emptyMap()), noAudienceProjectConfig).getResult()); verify(userProfileService).lookup(userProfileId); } @@ -867,7 +893,7 @@ public void getStoredVariationReturnsNullWhenVariationIsNoLongerInConfig() throw } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * saves a {@link Variation}of an {@link Experiment} for a user when a {@link UserProfileService} is present. */ @SuppressFBWarnings @@ -890,7 +916,7 @@ public void getVariationSavesBucketedVariationIntoUserProfile() throws Exception DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, userProfileService); assertEquals(variation, decisionService.getVariation( - experiment, userProfileId, Collections.emptyMap(), noAudienceProjectConfig).getResult() + experiment, optimizely.createUserContext(userProfileId, Collections.emptyMap()), noAudienceProjectConfig).getResult() ); logbackVerifier.expectMessage(Level.INFO, String.format("Saved variation \"%s\" of experiment \"%s\" for user \"" + userProfileId + "\".", variation.getId(), @@ -900,7 +926,7 @@ public void getVariationSavesBucketedVariationIntoUserProfile() throws Exception } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map, ProjectConfig)} logs correctly + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} logs correctly * when a {@link UserProfileService} is present but fails to save an activation. */ @Test @@ -950,7 +976,7 @@ public void getVariationSavesANewUserProfile() throws Exception { when(bucketer.bucket(eq(experiment), eq(userProfileId), eq(noAudienceProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(variation)); when(userProfileService.lookup(userProfileId)).thenReturn(null); - assertEquals(variation, decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), noAudienceProjectConfig).getResult()); + assertEquals(variation, decisionService.getVariation(experiment, optimizely.createUserContext(userProfileId, Collections.emptyMap()), noAudienceProjectConfig).getResult()); verify(userProfileService).save(expectedUserProfile.toMap()); } @@ -963,15 +989,15 @@ public void getVariationBucketingId() throws Exception { when(bucketer.bucket(eq(experiment), eq("bucketId"), eq(validProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(expectedVariation)); - Map attr = new HashMap(); + Map attr = new HashMap(); attr.put(ControlAttribute.BUCKETING_ATTRIBUTE.toString(), "bucketId"); // user excluded without audiences and whitelisting - assertThat(decisionService.getVariation(experiment, genericUserId, attr, validProjectConfig).getResult(), is(expectedVariation)); + assertThat(decisionService.getVariation(experiment, optimizely.createUserContext(genericUserId, attr), validProjectConfig).getResult(), is(expectedVariation)); } /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * uses bucketing ID to bucket the user into rollouts. */ @Test @@ -981,7 +1007,7 @@ public void getVariationForRolloutWithBucketingId() { FeatureFlag featureFlag = FEATURE_FLAG_SINGLE_VARIABLE_INTEGER; String bucketingId = "user_bucketing_id"; String userId = "user_id"; - Map attributes = new HashMap(); + Map attributes = new HashMap(); attributes.put(ControlAttribute.BUCKETING_ATTRIBUTE.toString(), bucketingId); Bucketer bucketer = mock(Bucketer.class); @@ -999,7 +1025,7 @@ public void getVariationForRolloutWithBucketingId() { rolloutVariation, FeatureDecision.DecisionSource.ROLLOUT); - FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, attributes, v4ProjectConfig).getResult(); + FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, optimizely.createUserContext(userId, attributes), v4ProjectConfig).getResult(); assertEquals(expectedFeatureDecision, featureDecision); } diff --git a/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTest.java b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTest.java index cab4face3..41b02ea91 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTest.java @@ -17,15 +17,14 @@ package com.optimizely.ab.config; import ch.qos.logback.classic.Level; +import com.google.errorprone.annotations.Var; import com.optimizely.ab.config.audience.AndCondition; import com.optimizely.ab.config.audience.Condition; import com.optimizely.ab.config.audience.NotCondition; import com.optimizely.ab.config.audience.OrCondition; import com.optimizely.ab.config.audience.UserAttribute; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; +import java.util.*; import static java.util.Arrays.asList; import static org.hamcrest.CoreMatchers.is; @@ -170,4 +169,22 @@ public void getAttributeIDWhenAttributeKeyPrefixIsMatched() { " has reserved prefix $opt_; using attribute ID instead of reserved attribute name."); } + @Test + public void confirmUniqueVariationsInFlagVariationsMapTest() { + // Test to confirm no duplicate variations are added for each flag + // This should never happen as a Map is used for each flag based on variation ID as the key + Map> flagVariationsMap = projectConfig.getFlagVariationsMap(); + for (List variationsList : flagVariationsMap.values()) { + Boolean duplicate = false; + Map variationIdToVariationsMap = new HashMap<>(); + for (Variation variation : variationsList) { + if (variationIdToVariationsMap.containsKey(variation.getId())) { + duplicate = true; + } + variationIdToVariationsMap.put(variation.getId(), variation); + } + assertFalse(duplicate); + } + } + } From 80ab3f0125ab8eba32965a16b7607bbce7041665 Mon Sep 17 00:00:00 2001 From: Jake Brown Date: Fri, 3 Dec 2021 14:10:31 -0500 Subject: [PATCH 063/147] fix(ForcedDecision): remove config-ready check from forced-decision apis (#454) ## Summary - Remove config-ready check from forced-decision apis ## Test plan - Unit and FSC tests --- .../optimizely/ab/OptimizelyUserContext.java | 17 ----- .../ab/OptimizelyUserContextTest.java | 74 ++----------------- 2 files changed, 6 insertions(+), 85 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java index 0a785c550..53eb26cf6 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -198,10 +198,6 @@ public void trackEvent(@Nonnull String eventName) throws UnknownEventTypeExcepti */ public Boolean setForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDecisionContext, @Nonnull OptimizelyForcedDecision optimizelyForcedDecision) { - if (optimizely.getOptimizelyConfig() == null) { - logger.error("Optimizely SDK not ready."); - return false; - } // Check if the forcedDecisionsMap has been initialized yet or not if (forcedDecisionsMap == null ){ // Thread-safe implementation of HashMap @@ -219,10 +215,6 @@ public Boolean setForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDe */ @Nullable public OptimizelyForcedDecision getForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDecisionContext) { - if (optimizely.getOptimizelyConfig() == null) { - logger.error("Optimizely SDK not ready."); - return null; - } return findForcedDecision(optimizelyDecisionContext); } @@ -247,11 +239,6 @@ public OptimizelyForcedDecision findForcedDecision(@Nonnull OptimizelyDecisionCo * @return Returns a boolean, true if successfully removed, otherwise false */ public boolean removeForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDecisionContext) { - if (optimizely.getOptimizelyConfig() == null) { - logger.error("Optimizely SDK not ready."); - return false; - } - try { if (forcedDecisionsMap != null) { if (forcedDecisionsMap.remove(optimizelyDecisionContext.getKey()) != null) { @@ -271,10 +258,6 @@ public boolean removeForcedDecision(@Nonnull OptimizelyDecisionContext optimizel * @return Returns a boolean, True if successfully, otherwise false */ public boolean removeAllForcedDecisions() { - if (optimizely.getProjectConfig() == null) { - logger.error("Optimizely SDK not ready."); - return false; - } // Clear both maps for with and without ruleKey if (forcedDecisionsMap != null) { forcedDecisionsMap.clear(); diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java index 6197d878b..ef91ca596 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -82,11 +82,13 @@ public void optimizelyUserContext_withAttributes() { @Test public void optimizelyUserContext_noAttributes() { - OptimizelyUserContext user = new OptimizelyUserContext(optimizely, userId); + OptimizelyUserContext user_1 = new OptimizelyUserContext(optimizely, userId); + OptimizelyUserContext user_2 = new OptimizelyUserContext(optimizely, userId); - assertEquals(user.getOptimizely(), optimizely); - assertEquals(user.getUserId(), userId); - assertTrue(user.getAttributes().isEmpty()); + assertEquals(user_1.getOptimizely(), optimizely); + assertEquals(user_1.getUserId(), userId); + assertTrue(user_1.getAttributes().isEmpty()); + assertEquals(user_1.hashCode(), user_2.hashCode()); } @Test @@ -1263,21 +1265,6 @@ public void setForcedDecisionWithoutRuleKeyTest() { } - @Test - public void setForcedDecisionWithoutRuleKeyTestSdkNotReady() { - String flagKey = "55555"; - String variationKey = "33333"; - Optimizely optimizely = new Optimizely.Builder().build(); - OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( - optimizely, - userId, - Collections.emptyMap()); - - OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); - OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); - assertFalse(optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision)); - } - @Test public void getForcedVariationWithRuleKey() { String flagKey = "55555"; @@ -1330,22 +1317,6 @@ public void getForcedVariationWithoutRuleKey() { assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); } - @Test - public void getForcedVariationWithoutRuleKeySdkNotReady() { - String flagKey = "55555"; - String variationKey = "33333"; - Optimizely optimizely = new Optimizely.Builder().build(); - OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( - optimizely, - userId, - Collections.emptyMap()); - - OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); - OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); - - optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); - assertNull(optimizelyUserContext.getForcedDecision(optimizelyDecisionContext)); - } @Test public void failedGetForcedDecisionWithoutRuleKey() { @@ -1434,22 +1405,6 @@ public void removeForcedDecisionWithIncorrectFlagKey() { assertFalse(optimizelyUserContext.removeForcedDecision(incorrectOptimizelyDecisionContext)); } - @Test - public void removeForcedDecisionWithoutRuleKeySdkNotReady() { - String flagKey = "flag2"; - String variationKey = "33333"; - Optimizely optimizely = new Optimizely.Builder().build(); - OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( - optimizely, - userId, - Collections.emptyMap()); - - OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); - OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); - - optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); - assertFalse(optimizelyUserContext.removeForcedDecision(optimizelyDecisionContext)); - } @Test public void removeForcedDecisionWithIncorrectFlagKeyButSimilarRuleKey() { @@ -1487,23 +1442,6 @@ public void removeAllForcedDecisions() { assertTrue(optimizelyUserContext.removeAllForcedDecisions()); } - @Test - public void removeAllForcedDecisionsSdkNotReady() { - String flagKey = "55555"; - String ruleKey = "77777"; - String variationKey = "33333"; - Optimizely optimizely = new Optimizely.Builder().build(); - OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( - optimizely, - userId, - Collections.emptyMap()); - - OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); - OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); - - optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); - assertFalse(optimizelyUserContext.removeAllForcedDecisions()); - } @Test public void findValidatedForcedDecisionWithRuleKey() { From 7eb639cf0bb66ecf4572cc307b866372648c4a9e Mon Sep 17 00:00:00 2001 From: Jake Brown Date: Tue, 7 Dec 2021 08:29:40 -0500 Subject: [PATCH 064/147] Add null check to flagKey when creating OptimizelyDecisionContext. (#455) ## Summary - Add null check to OptimizelyDecisionContext constructor for flagKey We want to confirm the user is not passing a null value as flagKey in addition to the @NonNull annotation provided to ensure the object is valid ## Test plan - FSC ## Issues N/A --- .../main/java/com/optimizely/ab/OptimizelyDecisionContext.java | 1 + 1 file changed, 1 insertion(+) diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyDecisionContext.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyDecisionContext.java index 4c4159301..3663f769d 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyDecisionContext.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyDecisionContext.java @@ -27,6 +27,7 @@ public class OptimizelyDecisionContext { private String ruleKey; public OptimizelyDecisionContext(@Nonnull String flagKey, @Nullable String ruleKey) { + if (flagKey == null) throw new NullPointerException("FlagKey must not be null, please provide a valid input."); this.flagKey = flagKey; this.ruleKey = ruleKey; } From ca3690d9fb1fff9c2b090571519ce0ad4251085f Mon Sep 17 00:00:00 2001 From: mnoman09 Date: Wed, 15 Dec 2021 00:10:40 +0500 Subject: [PATCH 065/147] removed log4j properties (#456) --- java-quickstart/src/main/resources/log4j.properties | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 java-quickstart/src/main/resources/log4j.properties diff --git a/java-quickstart/src/main/resources/log4j.properties b/java-quickstart/src/main/resources/log4j.properties deleted file mode 100644 index cb9efa9d6..000000000 --- a/java-quickstart/src/main/resources/log4j.properties +++ /dev/null @@ -1,8 +0,0 @@ -# Root logger option -log4j.rootLogger=INFO, stdout - -# Redirect log messages to console -log4j.appender.stdout=org.apache.log4j.ConsoleAppender -log4j.appender.stdout.Target=System.out -log4j.appender.stdout.layout=org.apache.log4j.PatternLayout -log4j.appender.stdout.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n \ No newline at end of file From 7298cf53b03af2ee1c51cd4c76921bff4203c997 Mon Sep 17 00:00:00 2001 From: mnoman09 Date: Thu, 6 Jan 2022 04:49:11 +0500 Subject: [PATCH 066/147] move validate forced decision to decision service (#457) * Moved validated forced decision to decision service and fixed tests * comment fix * Updated Header Co-authored-by: mnoman09 --- .../java/com/optimizely/ab/Optimizely.java | 24 +--------- .../optimizely/ab/OptimizelyUserContext.java | 32 +------------- .../ab/bucketing/DecisionService.java | 40 +++++++++++++++-- .../ab/config/DatafileProjectConfig.java | 23 +++++++++- .../optimizely/ab/config/ProjectConfig.java | 4 +- .../com/optimizely/ab/OptimizelyTest.java | 4 +- .../ab/OptimizelyUserContextTest.java | 40 +---------------- .../ab/bucketing/DecisionServiceTest.java | 44 ++++++++++++++++++- 8 files changed, 112 insertions(+), 99 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index c53095692..7eae1a1d0 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2016-2021, Optimizely, Inc. and contributors * + * Copyright 2016-2022, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -1185,7 +1185,7 @@ OptimizelyDecision decide(@Nonnull OptimizelyUserContext user, // Check Forced Decision OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flag.getKey(), null); - DecisionResponse forcedDecisionVariation = user.findValidatedForcedDecision(optimizelyDecisionContext); + DecisionResponse forcedDecisionVariation = decisionService.validatedForcedDecision(optimizelyDecisionContext, projectConfig, user); decisionReasons.merge(forcedDecisionVariation.getReasons()); if (forcedDecisionVariation.getResult() != null) { flagDecision = new FeatureDecision(null, forcedDecisionVariation.getResult(), FeatureDecision.DecisionSource.FEATURE_TEST); @@ -1344,26 +1344,6 @@ private DecisionResponse> getDecisionVariableMap(@Nonnull Fe return new DecisionResponse(valuesMap, reasons); } - /** - * Gets a variation based on flagKey and variationKey - * - * @param flagKey The flag key for the variation - * @param variationKey The variation key for the variation - * @return Returns a variation based on flagKey and variationKey, otherwise null - */ - public Variation getFlagVariationByKey(String flagKey, String variationKey) { - Map> flagVariationsMap = getProjectConfig().getFlagVariationsMap(); - if (flagVariationsMap.containsKey(flagKey)) { - List variations = flagVariationsMap.get(flagKey); - for (Variation variation : variations) { - if (variation.getKey().equals(variationKey)) { - return variation; - } - } - } - return null; - } - /** * Helper method which makes separate copy of attributesMap variable and returns it * diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java index 53eb26cf6..d05df3bbb 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -1,6 +1,6 @@ /** * - * Copyright 2020-2021, Optimizely and contributors + * Copyright 2020-2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -265,35 +265,7 @@ public boolean removeAllForcedDecisions() { return true; } - /** - * Find a validated forced decision - * - * @param optimizelyDecisionContext The OptimizelyDecisionContext containing flagKey and ruleKey - * @return Returns a DecisionResponse structure of type Variation, otherwise null result with reasons - */ - public DecisionResponse findValidatedForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDecisionContext) { - DecisionReasons reasons = DefaultDecisionReasons.newInstance(); - OptimizelyForcedDecision optimizelyForcedDecision = findForcedDecision(optimizelyDecisionContext); - String variationKey = optimizelyForcedDecision != null ? optimizelyForcedDecision.getVariationKey() : null; - if (variationKey != null) { - Variation variation = optimizely.getFlagVariationByKey(optimizelyDecisionContext.getFlagKey(), variationKey); - String ruleKey = optimizelyDecisionContext.getRuleKey(); - String flagKey = optimizelyDecisionContext.getFlagKey(); - String info; - String target = ruleKey != OptimizelyDecisionContext.OPTI_NULL_RULE_KEY ? String.format("flag (%s), rule (%s)", flagKey, ruleKey) : String.format("flag (%s)", flagKey); - if (variation != null) { - info = String.format("Variation (%s) is mapped to %s and user (%s) in the forced decision map.", variationKey, target, userId); - logger.debug(info); - reasons.addInfo(info); - return new DecisionResponse(variation, reasons); - } else { - info = String.format("Invalid variation is mapped to %s and user (%s) in the forced decision map.", target, userId); - logger.debug(info); - reasons.addInfo(info); - } - } - return new DecisionResponse<>(null, reasons); - } + // Utils diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java index e386c360d..c7ee0b3f3 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2017-2021, Optimizely, Inc. and contributors * + * Copyright 2017-2022, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -16,6 +16,7 @@ package com.optimizely.ab.bucketing; import com.optimizely.ab.OptimizelyDecisionContext; +import com.optimizely.ab.OptimizelyForcedDecision; import com.optimizely.ab.OptimizelyRuntimeException; import com.optimizely.ab.OptimizelyUserContext; import com.optimizely.ab.config.*; @@ -469,6 +470,39 @@ String getBucketingId(@Nonnull String userId, return bucketingId; } + /** + * Find a validated forced decision + * + * @param optimizelyDecisionContext The OptimizelyDecisionContext containing flagKey and ruleKey + * @param projectConfig The Project config + * @param user The OptimizelyUserContext + * @return Returns a DecisionResponse structure of type Variation, otherwise null result with reasons + */ + public DecisionResponse validatedForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDecisionContext, @Nonnull ProjectConfig projectConfig, @Nonnull OptimizelyUserContext user) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + String userId = user.getUserId(); + OptimizelyForcedDecision optimizelyForcedDecision = user.findForcedDecision(optimizelyDecisionContext); + String variationKey = optimizelyForcedDecision != null ? optimizelyForcedDecision.getVariationKey() : null; + if (projectConfig != null && variationKey != null) { + Variation variation = projectConfig.getFlagVariationByKey(optimizelyDecisionContext.getFlagKey(), variationKey); + String ruleKey = optimizelyDecisionContext.getRuleKey(); + String flagKey = optimizelyDecisionContext.getFlagKey(); + String info; + String target = ruleKey != OptimizelyDecisionContext.OPTI_NULL_RULE_KEY ? String.format("flag (%s), rule (%s)", flagKey, ruleKey) : String.format("flag (%s)", flagKey); + if (variation != null) { + info = String.format("Variation (%s) is mapped to %s and user (%s) in the forced decision map.", variationKey, target, userId); + logger.debug(info); + reasons.addInfo(info); + return new DecisionResponse(variation, reasons); + } else { + info = String.format("Invalid variation is mapped to %s and user (%s) in the forced decision map.", target, userId); + logger.debug(info); + reasons.addInfo(info); + } + } + return new DecisionResponse<>(null, reasons); + } + public ConcurrentHashMap> getForcedVariationMapping() { return forcedVariationMapping; } @@ -591,7 +625,7 @@ public DecisionResponse getVariationFromExperimentRule(@Nonnull Proje String ruleKey = rule != null ? rule.getKey() : null; // Check Forced-Decision OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); - DecisionResponse forcedDecisionResponse = user.findValidatedForcedDecision(optimizelyDecisionContext); + DecisionResponse forcedDecisionResponse = validatedForcedDecision(optimizelyDecisionContext, projectConfig, user); reasons.merge(forcedDecisionResponse.getReasons()); @@ -640,7 +674,7 @@ DecisionResponse getVariationFromDeliveryRule(@Nonnull // Check forced-decisions first Experiment rule = rules.get(ruleIndex); OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, rule.getKey()); - DecisionResponse forcedDecisionResponse = user.findValidatedForcedDecision(optimizelyDecisionContext); + DecisionResponse forcedDecisionResponse = validatedForcedDecision(optimizelyDecisionContext, projectConfig, user); reasons.merge(forcedDecisionResponse.getReasons()); Variation variation = forcedDecisionResponse.getResult(); diff --git a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java index 831563826..9620f5cbf 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2021, Optimizely and contributors + * Copyright 2016-2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -503,6 +503,27 @@ public Map> getFlagVariationsMap() { return flagVariationsMap; } + /** + * Gets a variation based on flagKey and variationKey + * + * @param flagKey The flag key for the variation + * @param variationKey The variation key for the variation + * @return Returns a variation based on flagKey and variationKey, otherwise null + */ + @Override + public Variation getFlagVariationByKey(String flagKey, String variationKey) { + Map> flagVariationsMap = getFlagVariationsMap(); + if (flagVariationsMap.containsKey(flagKey)) { + List variations = flagVariationsMap.get(flagKey); + for (Variation variation : variations) { + if (variation.getKey().equals(variationKey)) { + return variation; + } + } + } + return null; + } + @Override public String toString() { return "ProjectConfig{" + diff --git a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java index 9c3321708..10ebdc832 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2021, Optimizely and contributors + * Copyright 2016-2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -105,6 +105,8 @@ Experiment getExperimentForKey(@Nonnull String experimentKey, Map> getFlagVariationsMap(); + Variation getFlagVariationByKey(String flagKey, String variationKey); + @Override String toString(); diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java index 0a9c334f8..2cab4a01e 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2016-2020, Optimizely, Inc. and contributors * + * Copyright 2016-2022, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -4660,7 +4660,7 @@ public void getFlagVariationByKey() throws IOException { String flagKey = "double_single_variable_feature"; String variationKey = "pi_variation"; Optimizely optimizely = Optimizely.builder().withDatafile(validConfigJsonV4()).build(); - Variation variation = optimizely.getFlagVariationByKey(flagKey, variationKey); + Variation variation = optimizely.getProjectConfig().getFlagVariationByKey(flagKey, variationKey); assertNotNull(variation); assertEquals(variationKey, variation.getKey()); diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java index ef91ca596..c196938c4 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2021, Optimizely and contributors + * Copyright 2021-2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -1442,44 +1442,6 @@ public void removeAllForcedDecisions() { assertTrue(optimizelyUserContext.removeAllForcedDecisions()); } - - @Test - public void findValidatedForcedDecisionWithRuleKey() { - String ruleKey = "77777"; - String flagKey = "feature_2"; - String variationKey = "variation_no_traffic"; - OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( - optimizely, - userId, - Collections.emptyMap()); - - OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); - OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); - - optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); - DecisionResponse response = optimizelyUserContext.findValidatedForcedDecision(optimizelyDecisionContext); - Variation variation = response.getResult(); - assertEquals(variationKey, variation.getKey()); - } - - @Test - public void findValidatedForcedDecisionWithoutRuleKey() { - String flagKey = "feature_2"; - String variationKey = "variation_no_traffic"; - OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( - optimizely, - userId, - Collections.emptyMap()); - - OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); - OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); - - optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); - DecisionResponse response = optimizelyUserContext.findValidatedForcedDecision(optimizelyDecisionContext); - Variation variation = response.getResult(); - assertEquals(variationKey, variation.getKey()); - } - @Test public void setForcedDecisionsAndCallDecide() { String flagKey = "feature_2"; diff --git a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java index eddbf0178..6057b43cf 100644 --- a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2017-2020, Optimizely, Inc. and contributors * + * Copyright 2017-2022, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -17,6 +17,8 @@ import ch.qos.logback.classic.Level; import com.optimizely.ab.Optimizely; +import com.optimizely.ab.OptimizelyDecisionContext; +import com.optimizely.ab.OptimizelyForcedDecision; import com.optimizely.ab.OptimizelyUserContext; import com.optimizely.ab.config.*; import com.optimizely.ab.error.ErrorHandler; @@ -35,6 +37,7 @@ import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.*; import static com.optimizely.ab.config.ValidProjectConfigV4.*; +import static junit.framework.TestCase.assertEquals; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.*; @@ -761,6 +764,45 @@ public void getVariationFromExperimentRuleTest() { assertEquals(expectedVariation, decisionResponse.getResult()); } + @Test + public void validatedForcedDecisionWithRuleKey() { + String userId = "testUser1"; + String ruleKey = "2637642575"; + String flagKey = "multi_variate_feature"; + String variationKey = "2346257680"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + DecisionResponse response = decisionService.validatedForcedDecision(optimizelyDecisionContext, v4ProjectConfig, optimizelyUserContext); + Variation variation = response.getResult(); + assertEquals(variationKey, variation.getKey()); + } + + @Test + public void validatedForcedDecisionWithoutRuleKey() { + String userId = "testUser1"; + String flagKey = "multi_variate_feature"; + String variationKey = "521740985"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + DecisionResponse response = decisionService.validatedForcedDecision(optimizelyDecisionContext, v4ProjectConfig, optimizelyUserContext); + Variation variation = response.getResult(); + assertEquals(variationKey, variation.getKey()); + } + //========= white list tests ==========/ /** From 44041805b4ed56386959cfb4ea2f5454fe695487 Mon Sep 17 00:00:00 2001 From: mnoman09 Date: Tue, 11 Jan 2022 23:18:22 +0500 Subject: [PATCH 067/147] chore: prepare for release 3.10.0 (#458) ## Summary - prepare for release 3.10.0 --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b88169576..6d7d1bf62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Optimizely Java X SDK Changelog +## 3.10.0 +January 10th, 2022 + +### New Features +* Add a set of new APIs for overriding and managing user-level flag, experiment and delivery rule decisions. These methods can be used for QA and automated testing purposes. They are an extension of the OptimizelyUserContext interface ([#451](https://github.com/optimizely/java-sdk/pull/451), [#454](https://github.com/optimizely/java-sdk/pull/454), [#455](https://github.com/optimizely/java-sdk/pull/455), [#457](https://github.com/optimizely/java-sdk/pull/457)) + - setForcedDecision + - getForcedDecision + - removeForcedDecision + - removeAllForcedDecisions + +- For details, refer to our documentation pages: [OptimizelyUserContext](https://docs.developers.optimizely.com/full-stack/v4.0/docs/optimizelyusercontext-java) and [Forced Decision methods](https://docs.developers.optimizely.com/full-stack/v4.0/docs/forced-decision-methods-java). + ## [3.9.0] September 16th, 2021 From 535372cb504c934154e62971e8cddd5e354ca6f7 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Mon, 24 Jan 2022 12:26:25 -0800 Subject: [PATCH 068/147] chore: remove Jcenter deadline for old SDK versions (#461) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3bf5cac4f..c2aa60f86 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ following in your `build.gradle` and substitute `VERSION` for the latest SDK ver --- **NOTE** -[Bintray/JCenter will be shut down](https://jfrog.com/blog/into-the-sunset-bintray-jcenter-gocenter-and-chartcenter/). The publish repository has been migrated to MavenCentral for the SDK version 3.8.1 or later. Older versions will be available in JCenter until February 1st, 2022. +[Bintray/JCenter will be shut down](https://jfrog.com/blog/into-the-sunset-bintray-jcenter-gocenter-and-chartcenter/). The publish repository has been migrated to MavenCentral for the SDK version 3.8.1 or later. Older versions will be available in JCenter. --- From d4c37aea2f05e13f1051b668928cc2a348b14e9d Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Thu, 3 Feb 2022 11:19:07 -0800 Subject: [PATCH 069/147] fix(NotificationManager): add thread-safety to NotificationManager (#460) Fix NotificationManager to be thread-safe (add-handler and send-notifications can happen concurrently) --- .../ab/notification/NotificationManager.java | 25 ++++++++------ .../notification/NotificationManagerTest.java | 33 +++++++++++++++++++ 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/notification/NotificationManager.java b/core-api/src/main/java/com/optimizely/ab/notification/NotificationManager.java index 5254d76b8..7415e6b23 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/NotificationManager.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/NotificationManager.java @@ -19,6 +19,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; @@ -33,7 +34,7 @@ public class NotificationManager { private static final Logger logger = LoggerFactory.getLogger(NotificationManager.class); - private final Map> handlers = new LinkedHashMap<>(); + private final Map> handlers = Collections.synchronizedMap(new LinkedHashMap<>()); private final AtomicInteger counter; public NotificationManager() { @@ -47,10 +48,12 @@ public NotificationManager(AtomicInteger counter) { public int addHandler(NotificationHandler newHandler) { // Prevent registering a duplicate listener. - for (NotificationHandler handler: handlers.values()) { - if (handler.equals(newHandler)) { - logger.warn("Notification listener was already added"); - return -1; + synchronized (handlers) { + for (NotificationHandler handler : handlers.values()) { + if (handler.equals(newHandler)) { + logger.warn("Notification listener was already added"); + return -1; + } } } @@ -61,11 +64,13 @@ public int addHandler(NotificationHandler newHandler) { } public void send(final T message) { - for (Map.Entry> handler: handlers.entrySet()) { - try { - handler.getValue().handle(message); - } catch (Exception e) { - logger.warn("Catching exception sending notification for class: {}, handler: {}", message.getClass(), handler.getKey()); + synchronized (handlers) { + for (Map.Entry> handler: handlers.entrySet()) { + try { + handler.getValue().handle(message); + } catch (Exception e) { + logger.warn("Catching exception sending notification for class: {}, handler: {}", message.getClass(), handler.getKey()); + } } } } diff --git a/core-api/src/test/java/com/optimizely/ab/notification/NotificationManagerTest.java b/core-api/src/test/java/com/optimizely/ab/notification/NotificationManagerTest.java index c51a84e3f..58767ac7a 100644 --- a/core-api/src/test/java/com/optimizely/ab/notification/NotificationManagerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/notification/NotificationManagerTest.java @@ -20,6 +20,11 @@ import org.junit.Test; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.*; @@ -70,4 +75,32 @@ public void testSendWithError() { assertEquals(1, messages.size()); assertEquals("message1", messages.get(0).getMessage()); } + + @Test + public void testThreadSafety() throws InterruptedException { + int numThreads = 10; + int numRepeats = 2; + ExecutorService executor = Executors.newFixedThreadPool(numThreads); + CountDownLatch latch = new CountDownLatch(numThreads); + AtomicBoolean failedAlready = new AtomicBoolean(false); + + for(int i = 0; i < numThreads; i++) { + executor.execute(() -> { + try { + for (int j = 0; j < numRepeats; j++) { + if(!failedAlready.get()) { + notificationManager.addHandler(new TestNotificationHandler<>()); + notificationManager.send(new TestNotification("message1")); + } + } + } catch (Exception e) { + failedAlready.set(true); + } finally { + latch.countDown(); + } + }); + } + assertTrue(latch.await(10, TimeUnit.SECONDS)); + assertEquals(numThreads * numRepeats, notificationManager.size()); + } } From 77fb91404052d4c93f685f2dadfbc280457e8d86 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Thu, 3 Feb 2022 12:03:25 -0800 Subject: [PATCH 070/147] prepare for release 3.10.1 (#462) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d7d1bf62..ca3935252 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Optimizely Java X SDK Changelog +## [3.10.1] +February 3rd, 2022 + +### Fixes +- Fix NotificationManager to be thread-safe (add-handler and send-notifications can happen concurrently) ([#460](https://github.com/optimizely/java-sdk/pull/460)). + ## 3.10.0 January 10th, 2022 From 206ae8be0de9580863a05598a02c0337e0ea9ee4 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Tue, 15 Feb 2022 16:51:49 -0800 Subject: [PATCH 071/147] chore: fix http-httpclient module tests not included in travis (#464) --- build.gradle | 2 +- core-httpclient-impl/build.gradle | 4 ++++ .../test/java/com/optimizely/ab/OptimizelyHttpClientTest.java | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index e0e14fa9d..4c167014f 100644 --- a/build.gradle +++ b/build.gradle @@ -16,7 +16,7 @@ allprojects { } jacoco { - toolVersion = '0.8.0' + toolVersion = '0.8.7' } } diff --git a/core-httpclient-impl/build.gradle b/core-httpclient-impl/build.gradle index 7e452d36e..b43c70269 100644 --- a/core-httpclient-impl/build.gradle +++ b/core-httpclient-impl/build.gradle @@ -5,3 +5,7 @@ dependencies { compile group: 'org.apache.httpcomponents', name: 'httpclient', version: httpClientVersion } + +task exhaustiveTest { + dependsOn('test') +} diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyHttpClientTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyHttpClientTest.java index 7dc61f0f9..4667bec34 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyHttpClientTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyHttpClientTest.java @@ -40,6 +40,8 @@ public class OptimizelyHttpClientTest { @Before public void setUp() { System.setProperty("https.proxyHost", "localhost"); + // default port (80) returns 404 instead of HttpHostConnectException + System.setProperty("https.proxyPort", "12345"); } @After From d8df15987b0060880bd3795f319be4e032b1b9c5 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Tue, 15 Feb 2022 17:09:55 -0800 Subject: [PATCH 072/147] fix(audience): log debug instead of warning for missing attribute value (#463) --- .../ab/config/audience/match/ExactMatch.java | 6 ++-- .../match/SemanticVersionEqualsMatch.java | 3 +- .../match/SemanticVersionGEMatch.java | 3 +- .../match/SemanticVersionGTMatch.java | 3 +- .../match/SemanticVersionLEMatch.java | 3 +- .../match/SemanticVersionLTMatch.java | 3 +- .../AudienceConditionEvaluationTest.java | 36 ++++++++++++++----- .../audience/match/SemanticVersionTest.java | 15 ++++++-- 8 files changed, 55 insertions(+), 17 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExactMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExactMatch.java index 5781ac892..d39d00c83 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExactMatch.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExactMatch.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018-2020, Optimizely and contributors + * Copyright 2018-2020, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,8 @@ class ExactMatch implements Match { @Nullable public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + if (attributeValue == null) return null; + if (isValidNumber(attributeValue)) { if (isValidNumber(conditionValue)) { return NumberComparator.compareUnsafe(attributeValue, conditionValue) == 0; @@ -39,7 +41,7 @@ public Boolean eval(Object conditionValue, Object attributeValue) throws Unexpec throw new UnexpectedValueTypeException(); } - if (attributeValue == null || attributeValue.getClass() != conditionValue.getClass()) { + if (attributeValue.getClass() != conditionValue.getClass()) { return null; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionEqualsMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionEqualsMatch.java index ac0c8310b..58ecb4202 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionEqualsMatch.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionEqualsMatch.java @@ -1,6 +1,6 @@ /** * - * Copyright 2020, Optimizely and contributors + * Copyright 2020, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ class SemanticVersionEqualsMatch implements Match { @Nullable public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + if (attributeValue == null) return null; // stay silent (no WARNING) when attribute value is missing or empty. return SemanticVersion.compare(attributeValue, conditionValue) == 0; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGEMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGEMatch.java index 91f95d4cd..bad0b1e4f 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGEMatch.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGEMatch.java @@ -1,6 +1,6 @@ /** * - * Copyright 2020, Optimizely and contributors + * Copyright 2020, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,6 +25,7 @@ class SemanticVersionGEMatch implements Match { @Nullable public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + if (attributeValue == null) return null; // stay silent (no WARNING) when attribute value is missing or empty. return SemanticVersion.compare(attributeValue, conditionValue) >= 0; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGTMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGTMatch.java index 52513024c..7d403f693 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGTMatch.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGTMatch.java @@ -1,6 +1,6 @@ /** * - * Copyright 2020, Optimizely and contributors + * Copyright 2020, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ class SemanticVersionGTMatch implements Match { @Nullable public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + if (attributeValue == null) return null; // stay silent (no WARNING) when attribute value is missing or empty. return SemanticVersion.compare(attributeValue, conditionValue) > 0; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLEMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLEMatch.java index 4297d4545..b3aed672e 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLEMatch.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLEMatch.java @@ -1,6 +1,6 @@ /** * - * Copyright 2020, Optimizely and contributors + * Copyright 2020, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,6 +25,7 @@ class SemanticVersionLEMatch implements Match { @Nullable public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + if (attributeValue == null) return null; // stay silent (no WARNING) when attribute value is missing or empty. return SemanticVersion.compare(attributeValue, conditionValue) <= 0; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLTMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLTMatch.java index a35dcd2da..d65251f54 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLTMatch.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLTMatch.java @@ -1,6 +1,6 @@ /** * - * Copyright 2020, Optimizely and contributors + * Copyright 2020, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ class SemanticVersionLTMatch implements Match { @Nullable public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + if (attributeValue == null) return null; // stay silent (no WARNING) when attribute value is missing or empty. return SemanticVersion.compare(attributeValue, conditionValue) < 0; } } diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java index 481be0f23..07a0c1ad1 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2020, Optimizely and contributors + * Copyright 2016-2020, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package com.optimizely.ab.config.audience; import ch.qos.logback.classic.Level; +import com.fasterxml.jackson.databind.deser.std.MapEntryDeserializer; import com.optimizely.ab.internal.LogbackVerifier; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Before; @@ -191,16 +192,35 @@ public void unexpectedAttributeTypeNull() throws Exception { "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because a null value was passed for user attribute \"browser_type\""); } - /** - * Verify that UserAttribute.evaluate returns null on missing attribute value. + * Verify that UserAttribute.evaluate returns null (and log debug message) on missing attribute value. */ @Test - public void missingAttribute() throws Exception { - UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "gt", 20); - assertNull(testInstance.evaluate(null, Collections.EMPTY_MAP)); - logbackVerifier.expectMessage(Level.DEBUG, - "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because no value was passed for user attribute \"browser_type\""); + public void missingAttribute_returnsNullAndLogDebugMessage() throws Exception { + // check with all valid value types for each match + + Map items = new HashMap<>(); + items.put("exact", new Object[]{"string", 123, true}); + items.put("substring", new Object[]{"string"}); + items.put("gt", new Object[]{123, 5.3}); + items.put("ge", new Object[]{123, 5.3}); + items.put("lt", new Object[]{123, 5.3}); + items.put("le", new Object[]{123, 5.3}); + items.put("semver_eq", new Object[]{"1.2.3"}); + items.put("semver_ge", new Object[]{"1.2.3"}); + items.put("semver_gt", new Object[]{"1.2.3"}); + items.put("semver_le", new Object[]{"1.2.3"}); + items.put("semver_lt", new Object[]{"1.2.3"}); + + for (Map.Entry entry : items.entrySet()) { + for (Object value : entry.getValue()) { + UserAttribute testInstance = new UserAttribute("n", "custom_attribute", entry.getKey(), value); + assertNull(testInstance.evaluate(null, Collections.EMPTY_MAP)); + String valueStr = (value instanceof String) ? ("'" + value + "'") : value.toString(); + logbackVerifier.expectMessage(Level.DEBUG, + "Audience condition \"{name='n', type='custom_attribute', match='" + entry.getKey() + "', value=" + valueStr + "}\" evaluated to UNKNOWN because no value was passed for user attribute \"n\""); + } + } } /** diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/match/SemanticVersionTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/match/SemanticVersionTest.java index 1b819d418..29383a7d7 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/audience/match/SemanticVersionTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/match/SemanticVersionTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2020, Optimizely and contributors + * Copyright 2020, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,7 +26,6 @@ public class SemanticVersionTest { @Rule public ExpectedException thrown = ExpectedException.none(); - @Test public void semanticVersionInvalidOnlyDash() throws Exception { thrown.expect(Exception.class); @@ -165,4 +164,16 @@ public void testGreaterThan() throws Exception { assertTrue(SemanticVersion.compare("3.7.1-prerelease-prerelease+rc", "3.7.1-prerelease+build") > 0); assertTrue(SemanticVersion.compare("3.7.1-beta.2", "3.7.1-beta.1") > 0); } + + @Test + public void testSilentForNullOrMissingAttributesValues() throws Exception { + // SemanticVersionMatcher will throw UnexpectedValueType exception for invalid condition or attribute values (this exception is handled to log WARNING messages). + // But, for missing (or null) attribute value, it should not throw the exception. + assertNull(new SemanticVersionEqualsMatch().eval("1.2.3", null)); + assertNull(new SemanticVersionGEMatch().eval("1.2.3", null)); + assertNull(new SemanticVersionGTMatch().eval("1.2.3", null)); + assertNull(new SemanticVersionLEMatch().eval("1.2.3", null)); + assertNull(new SemanticVersionLTMatch().eval("1.2.3", null)); + } + } From d3dfb974f14a74cf369243a48636210539101a05 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Thu, 3 Mar 2022 16:57:49 -0800 Subject: [PATCH 073/147] remove a separate settings.gradle from quickstart (#465) --- java-quickstart/gradle/wrapper/gradle-wrapper.properties | 2 +- java-quickstart/settings.gradle | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 java-quickstart/settings.gradle diff --git a/java-quickstart/gradle/wrapper/gradle-wrapper.properties b/java-quickstart/gradle/wrapper/gradle-wrapper.properties index 933b6473c..3c46198fc 100644 --- a/java-quickstart/gradle/wrapper/gradle-wrapper.properties +++ b/java-quickstart/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip diff --git a/java-quickstart/settings.gradle b/java-quickstart/settings.gradle deleted file mode 100644 index 8cd2bdccd..000000000 --- a/java-quickstart/settings.gradle +++ /dev/null @@ -1,2 +0,0 @@ -rootProject.name = 'java-quickstart' - From 81fdfb2dd4c099f44acd7ddf523de52d01c84fc3 Mon Sep 17 00:00:00 2001 From: Trisha Hanlon Date: Thu, 10 Mar 2022 16:54:16 -0600 Subject: [PATCH 074/147] Create README.md (#467) --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c2aa60f86..62c75d880 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,12 @@ Optimizely Rollouts is free feature flags for development teams. Easily roll out #### Gradle -The SDK is available through Bintray and is created with source and target compatibility of 1.8. The core-api and httpclient Bintray packages are [optimizely-sdk-core-api](https://bintray.com/optimizely/optimizely/optimizely-sdk-core-api) -and [optimizely-sdk-httpclient](https://bintray.com/optimizely/optimizely/optimizely-sdk-httpclient) respectively. To install, place the -following in your `build.gradle` and substitute `VERSION` for the latest SDK version available via MavenCentral. +The Java SDK is distributed through Maven Central and is created with source and target compatibility of Java 1.8. The `core-api` and `httpclient` packages are [optimizely-sdk-core-api](https://mvnrepository.com/artifact/com.optimizely.ab/core-api) and [optimizely-sdk-httpclient](https://mvnrepository.com/artifact/com.optimizely.ab/core-httpclient-impl), respectively. --- **NOTE** -[Bintray/JCenter will be shut down](https://jfrog.com/blog/into-the-sunset-bintray-jcenter-gocenter-and-chartcenter/). The publish repository has been migrated to MavenCentral for the SDK version 3.8.1 or later. Older versions will be available in JCenter. +Optimizely previously distributed the Java SDK through Bintray/JCenter. But, as of April 27, 2021, [Bintray/JCenter will become a read-only repository indefinitely](https://jfrog.com/blog/into-the-sunset-bintray-jcenter-gocenter-and-chartcenter/). The publish repository has been migrated to [MavenCentral](https://mvnrepository.com/artifact/com.optimizely.ab) for the SDK version 3.8.1 or later. --- From 336b610e60e17e7f2389633f19ed981215bcbfdd Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Tue, 15 Mar 2022 08:44:21 -0700 Subject: [PATCH 075/147] fix: add Optimizely builder option for client-engine info (#466) --- .../java/com/optimizely/ab/Optimizely.java | 20 +++++-- .../ab/event/internal/BuildVersionInfo.java | 20 ++++++- .../ab/event/internal/EventFactory.java | 4 +- .../ab/event/internal/payload/EventBatch.java | 4 +- .../optimizely/ab/OptimizelyBuilderTest.java | 55 ++++++++++++++++++- .../ab/event/internal/EventFactoryTest.java | 14 ++--- 6 files changed, 96 insertions(+), 21 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 7eae1a1d0..51335ec4f 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -25,10 +25,7 @@ import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.error.NoOpErrorHandler; import com.optimizely.ab.event.*; -import com.optimizely.ab.event.internal.ClientEngineInfo; -import com.optimizely.ab.event.internal.EventFactory; -import com.optimizely.ab.event.internal.UserEvent; -import com.optimizely.ab.event.internal.UserEventFactory; +import com.optimizely.ab.event.internal.*; import com.optimizely.ab.event.internal.payload.EventBatch; import com.optimizely.ab.notification.*; import com.optimizely.ab.optimizelyconfig.OptimizelyConfig; @@ -1489,7 +1486,7 @@ public Builder withErrorHandler(ErrorHandler errorHandler) { } /** - * The withEventHandler has has been moved to the EventProcessor which takes a EventHandler in it's builder + * The withEventHandler has been moved to the EventProcessor which takes a EventHandler in it's builder * method. * {@link com.optimizely.ab.event.BatchEventProcessor.Builder#withEventHandler(com.optimizely.ab.event.EventHandler)} label} * Please use that builder method instead. @@ -1519,6 +1516,19 @@ public Builder withUserProfileService(UserProfileService userProfileService) { return this; } + /** + * Override the SDK name and version (for client SDKs like android-sdk wrapping the core java-sdk) to be included in events. + * + * @param clientEngine the client engine type. + * @param clientVersion the client SDK version. + * @return An Optimizely builder + */ + public Builder withClientInfo(EventBatch.ClientEngine clientEngine, String clientVersion) { + ClientEngineInfo.setClientEngine(clientEngine); + BuildVersionInfo.setClientVersion(clientVersion); + return this; + } + @Deprecated public Builder withClientEngine(EventBatch.ClientEngine clientEngine) { logger.info("Deprecated. In the future, set ClientEngine via ClientEngineInfo#setClientEngine."); diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/BuildVersionInfo.java b/core-api/src/main/java/com/optimizely/ab/event/internal/BuildVersionInfo.java index 3aea4d878..d5620b4e9 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/BuildVersionInfo.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/BuildVersionInfo.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, 2019, Optimizely and contributors + * Copyright 2016-2017, 2019, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,13 +30,29 @@ /** * Helper class to retrieve the SDK version information. */ -@Immutable public final class BuildVersionInfo { private static final Logger logger = LoggerFactory.getLogger(BuildVersionInfo.class); + @Deprecated public final static String VERSION = readVersionNumber(); + // can be overridden by other wrapper client (android-sdk, etc) + + private static String clientVersion = readVersionNumber(); + + public static void setClientVersion(String version) { + if (version == null || version.isEmpty()) { + logger.warn("ClientVersion cannot be empty, defaulting to the core java-sdk version."); + return; + } + clientVersion = version; + } + + public static String getClientVersion() { + return clientVersion; + } + private static String readVersionNumber() { BufferedReader bufferedReader = null; try { diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java b/core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java index 5a881128d..f651be851 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2020, Optimizely and contributors + * Copyright 2016-2020, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -73,7 +73,7 @@ public static LogEvent createLogEvent(List userEvents) { builder .setClientName(ClientEngineInfo.getClientEngine().getClientEngineValue()) - .setClientVersion(BuildVersionInfo.VERSION) + .setClientVersion(BuildVersionInfo.getClientVersion()) .setAccountId(projectConfig.getAccountId()) .setAnonymizeIp(projectConfig.getAnonymizeIP()) .setProjectId(projectConfig.getProjectId()) diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/EventBatch.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/EventBatch.java index fe06b631f..c50ee6288 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/EventBatch.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/EventBatch.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018-2019, Optimizely and contributors + * Copyright 2018-2019, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -165,7 +165,7 @@ public int hashCode() { public static class Builder { private String clientName = ClientEngine.JAVA_SDK.getClientEngineValue(); - private String clientVersion = BuildVersionInfo.VERSION; + private String clientVersion = BuildVersionInfo.getClientVersion(); private String accountId; private List visitors; private Boolean anonymizeIp; diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java index 932150337..91bb19e18 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, 2019, Optimizely and contributors + * Copyright 2016-2017, 2019, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,12 +20,17 @@ import com.optimizely.ab.config.*; import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.error.NoOpErrorHandler; +import com.optimizely.ab.event.BatchEventProcessor; import com.optimizely.ab.event.EventHandler; +import com.optimizely.ab.event.LogEvent; +import com.optimizely.ab.event.internal.BuildVersionInfo; +import com.optimizely.ab.event.internal.payload.EventBatch; import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; @@ -34,11 +39,14 @@ import java.util.List; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.*; +import static junit.framework.Assert.assertEquals; import static org.hamcrest.CoreMatchers.instanceOf; import static org.hamcrest.CoreMatchers.is; import static org.junit.Assert.*; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.never; /** * Tests for {@link Optimizely#builder(String, EventHandler)}. @@ -195,4 +203,45 @@ public void withDefaultDecideOptions() throws Exception { assertEquals(optimizelyClient.defaultDecideOptions.get(2), OptimizelyDecideOption.EXCLUDE_VARIABLES); } + @Test + public void withClientInfo() throws Exception { + Optimizely optimizely; + EventHandler eventHandler; + ArgumentCaptor argument = ArgumentCaptor.forClass(LogEvent.class); + + // default client-engine info (java-sdk) + + eventHandler = mock(EventHandler.class); + optimizely = Optimizely.builder(validConfigJsonV4(), eventHandler).build(); + optimizely.track("basic_event", "tester"); + + verify(eventHandler, timeout(5000)).dispatchEvent(argument.capture()); + assertEquals(argument.getValue().getEventBatch().getClientName(), "java-sdk"); + assertEquals(argument.getValue().getEventBatch().getClientVersion(), BuildVersionInfo.getClientVersion()); + + // invalid override with null inputs + + reset(eventHandler); + optimizely = Optimizely.builder(validConfigJsonV4(), eventHandler) + .withClientInfo(null, null) + .build(); + optimizely.track("basic_event", "tester"); + + verify(eventHandler, timeout(5000)).dispatchEvent(argument.capture()); + assertEquals(argument.getValue().getEventBatch().getClientName(), "java-sdk"); + assertEquals(argument.getValue().getEventBatch().getClientVersion(), BuildVersionInfo.getClientVersion()); + + // override client-engine info + + reset(eventHandler); + optimizely = Optimizely.builder(validConfigJsonV4(), eventHandler) + .withClientInfo(EventBatch.ClientEngine.ANDROID_SDK, "1.2.3") + .build(); + optimizely.track("basic_event", "tester"); + + verify(eventHandler, timeout(5000)).dispatchEvent(argument.capture()); + assertEquals(argument.getValue().getEventBatch().getClientName(), "android-sdk"); + assertEquals(argument.getValue().getEventBatch().getClientVersion(), "1.2.3"); + } + } diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java index 1c5a48313..657bc4fbf 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2020, Optimizely and contributors + * Copyright 2016-2020, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -157,7 +157,7 @@ public void createImpressionEventPassingUserAgentAttribute() throws Exception { assertThat(eventBatch.getAccountId(), is(validProjectConfig.getAccountId())); assertThat(eventBatch.getVisitors().get(0).getAttributes(), is(expectedUserFeatures)); assertThat(eventBatch.getClientName(), is(EventBatch.ClientEngine.JAVA_SDK.getClientEngineValue())); - assertThat(eventBatch.getClientVersion(), is(BuildVersionInfo.VERSION)); + assertThat(eventBatch.getClientVersion(), is(BuildVersionInfo.getClientVersion())); assertNull(eventBatch.getVisitors().get(0).getSessionId()); } @@ -224,7 +224,7 @@ public void createImpressionEvent() throws Exception { assertThat(eventBatch.getAccountId(), is(validProjectConfig.getAccountId())); assertThat(eventBatch.getVisitors().get(0).getAttributes(), is(expectedUserFeatures)); assertThat(eventBatch.getClientName(), is(EventBatch.ClientEngine.JAVA_SDK.getClientEngineValue())); - assertThat(eventBatch.getClientVersion(), is(BuildVersionInfo.VERSION)); + assertThat(eventBatch.getClientVersion(), is(BuildVersionInfo.getClientVersion())); assertNull(eventBatch.getVisitors().get(0).getSessionId()); } @@ -649,7 +649,7 @@ public void createConversionEvent() throws Exception { assertEquals(conversion.getAnonymizeIp(), validProjectConfig.getAnonymizeIP()); assertTrue(conversion.getEnrichDecisions()); assertEquals(conversion.getClientName(), EventBatch.ClientEngine.JAVA_SDK.getClientEngineValue()); - assertEquals(conversion.getClientVersion(), BuildVersionInfo.VERSION); + assertEquals(conversion.getClientVersion(), BuildVersionInfo.getClientVersion()); } /** @@ -717,7 +717,7 @@ public void createConversionEventPassingUserAgentAttribute() throws Exception { assertEquals(conversion.getAnonymizeIp(), validProjectConfig.getAnonymizeIP()); assertTrue(conversion.getEnrichDecisions()); assertEquals(conversion.getClientName(), EventBatch.ClientEngine.JAVA_SDK.getClientEngineValue()); - assertEquals(conversion.getClientVersion(), BuildVersionInfo.VERSION); + assertEquals(conversion.getClientVersion(), BuildVersionInfo.getClientVersion()); } /** @@ -961,7 +961,7 @@ public void createImpressionEventWithBucketingId() throws Exception { assertThat(impression.getVisitors().get(0).getAttributes(), is(expectedUserFeatures)); assertThat(impression.getClientName(), is(EventBatch.ClientEngine.JAVA_SDK.getClientEngineValue())); - assertThat(impression.getClientVersion(), is(BuildVersionInfo.VERSION)); + assertThat(impression.getClientVersion(), is(BuildVersionInfo.getClientVersion())); assertNull(impression.getVisitors().get(0).getSessionId()); } @@ -1034,7 +1034,7 @@ public void createConversionEventWithBucketingId() throws Exception { assertEquals(conversion.getAnonymizeIp(), validProjectConfig.getAnonymizeIP()); assertTrue(conversion.getEnrichDecisions()); assertEquals(conversion.getClientName(), EventBatch.ClientEngine.JAVA_SDK.getClientEngineValue()); - assertEquals(conversion.getClientVersion(), BuildVersionInfo.VERSION); + assertEquals(conversion.getClientVersion(), BuildVersionInfo.getClientVersion()); } From 0248dda3cad967c1a84ad63b725a0a2a2d08b2fd Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Thu, 17 Mar 2022 13:46:06 -0700 Subject: [PATCH 076/147] prepare for release 3.10.2 (#468) --- CHANGELOG.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca3935252..50b9c6cf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,21 @@ # Optimizely Java X SDK Changelog +## [3.10.2] +March 17th, 2022 + +### Fixes + +- For some audience condition matchers (semantic-version, le, or ge), SDK logs WARNING messages when the attribute value is missing. This is fixed down to the DEBUG level to be consistent with other condition matchers ([#463](https://github.com/optimizely/java-sdk/pull/463)). +- Add an option to specify the client-engine version (android-sdk, etc) in the Optimizely builder ([#466](https://github.com/optimizely/java-sdk/pull/466)). + + ## [3.10.1] February 3rd, 2022 ### Fixes - Fix NotificationManager to be thread-safe (add-handler and send-notifications can happen concurrently) ([#460](https://github.com/optimizely/java-sdk/pull/460)). -## 3.10.0 +## [3.10.0] January 10th, 2022 ### New Features From 9dc90892641601fd38fe5f0365be5dfb8f0fc6e7 Mon Sep 17 00:00:00 2001 From: Trisha Hanlon Date: Thu, 7 Apr 2022 13:36:28 -0500 Subject: [PATCH 077/147] Updating doc urls (#471) * Updating doc urls * Updating to v4.0 for doc urls --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 62c75d880..376a8ec59 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ Optimizely Java SDK This repository houses the Java SDK for use with Optimizely Full Stack and Optimizely Rollouts. -Optimizely Full Stack is A/B testing and feature flag management for product development teams. Experiment in any application. Make every feature on your roadmap an opportunity to learn. Learn more at https://www.optimizely.com/platform/full-stack/, or see the [documentation](https://docs.developers.optimizely.com/full-stack/docs). +Optimizely Full Stack is A/B testing and feature flag management for product development teams. Experiment in any application. Make every feature on your roadmap an opportunity to learn. Learn more at https://www.optimizely.com/platform/full-stack/, or see the [documentation](https://docs.developers.optimizely.com/experimentation/v4.0-full-stack/docs). -Optimizely Rollouts is free feature flags for development teams. Easily roll out and roll back features in any application without code deploys. Mitigate risk for every feature on your roadmap. Learn more at https://www.optimizely.com/rollouts/, or see the [documentation](https://docs.developers.optimizely.com/rollouts/docs). +Optimizely Rollouts is free feature flags for development teams. Easily roll out and roll back features in any application without code deploys. Mitigate risk for every feature on your roadmap. Learn more at https://www.optimizely.com/rollouts/, or see the [documentation](https://docs.developers.optimizely.com/experimentation/v3.1.0-full-stack/docs/introduction-to-rollouts). ## Getting Started @@ -57,7 +57,7 @@ To access the Feature Management configuration in the Optimizely dashboard, plea ### Using the SDK -See the Optimizely Full Stack [developer documentation](http://developers.optimizely.com/server/reference/index.html) to learn how to set +See the Optimizely Full Stack [developer documentation](https://docs.developers.optimizely.com/experimentation/v4.0-full-stack/docs/java-sdk) to learn how to set up your first Java project and use the SDK. ## Development From fcb5524b0df92a86b12d3e3ef67c61eb1473066c Mon Sep 17 00:00:00 2001 From: Muhammad Noman Date: Fri, 15 Apr 2022 02:03:20 +0500 Subject: [PATCH 078/147] chore: removed travis yml and added git action support (#469) --- .github/workflows/build.yml | 40 ++++++++ .github/workflows/integration_test.yml | 56 ++++++++++++ .github/workflows/java.yml | 116 ++++++++++++++++++++++++ .github/workflows/source_clear_cron.yml | 16 ++++ .travis.yml | 92 ------------------- 5 files changed, 228 insertions(+), 92 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/integration_test.yml create mode 100644 .github/workflows/java.yml create mode 100644 .github/workflows/source_clear_cron.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..e7ba7782e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,40 @@ +name: Reusable action of building snapshot and publish + +on: + workflow_call: + inputs: + action: + required: true + type: string + travis_tag: + required: true + type: string + secrets: + MAVEN_SIGNING_KEY_BASE64: + required: true + MAVEN_SIGNING_PASSPHRASE: + required: true + MAVEN_CENTRAL_USERNAME: + required: true + MAVEN_CENTRAL_PASSWORD: + required: true +jobs: + run_build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: set up JDK 8 + uses: actions/setup-java@v2 + with: + java-version: '8' + distribution: 'temurin' + cache: gradle + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: ${{ inputs.action }} + env: + MAVEN_SIGNING_KEY_BASE64: ${{ secrets.MAVEN_SIGNING_KEY_BASE64 }} + MAVEN_SIGNING_PASSPHRASE: ${{ secrets.MAVEN_SIGNING_PASSPHRASE }} + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + run: TRAVIS_TAG=${{ inputs.travis_tag }} ./gradlew ${{ inputs.action }} diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml new file mode 100644 index 000000000..e4d1c7e7d --- /dev/null +++ b/.github/workflows/integration_test.yml @@ -0,0 +1,56 @@ +name: Reusable action of running integration of production suite + +on: + workflow_call: + inputs: + FULLSTACK_TEST_REPO: + required: false + type: string + secrets: + CI_USER_TOKEN: + required: true + TRAVIS_COM_TOKEN: + required: true +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + # You should create a personal access token and store it in your repository + token: ${{ secrets.CI_USER_TOKEN }} + repository: 'optimizely/travisci-tools' + path: 'home/runner/travisci-tools' + ref: 'master' + - name: set SDK Branch if PR + if: ${{ github.event_name == 'pull_request' }} + run: | + echo "SDK_BRANCH=${{ github.head_ref }}" >> $GITHUB_ENV + echo "TRAVIS_BRANCH=${{ github.head_ref }}" >> $GITHUB_ENV + - name: set SDK Branch if not pull request + if: ${{ github.event_name != 'pull_request' }} + run: | + echo "SDK_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV + echo "TRAVIS_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV + - name: Trigger build + env: + SDK: java + FULLSTACK_TEST_REPO: ${{ inputs.FULLSTACK_TEST_REPO }} + BUILD_NUMBER: ${{ github.run_id }} + TESTAPP_BRANCH: master + GITHUB_TOKEN: ${{ secrets.CI_USER_TOKEN }} + TRAVIS_EVENT_TYPE: ${{ github.event_name }} + GITHUB_CONTEXT: ${{ toJson(github) }} + TRAVIS_REPO_SLUG: ${{ github.repository }} + TRAVIS_PULL_REQUEST_SLUG: ${{ github.repository }} + UPSTREAM_REPO: ${{ github.repository }} + TRAVIS_COMMIT: ${{ github.sha }} + TRAVIS_PULL_REQUEST_SHA: ${{ github.event.pull_request.head.sha }} + TRAVIS_PULL_REQUEST: ${{ github.event.pull_request.number }} + UPSTREAM_SHA: ${{ github.sha }} + TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} + EVENT_MESSAGE: ${{ github.event.message }} + HOME: 'home/runner' + run: | + echo "$GITHUB_CONTEXT" + home/runner/travisci-tools/trigger-script-with-status-update.sh diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml new file mode 100644 index 000000000..a7c2eafe9 --- /dev/null +++ b/.github/workflows/java.yml @@ -0,0 +1,116 @@ + +name: Java CI with Gradle + +on: + push: + branches: [ master ] + tags: + - '*' + pull_request: + branches: [ master ] + workflow_dispatch: + inputs: + SNAPSHOT: + type: boolean + description: Set SNAPSHOT true to publish + +jobs: + lint_markdown_files: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '2.6' + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + - name: Install gem + run: | + gem install awesome_bot + - name: Run tests + run: find . -type f -name '*.md' -exec awesome_bot {} \; + + integration_tests: + uses: optimizely/java-sdk/.github/workflows/integration_test.yml@master + secrets: + CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }} + TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} + + fullstack_production_suite: + uses: optimizely/java-sdk/.github/workflows/integration_test.yml@master + with: + FULLSTACK_TEST_REPO: ProdTesting + secrets: + CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }} + TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} + + test: + if: startsWith(github.ref, 'refs/tags/') != true + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + jdk: [8, 9] + optimizely_default_parser: [GSON_CONFIG_PARSER, JACKSON_CONFIG_PARSER, JSON_CONFIG_PARSER, JSON_SIMPLE_CONFIG_PARSER] + steps: + - name: checkout + uses: actions/checkout@v2 + + - name: set up JDK ${{ matrix.jdk }} + uses: AdoptOpenJDK/install-jdk@v1 + with: + version: ${{ matrix.jdk }} + architecture: x64 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Gradle cache + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/buildSrc/**/*.kt') }} + + - name: run tests + id: unit_tests + env: + optimizely_default_parser: ${{ matrix.optimizely_default_parser }} + run: | + ./gradlew clean + ./gradlew exhaustiveTest + ./gradlew build + - name: Check on failures + if: steps.unit_tests.outcome != 'success' + run: | + cat /home/runner/java-sdk/core-api/build/reports/findbugs/main.html + cat /home/runner/java-sdk/core-api/build/reports/findbugs/test.html + - name: Check on success + if: steps.unit_tests.outcome == 'success' + run: | + ./gradlew coveralls uploadArchives --console plain + + publish: + if: startsWith(github.ref, 'refs/tags/') + uses: optimizely/java-sdk/.github/workflows/build.yml@master + with: + action: ship + travis_tag: ${GITHUB_REF#refs/*/} + secrets: + MAVEN_SIGNING_KEY_BASE64: ${{ secrets.MAVEN_SIGNING_KEY_BASE64 }} + MAVEN_SIGNING_PASSPHRASE: ${{ secrets.MAVEN_SIGNING_PASSPHRASE }} + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + + snapshot: + if: ${{ github.event.inputs.SNAPSHOT == 'true' && github.event_name == 'workflow_dispatch' }} + uses: optimizely/java-sdk/.github/workflows/build.yml@master + with: + action: ship + travis_tag: BB-SNAPSHOT + secrets: + MAVEN_SIGNING_KEY_BASE64: ${{ secrets.MAVEN_SIGNING_KEY_BASE64 }} + MAVEN_SIGNING_PASSPHRASE: ${{ secrets.MAVEN_SIGNING_PASSPHRASE }} + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} diff --git a/.github/workflows/source_clear_cron.yml b/.github/workflows/source_clear_cron.yml new file mode 100644 index 000000000..54eca5358 --- /dev/null +++ b/.github/workflows/source_clear_cron.yml @@ -0,0 +1,16 @@ +name: Source clear + +on: + schedule: + # Runs "weekly" + - cron: '0 0 * * 0' + +jobs: + source_clear: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Source clear scan + env: + SRCCLR_API_TOKEN: ${{ secrets.SRCCLR_API_TOKEN }} + run: curl -sSL https://download.sourceclear.com/ci.sh | bash -s – scan diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2735eacaf..000000000 --- a/.travis.yml +++ /dev/null @@ -1,92 +0,0 @@ -language: java -dist: trusty -jdk: - - openjdk8 - - oraclejdk8 - - oraclejdk9 -install: true -env: - - optimizely_default_parser=GSON_CONFIG_PARSER - - optimizely_default_parser=JACKSON_CONFIG_PARSER - - optimizely_default_parser=JSON_CONFIG_PARSER - - optimizely_default_parser=JSON_SIMPLE_CONFIG_PARSER -script: - - "./gradlew clean" - - "./gradlew exhaustiveTest" - - "./gradlew build" - -cache: - gradle: true - directories: - - "$HOME/.gradle/caches" - - "$HOME/.gradle/wrapper" -branches: - only: - - master - - /^\d+\.\d+\.(\d|[x])+(-SNAPSHOT|-alpha|-beta)?\d*$/ # trigger builds on tags which are semantically versioned to ship the SDK. -after_success: - - ./gradlew coveralls uploadArchives --console plain -after_failure: - - cat /home/travis/build/optimizely/java-sdk/core-api/build/reports/findbugs/main.html - - cat /home/travis/build/optimizely/java-sdk/core-api/build/reports/findbugs/test.html - -# Integration tests need to run first to reset the PR build status to pending -stages: - - 'Source Clear' - - 'Lint markdown files' - - 'Integration tests' - - 'Full stack production tests' - - 'Test' - - 'Publish' - - 'Snapshot' - -jobs: - include: - - stage: 'Lint markdown files' - os: linux - language: generic - install: gem install awesome_bot - script: - - find . -type f -name '*.md' -exec awesome_bot {} \; - notifications: - email: false - - - &integrationtest - stage: 'Integration tests' - merge_mode: replace - env: SDK=java SDK_BRANCH=$TRAVIS_PULL_REQUEST_BRANCH - cache: false - language: minimal - before_install: skip - install: skip - before_script: - - mkdir $HOME/travisci-tools && pushd $HOME/travisci-tools && git init && git pull https://$CI_USER_TOKEN@github.com/optimizely/travisci-tools.git && popd - script: - - $HOME/travisci-tools/trigger-script-with-status-update.sh - after_success: travis_terminate 0 - - - <<: *integrationtest - stage: 'Full stack production tests' - env: - SDK=java - SDK_BRANCH=$TRAVIS_PULL_REQUEST_BRANCH - FULLSTACK_TEST_REPO=ProdTesting - - - stage: 'Source Clear' - if: type = cron - install: skip - before_script: skip - script: skip - after_success: skip - - - stage: 'Publish' - if: tag IS present - script: - - ./gradlew ship - after_success: skip - - - stage: 'Snapshot' - if: env(SNAPSHOT) = true and type = api - script: - - TRAVIS_TAG=BB-SNAPSHOT ./gradlew ship - after_success: skip From 60188c6e52311f873fedafbcaeccb6ba45446790 Mon Sep 17 00:00:00 2001 From: Muhammad Noman Date: Fri, 24 Jun 2022 00:04:34 +0500 Subject: [PATCH 079/147] Fixing the git action build ubuntu to 18.04 (#473) * test * test * test * adding travis yml to make sure everytest is working * test * test * commenting out gitaction build for now until github action resolves there issue * enabling only unit test for testing * test * test * test * test * test again * test * test * test * test * changed ubuntu version to 18.04 from latest * removing travis yml * reverted the changes * revert --- .github/workflows/build.yml | 2 +- .github/workflows/java.yml | 7 +++---- .../ab/config/DatafileProjectConfigTestUtils.java | 2 +- .../ab/config/parser/JacksonConfigParserTest.java | 1 + 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e7ba7782e..0cd965aad 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,7 +20,7 @@ on: required: true jobs: run_build: - runs-on: ubuntu-latest + runs-on: ubuntu-18.04 steps: - uses: actions/checkout@v2 - name: set up JDK 8 diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index a7c2eafe9..bc866f898 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -1,4 +1,3 @@ - name: Java CI with Gradle on: @@ -46,7 +45,7 @@ jobs: test: if: startsWith(github.ref, 'refs/tags/') != true - runs-on: ubuntu-latest + runs-on: ubuntu-18.04 strategy: fail-fast: false matrix: @@ -82,12 +81,12 @@ jobs: ./gradlew exhaustiveTest ./gradlew build - name: Check on failures - if: steps.unit_tests.outcome != 'success' + if: always() && steps.unit_tests.outcome != 'success' run: | cat /home/runner/java-sdk/core-api/build/reports/findbugs/main.html cat /home/runner/java-sdk/core-api/build/reports/findbugs/test.html - name: Check on success - if: steps.unit_tests.outcome == 'success' + if: always() && steps.unit_tests.outcome == 'success' run: | ./gradlew coveralls uploadArchives --console plain diff --git a/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java index b96815a39..49eaac733 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java +++ b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java @@ -491,7 +491,7 @@ private static void verifyExperiments(List actual, List assertThat(actualExperiment.getGroupId(), is(expectedExperiment.getGroupId())); assertThat(actualExperiment.getStatus(), is(expectedExperiment.getStatus())); assertThat(actualExperiment.getAudienceIds(), is(expectedExperiment.getAudienceIds())); - assertThat(actualExperiment.getAudienceConditions(), is(expectedExperiment.getAudienceConditions())); + assertEquals(actualExperiment.getAudienceConditions(), expectedExperiment.getAudienceConditions()); assertThat(actualExperiment.getUserIdToVariationKeyMap(), is(expectedExperiment.getUserIdToVariationKeyMap())); diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java index e4e009e10..2e5dcb672 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java @@ -69,6 +69,7 @@ public void parseProjectConfigV3() throws Exception { verifyProjectConfig(actual, expected); } + @SuppressFBWarnings("NP_NULL_PARAM_DEREF") @Test public void parseProjectConfigV4() throws Exception { JacksonConfigParser parser = new JacksonConfigParser(); From 60b11f81ef416c7985d0c59f92fc9aa45cfd02a3 Mon Sep 17 00:00:00 2001 From: Muhammad Noman Date: Tue, 19 Jul 2022 04:21:36 +0500 Subject: [PATCH 080/147] feat: updated for fsc git action (#476) Updated gitactions.yaml --- .github/workflows/integration_test.yml | 14 +++++++------- .github/workflows/java.yml | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index e4d1c7e7d..9471458fc 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -15,7 +15,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: # You should create a personal access token and store it in your repository token: ${{ secrets.CI_USER_TOKEN }} @@ -39,15 +39,15 @@ jobs: BUILD_NUMBER: ${{ github.run_id }} TESTAPP_BRANCH: master GITHUB_TOKEN: ${{ secrets.CI_USER_TOKEN }} - TRAVIS_EVENT_TYPE: ${{ github.event_name }} + EVENT_TYPE: ${{ github.event_name }} GITHUB_CONTEXT: ${{ toJson(github) }} - TRAVIS_REPO_SLUG: ${{ github.repository }} - TRAVIS_PULL_REQUEST_SLUG: ${{ github.repository }} + #REPO_SLUG: ${{ github.repository }} + PULL_REQUEST_SLUG: ${{ github.repository }} UPSTREAM_REPO: ${{ github.repository }} - TRAVIS_COMMIT: ${{ github.sha }} - TRAVIS_PULL_REQUEST_SHA: ${{ github.event.pull_request.head.sha }} - TRAVIS_PULL_REQUEST: ${{ github.event.pull_request.number }} + PULL_REQUEST_SHA: ${{ github.event.pull_request.head.sha }} + PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} UPSTREAM_SHA: ${{ github.sha }} + TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} EVENT_MESSAGE: ${{ github.event.message }} HOME: 'home/runner' diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index bc866f898..35ab54025 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -30,7 +30,7 @@ jobs: run: find . -type f -name '*.md' -exec awesome_bot {} \; integration_tests: - uses: optimizely/java-sdk/.github/workflows/integration_test.yml@master + uses: optimizely/java-sdk/.github/workflows/integration_test.yml@mnoman/fsc-gitaction-test secrets: CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }} TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} From 6b9b965b7c9839335c98315f3d7a1d3bba2ae741 Mon Sep 17 00:00:00 2001 From: Muhammad Noman Date: Fri, 22 Jul 2022 02:46:50 +0500 Subject: [PATCH 081/147] feat: Add ODP Segments support in Audience Evaluation (#474) ## Summary 1. Added ODP Segments support in Audience evaluation 2. Adding logic to parse Integrations from datafile and make odp host and key available in projectConfig. ## Test plan - Manually tested thoroughly. - Added New unit tests. - Existing unit tests pass. - Existing Full stack compatibility tests pass. ## JIRA [OASIS-8382](https://optimizely.atlassian.net/browse/OASIS-8382) --- .github/workflows/java.yml | 2 +- .../optimizely/ab/OptimizelyUserContext.java | 35 +- .../ab/bucketing/DecisionService.java | 4 +- .../ab/config/DatafileProjectConfig.java | 41 +- .../com/optimizely/ab/config/Integration.java | 67 ++ .../optimizely/ab/config/ProjectConfig.java | 6 + .../ab/config/audience/AndCondition.java | 7 +- .../ab/config/audience/AttributeType.java | 33 + .../config/audience/AudienceIdCondition.java | 9 +- .../ab/config/audience/Condition.java | 5 +- .../ab/config/audience/EmptyCondition.java | 5 +- .../ab/config/audience/NotCondition.java | 10 +- .../ab/config/audience/NullCondition.java | 5 +- .../ab/config/audience/OrCondition.java | 7 +- .../ab/config/audience/UserAttribute.java | 35 +- .../config/audience/match/MatchRegistry.java | 2 +- .../parser/DatafileGsonDeserializer.java | 10 +- .../parser/DatafileJacksonDeserializer.java | 9 +- .../ab/config/parser/JsonConfigParser.java | 24 +- .../config/parser/JsonSimpleConfigParser.java | 23 +- .../ab/internal/ExperimentUtils.java | 19 +- .../DatafileProjectConfigTestUtils.java | 20 +- .../ab/config/ValidProjectConfigV4.java | 7 +- .../AudienceConditionEvaluationTest.java | 782 ++++++++++++------ .../parser/DefaultConfigParserTest.java | 1 + .../config/parser/GsonConfigParserTest.java | 64 ++ .../parser/JacksonConfigParserTest.java | 64 ++ .../config/parser/JsonConfigParserTest.java | 63 ++ .../parser/JsonSimpleConfigParserTest.java | 64 ++ .../ab/internal/ExperimentUtilsTest.java | 27 +- .../OptimizelyConfigServiceTest.java | 3 +- .../com/optimizely/ab/testutils/OTUtils.java | 36 + .../config/valid-project-config-v4.json | 7 + 33 files changed, 1184 insertions(+), 312 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/config/Integration.java create mode 100644 core-api/src/main/java/com/optimizely/ab/config/audience/AttributeType.java create mode 100644 core-api/src/test/java/com/optimizely/ab/testutils/OTUtils.java diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index 35ab54025..af0dccf0a 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -45,7 +45,7 @@ jobs: test: if: startsWith(github.ref, 'refs/tags/') != true - runs-on: ubuntu-18.04 + runs-on: macos-latest strategy: fail-fast: false matrix: diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java index d05df3bbb..e59c4f3aa 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -36,6 +36,8 @@ public class OptimizelyUserContext { @Nonnull private final Map attributes; + private List qualifiedSegments; + @Nonnull private final Optimizely optimizely; @@ -44,19 +46,14 @@ public class OptimizelyUserContext { public OptimizelyUserContext(@Nonnull Optimizely optimizely, @Nonnull String userId, @Nonnull Map attributes) { - this.optimizely = optimizely; - this.userId = userId; - if (attributes != null) { - this.attributes = Collections.synchronizedMap(new HashMap<>(attributes)); - } else { - this.attributes = Collections.synchronizedMap(new HashMap<>()); - } + this(optimizely, userId, attributes, Collections.EMPTY_MAP, null); } public OptimizelyUserContext(@Nonnull Optimizely optimizely, @Nonnull String userId, @Nonnull Map attributes, - @Nullable Map forcedDecisionsMap) { + @Nullable Map forcedDecisionsMap, + @Nullable List qualifiedSegments) { this.optimizely = optimizely; this.userId = userId; if (attributes != null) { @@ -65,8 +62,10 @@ public OptimizelyUserContext(@Nonnull Optimizely optimizely, this.attributes = Collections.synchronizedMap(new HashMap<>()); } if (forcedDecisionsMap != null) { - this.forcedDecisionsMap = new ConcurrentHashMap<>(forcedDecisionsMap); + this.forcedDecisionsMap = new ConcurrentHashMap<>(forcedDecisionsMap); } + + this.qualifiedSegments = Collections.synchronizedList( qualifiedSegments == null ? new LinkedList<>(): qualifiedSegments); } public OptimizelyUserContext(@Nonnull Optimizely optimizely, @Nonnull String userId) { @@ -86,7 +85,16 @@ public Optimizely getOptimizely() { } public OptimizelyUserContext copy() { - return new OptimizelyUserContext(optimizely, userId, attributes, forcedDecisionsMap); + return new OptimizelyUserContext(optimizely, userId, attributes, forcedDecisionsMap, qualifiedSegments); + } + + /** + * Returns true if the user is qualified for the given segment name + * @param segment A String segment key which will be checked in the qualified segments list that if it exists then user is qualified. + * @return boolean Is user qualified for a segment. + */ + public boolean isQualifiedFor(@Nonnull String segment) { + return qualifiedSegments.contains(segment); } /** @@ -265,7 +273,14 @@ public boolean removeAllForcedDecisions() { return true; } + public List getQualifiedSegments() { + return qualifiedSegments; + } + public void setQualifiedSegments(List qualifiedSegments) { + this.qualifiedSegments.clear(); + this.qualifiedSegments.addAll(qualifiedSegments); + } // Utils diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java index c7ee0b3f3..84d47d03f 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java @@ -152,7 +152,7 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, } } - DecisionResponse decisionMeetAudience = ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, experiment, user.getAttributes(), EXPERIMENT, experiment.getKey()); + DecisionResponse decisionMeetAudience = ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, experiment, user, EXPERIMENT, experiment.getKey()); reasons.merge(decisionMeetAudience.getReasons()); if (decisionMeetAudience.getResult()) { String bucketingId = getBucketingId(user.getUserId(), user.getAttributes()); @@ -693,7 +693,7 @@ DecisionResponse getVariationFromDeliveryRule(@Nonnull DecisionResponse audienceDecisionResponse = ExperimentUtils.doesUserMeetAudienceConditions( projectConfig, rule, - user.getAttributes(), + user, RULE, String.valueOf(ruleIndex + 1) ); diff --git a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java index 9620f5cbf..65edf5768 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java @@ -63,6 +63,8 @@ public class DatafileProjectConfig implements ProjectConfig { private final boolean anonymizeIP; private final boolean sendFlagDecisions; private final Boolean botFiltering; + private final String hostForODP; + private final String publicKeyForODP; private final List attributes; private final List audiences; private final List typedAudiences; @@ -71,6 +73,7 @@ public class DatafileProjectConfig implements ProjectConfig { private final List featureFlags; private final List groups; private final List rollouts; + private final List integrations; // key to entity mappings private final Map attributeKeyMapping; @@ -121,6 +124,7 @@ public DatafileProjectConfig(String accountId, String projectId, String version, experiments, null, groups, + null, null ); } @@ -142,8 +146,8 @@ public DatafileProjectConfig(String accountId, List experiments, List featureFlags, List groups, - List rollouts) { - + List rollouts, + List integrations) { this.accountId = accountId; this.projectId = projectId; this.version = version; @@ -182,6 +186,24 @@ public DatafileProjectConfig(String accountId, allExperiments.addAll(aggregateGroupExperiments(groups)); this.experiments = Collections.unmodifiableList(allExperiments); + String publicKeyForODP = ""; + String hostForODP = ""; + if (integrations == null) { + this.integrations = Collections.emptyList(); + } else { + this.integrations = Collections.unmodifiableList(integrations); + for (Integration integration: this.integrations) { + if (integration.getKey().equals("odp")) { + hostForODP = integration.getHost(); + publicKeyForODP = integration.getPublicKey(); + break; + } + } + } + + this.publicKeyForODP = publicKeyForODP; + this.hostForODP = hostForODP; + Map variationIdToExperimentMap = new HashMap(); for (Experiment experiment : this.experiments) { for (Variation variation : experiment.getVariations()) { @@ -448,6 +470,11 @@ public List getTypedAudiences() { return typedAudiences; } + @Override + public List getIntegrations() { + return integrations; + } + @Override public Audience getAudience(String audienceId) { return audienceIdMapping.get(audienceId); @@ -524,6 +551,16 @@ public Variation getFlagVariationByKey(String flagKey, String variationKey) { return null; } + @Override + public String getHostForODP() { + return hostForODP; + } + + @Override + public String getPublicKeyForODP() { + return publicKeyForODP; + } + @Override public String toString() { return "ProjectConfig{" + diff --git a/core-api/src/main/java/com/optimizely/ab/config/Integration.java b/core-api/src/main/java/com/optimizely/ab/config/Integration.java new file mode 100644 index 000000000..ed24df625 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/Integration.java @@ -0,0 +1,67 @@ +/** + * + * Copyright 2022, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +/** + * Represents the Optimizely Integration configuration. + * + * @see Project JSON + */ +@Immutable +@JsonIgnoreProperties(ignoreUnknown = true) +public class Integration { + private final String key; + private final String host; + private final String publicKey; + + @JsonCreator + public Integration(@JsonProperty("key") String key, + @JsonProperty("host") String host, + @JsonProperty("publicKey") String publicKey) { + this.key = key; + this.host = host; + this.publicKey = publicKey; + } + + @Nonnull + public String getKey() { + return key; + } + + @Nullable + public String getHost() { return host; } + + @Nullable + public String getPublicKey() { return publicKey; } + + @Override + public String toString() { + return "Integration{" + + "key='" + key + '\'' + + ((this.host != null) ? (", host='" + host + '\'') : "") + + ((this.publicKey != null) ? (", publicKey='" + publicKey + '\'') : "") + + '}'; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java index 10ebdc832..be512bd04 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java @@ -83,6 +83,8 @@ Experiment getExperimentForKey(@Nonnull String experimentKey, List getTypedAudiences(); + List getIntegrations(); + Audience getAudience(String audienceId); Map getExperimentKeyMapping(); @@ -107,6 +109,10 @@ Experiment getExperimentForKey(@Nonnull String experimentKey, Variation getFlagVariationByKey(String flagKey, String variationKey); + String getHostForODP(); + + String getPublicKeyForODP(); + @Override String toString(); diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java index f6561a65c..8d855e3e9 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2019, Optimizely and contributors + * Copyright 2016-2019, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ */ package com.optimizely.ab.config.audience; +import com.optimizely.ab.OptimizelyUserContext; import com.optimizely.ab.config.ProjectConfig; import javax.annotation.Nonnull; @@ -42,7 +43,7 @@ public List getConditions() { } @Nullable - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, OptimizelyUserContext user) { if (conditions == null) return null; boolean foundNull = false; // According to the matrix where: @@ -53,7 +54,7 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { // true and true is true // null and null is null for (Condition condition : conditions) { - Boolean conditionEval = condition.evaluate(config, attributes); + Boolean conditionEval = condition.evaluate(config, user); if (conditionEval == null) { foundNull = true; } else if (!conditionEval) { // false with nulls or trues is false. diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/AttributeType.java b/core-api/src/main/java/com/optimizely/ab/config/audience/AttributeType.java new file mode 100644 index 000000000..2a1be3880 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/AttributeType.java @@ -0,0 +1,33 @@ +/** + * + * Copyright 2022, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience; + +public enum AttributeType { + CUSTOM_ATTRIBUTE("custom_attribute"), + THIRD_PARTY_DIMENSION("third_party_dimension"); + + private final String key; + + AttributeType(String key) { + this.key = key; + } + + @Override + public String toString() { + return key; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java index e07757016..4b3341d4c 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java @@ -1,12 +1,12 @@ /** * - * Copyright 2018-2021, Optimizely and contributors + * Copyright 2018-2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -19,6 +19,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import com.optimizely.ab.OptimizelyUserContext; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.internal.InvalidAudienceCondition; import org.slf4j.Logger; @@ -71,7 +72,7 @@ public String getOperandOrId() { @Nullable @Override - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, OptimizelyUserContext user) { if (config != null) { audience = config.getAudienceIdMapping().get(audienceId); } @@ -80,7 +81,7 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { return null; } logger.debug("Starting to evaluate audience \"{}\" with conditions: {}.", audience.getId(), audience.getConditions()); - Boolean result = audience.getConditions().evaluate(config, attributes); + Boolean result = audience.getConditions().evaluate(config, user); logger.debug("Audience \"{}\" evaluated to {}.", audience.getId(), result); return result; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java index 11b7165b9..105c9b8e0 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2018, Optimizely and contributors + * Copyright 2016-2018, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ */ package com.optimizely.ab.config.audience; +import com.optimizely.ab.OptimizelyUserContext; import com.optimizely.ab.config.ProjectConfig; import javax.annotation.Nullable; @@ -27,7 +28,7 @@ public interface Condition { @Nullable - Boolean evaluate(ProjectConfig config, Map attributes); + Boolean evaluate(ProjectConfig config, OptimizelyUserContext user); String toJson(); diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java index 9bb355a13..61e010317 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java @@ -1,5 +1,5 @@ /** - * Copyright 2019, Optimizely Inc. and contributors + * Copyright 2019, 2022, Optimizely Inc. and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ */ package com.optimizely.ab.config.audience; +import com.optimizely.ab.OptimizelyUserContext; import com.optimizely.ab.config.ProjectConfig; import javax.annotation.Nullable; @@ -23,7 +24,7 @@ public class EmptyCondition implements Condition { @Nullable @Override - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, OptimizelyUserContext user) { return true; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java index cabc07812..9e4b2fb86 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2019, Optimizely and contributors + * Copyright 2016-2019, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,14 +16,13 @@ */ package com.optimizely.ab.config.audience; +import com.optimizely.ab.OptimizelyUserContext; import com.optimizely.ab.config.ProjectConfig; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import javax.annotation.Nonnull; -import java.util.Map; -import java.util.StringJoiner; /** * Represents a 'Not' conditions condition operation. @@ -43,9 +42,8 @@ public Condition getCondition() { } @Nullable - public Boolean evaluate(ProjectConfig config, Map attributes) { - - Boolean conditionEval = condition == null ? null : condition.evaluate(config, attributes); + public Boolean evaluate(ProjectConfig config, OptimizelyUserContext user) { + Boolean conditionEval = condition == null ? null : condition.evaluate(config, user); return (conditionEval == null ? null : !conditionEval); } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java index 10633aed9..44ddee7ca 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java @@ -1,5 +1,5 @@ /** - * Copyright 2019, Optimizely Inc. and contributors + * Copyright 2019, 2022, Optimizely Inc. and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ */ package com.optimizely.ab.config.audience; +import com.optimizely.ab.OptimizelyUserContext; import com.optimizely.ab.config.ProjectConfig; import javax.annotation.Nullable; @@ -23,7 +24,7 @@ public class NullCondition implements Condition { @Nullable @Override - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, OptimizelyUserContext user) { return null; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java index 293687f66..12fbbb3b2 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2019, Optimizely and contributors + * Copyright 2016-2019, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ */ package com.optimizely.ab.config.audience; +import com.optimizely.ab.OptimizelyUserContext; import com.optimizely.ab.config.ProjectConfig; import javax.annotation.Nonnull; @@ -47,11 +48,11 @@ public List getConditions() { // false or false is false // null or null is null @Nullable - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, OptimizelyUserContext user) { if (conditions == null) return null; boolean foundNull = false; for (Condition condition : conditions) { - Boolean conditionEval = condition.evaluate(config, attributes); + Boolean conditionEval = condition.evaluate(config, user); if (conditionEval == null) { // true with falses and nulls is still true foundNull = true; } else if (conditionEval) { diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java b/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java index ed029f89c..e7ff16f31 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2020, Optimizely and contributors + * Copyright 2016-2020, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import com.optimizely.ab.OptimizelyUserContext; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.audience.match.*; import org.slf4j.Logger; @@ -27,8 +28,10 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; -import java.util.Collections; -import java.util.Map; +import java.util.*; + +import static com.optimizely.ab.config.audience.AttributeType.CUSTOM_ATTRIBUTE; +import static com.optimizely.ab.config.audience.AttributeType.THIRD_PARTY_DIMENSION; /** * Represents a user attribute instance within an audience's conditions. @@ -36,13 +39,14 @@ @Immutable @JsonIgnoreProperties(ignoreUnknown = true) public class UserAttribute implements Condition { + public static final String QUALIFIED = "qualified"; private static final Logger logger = LoggerFactory.getLogger(UserAttribute.class); private final String name; private final String type; private final String match; private final Object value; - + private final static List ATTRIBUTE_TYPE = Arrays.asList(new String[]{CUSTOM_ATTRIBUTE.toString(), THIRD_PARTY_DIMENSION.toString()}); @JsonCreator public UserAttribute(@JsonProperty("name") @Nonnull String name, @JsonProperty("type") @Nonnull String type, @@ -71,19 +75,25 @@ public Object getValue() { } @Nullable - public Boolean evaluate(ProjectConfig config, Map attributes) { - if (attributes == null) { - attributes = Collections.emptyMap(); - } + public Boolean evaluate(ProjectConfig config, OptimizelyUserContext user) { + Map attributes = user.getAttributes(); // Valid for primitive types, but needs to change when a value is an object or an array Object userAttributeValue = attributes.get(name); - if (!"custom_attribute".equals(type)) { + if (!isValidType(type)) { logger.warn("Audience condition \"{}\" uses an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK.", this); return null; // unknown type } // check user attribute value is equal try { + // Handle qualified segments + if (QUALIFIED.equals(match)) { + if (value instanceof String) { + return user.isQualifiedFor(value.toString()); + } + throw new UnknownValueTypeException(); + } + // Handle other conditions Match matcher = MatchRegistry.getMatch(match); Boolean result = matcher.eval(value, userAttributeValue); if (result == null) { @@ -118,6 +128,13 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { return null; } + private boolean isValidType(String type) { + if (ATTRIBUTE_TYPE.contains(type)) { + return true; + } + return false; + } + @Override public String getOperandOrId() { return null; diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchRegistry.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchRegistry.java index f78c35c8d..7563d2681 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchRegistry.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchRegistry.java @@ -1,6 +1,6 @@ /** * - * Copyright 2020-2021, Optimizely and contributors + * Copyright 2020-2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileGsonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileGsonDeserializer.java index 26fe47330..f349805fa 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileGsonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileGsonDeserializer.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2021, Optimizely and contributors + * Copyright 2016-2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -86,6 +86,7 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa List featureFlags = null; List rollouts = null; + List integrations = null; Boolean botFiltering = null; String sdkKey = null; String environmentKey = null; @@ -97,6 +98,10 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa Type rolloutsType = new TypeToken>() { }.getType(); rollouts = context.deserialize(jsonObject.get("rollouts").getAsJsonArray(), rolloutsType); + if (jsonObject.has("integrations")) { + Type integrationsType = new TypeToken>() {}.getType(); + integrations = context.deserialize(jsonObject.get("integrations").getAsJsonArray(), integrationsType); + } if (jsonObject.has("sdkKey")) sdkKey = jsonObject.get("sdkKey").getAsString(); if (jsonObject.has("environmentKey")) @@ -124,7 +129,8 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa experiments, featureFlags, groups, - rollouts + rollouts, + integrations ); } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileJacksonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileJacksonDeserializer.java index 4cded2ecb..4ef104428 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileJacksonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileJacksonDeserializer.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2021, Optimizely and contributors + * Copyright 2016-2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -63,6 +63,7 @@ public DatafileProjectConfig deserialize(JsonParser parser, DeserializationConte List featureFlags = null; List rollouts = null; + List integrations = null; String sdkKey = null; String environmentKey = null; Boolean botFiltering = null; @@ -70,6 +71,9 @@ public DatafileProjectConfig deserialize(JsonParser parser, DeserializationConte if (datafileVersion >= Integer.parseInt(DatafileProjectConfig.Version.V4.toString())) { featureFlags = JacksonHelpers.arrayNodeToList(node.get("featureFlags"), FeatureFlag.class, codec); rollouts = JacksonHelpers.arrayNodeToList(node.get("rollouts"), Rollout.class, codec); + if (node.hasNonNull("integrations")) { + integrations = JacksonHelpers.arrayNodeToList(node.get("integrations"), Integration.class, codec); + } if (node.hasNonNull("sdkKey")) { sdkKey = node.get("sdkKey").textValue(); } @@ -101,7 +105,8 @@ public DatafileProjectConfig deserialize(JsonParser parser, DeserializationConte experiments, featureFlags, groups, - rollouts + rollouts, + integrations ); } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java index c33f30a68..ea5101054 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2021, Optimizely and contributors + * Copyright 2016-2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -72,6 +72,7 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse List featureFlags = null; List rollouts = null; + List integrations = null; String sdkKey = null; String environmentKey = null; Boolean botFiltering = null; @@ -79,6 +80,9 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { featureFlags = parseFeatureFlags(rootObject.getJSONArray("featureFlags")); rollouts = parseRollouts(rootObject.getJSONArray("rollouts")); + if (rootObject.has("integrations")) { + integrations = parseIntegrations(rootObject.getJSONArray("integrations")); + } if (rootObject.has("sdkKey")) sdkKey = rootObject.getString("sdkKey"); if (rootObject.has("environmentKey")) @@ -106,7 +110,8 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse experiments, featureFlags, groups, - rollouts + rollouts, + integrations ); } catch (RuntimeException e) { throw new ConfigParseException("Unable to parse datafile: " + json, e); @@ -399,6 +404,21 @@ private List parseRollouts(JSONArray rolloutsJson) { return rollouts; } + private List parseIntegrations(JSONArray integrationsJson) { + List integrations = new ArrayList(integrationsJson.length()); + + for (int i = 0; i < integrationsJson.length(); i++) { + Object obj = integrationsJson.get(i); + JSONObject integrationObject = (JSONObject) obj; + String key = integrationObject.getString("key"); + String host = integrationObject.has("host") ? integrationObject.getString("host") : null; + String publicKey = integrationObject.has("publicKey") ? integrationObject.getString("publicKey") : null; + integrations.add(new Integration(key, host, publicKey)); + } + + return integrations; + } + @Override public String toJson(Object src) { JSONObject json = (JSONObject)JsonHelpers.convertToJsonObject(src); diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java index 751e651ca..c65eb6213 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2021, Optimizely and contributors + * Copyright 2016-2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -81,11 +81,15 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse List featureFlags = null; List rollouts = null; + List integrations = null; Boolean botFiltering = null; boolean sendFlagDecisions = false; if (datafileVersion >= Integer.parseInt(DatafileProjectConfig.Version.V4.toString())) { featureFlags = parseFeatureFlags((JSONArray) rootObject.get("featureFlags")); rollouts = parseRollouts((JSONArray) rootObject.get("rollouts")); + if (rootObject.containsKey("integrations")) { + integrations = parseIntegrations((JSONArray) rootObject.get("integrations")); + } if (rootObject.containsKey("botFiltering")) botFiltering = (Boolean) rootObject.get("botFiltering"); if (rootObject.containsKey("sendFlagDecisions")) @@ -109,7 +113,8 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse experiments, featureFlags, groups, - rollouts + rollouts, + integrations ); } catch (RuntimeException ex) { throw new ConfigParseException("Unable to parse datafile: " + json, ex); @@ -379,6 +384,20 @@ private List parseRollouts(JSONArray rolloutsJson) { return rollouts; } + private List parseIntegrations(JSONArray integrationsJson) { + List integrations = new ArrayList<>(integrationsJson.size()); + + for (Object obj : integrationsJson) { + JSONObject integrationObject = (JSONObject) obj; + String key = (String) integrationObject.get("key"); + String host = (String) integrationObject.get("host"); + String publicKey = (String) integrationObject.get("publicKey"); + integrations.add(new Integration(key, host, publicKey)); + } + + return integrations; + } + @Override public String toJson(Object src) { return JSONValue.toJSONString(src); diff --git a/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java b/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java index c1494bbda..8da421885 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java @@ -1,6 +1,6 @@ /** * - * Copyright 2017-2021, Optimizely and contributors + * Copyright 2017-2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ */ package com.optimizely.ab.internal; +import com.optimizely.ab.OptimizelyUserContext; import com.optimizely.ab.config.Experiment; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.audience.AudienceIdCondition; @@ -54,7 +55,7 @@ public static boolean isExperimentActive(@Nonnull Experiment experiment) { * * @param projectConfig the current projectConfig * @param experiment the experiment we are evaluating audiences for - * @param attributes the attributes of the user + * @param user the current OptimizelyUserContext * @param loggingEntityType It can be either experiment or rule. * @param loggingKey In case of loggingEntityType is experiment it will be experiment key or else it will be rule number. * @return whether the user meets the criteria for the experiment @@ -62,7 +63,7 @@ public static boolean isExperimentActive(@Nonnull Experiment experiment) { @Nonnull public static DecisionResponse doesUserMeetAudienceConditions(@Nonnull ProjectConfig projectConfig, @Nonnull Experiment experiment, - @Nonnull Map attributes, + @Nonnull OptimizelyUserContext user, @Nonnull String loggingEntityType, @Nonnull String loggingKey) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); @@ -70,9 +71,9 @@ public static DecisionResponse doesUserMeetAudienceConditions(@Nonnull DecisionResponse decisionResponse; if (experiment.getAudienceConditions() != null) { logger.debug("Evaluating audiences for {} \"{}\": {}.", loggingEntityType, loggingKey, experiment.getAudienceConditions()); - decisionResponse = evaluateAudienceConditions(projectConfig, experiment, attributes, loggingEntityType, loggingKey); + decisionResponse = evaluateAudienceConditions(projectConfig, experiment, user, loggingEntityType, loggingKey); } else { - decisionResponse = evaluateAudience(projectConfig, experiment, attributes, loggingEntityType, loggingKey); + decisionResponse = evaluateAudience(projectConfig, experiment, user, loggingEntityType, loggingKey); } Boolean resolveReturn = decisionResponse.getResult(); @@ -86,7 +87,7 @@ public static DecisionResponse doesUserMeetAudienceConditions(@Nonnull @Nonnull public static DecisionResponse evaluateAudience(@Nonnull ProjectConfig projectConfig, @Nonnull Experiment experiment, - @Nonnull Map attributes, + @Nonnull OptimizelyUserContext user, @Nonnull String loggingEntityType, @Nonnull String loggingKey) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); @@ -108,7 +109,7 @@ public static DecisionResponse evaluateAudience(@Nonnull ProjectConfig logger.debug("Evaluating audiences for {} \"{}\": {}.", loggingEntityType, loggingKey, conditions); - Boolean result = implicitOr.evaluate(projectConfig, attributes); + Boolean result = implicitOr.evaluate(projectConfig, user); String message = reasons.addInfo("Audiences for %s \"%s\" collectively evaluated to %s.", loggingEntityType, loggingKey, result); logger.info(message); @@ -118,7 +119,7 @@ public static DecisionResponse evaluateAudience(@Nonnull ProjectConfig @Nonnull public static DecisionResponse evaluateAudienceConditions(@Nonnull ProjectConfig projectConfig, @Nonnull Experiment experiment, - @Nonnull Map attributes, + @Nonnull OptimizelyUserContext user, @Nonnull String loggingEntityType, @Nonnull String loggingKey) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); @@ -128,7 +129,7 @@ public static DecisionResponse evaluateAudienceConditions(@Nonnull Proj Boolean result = null; try { - result = conditions.evaluate(projectConfig, attributes); + result = conditions.evaluate(projectConfig, user); String message = reasons.addInfo("Audiences for %s \"%s\" collectively evaluated to %s.", loggingEntityType, loggingKey, result); logger.info(message); } catch (Exception e) { diff --git a/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java index 49eaac733..9b65421bb 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java +++ b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java @@ -474,6 +474,7 @@ public static void verifyProjectConfig(@CheckForNull ProjectConfig actual, @Nonn verifyFeatureFlags(actual.getFeatureFlags(), expected.getFeatureFlags()); verifyGroups(actual.getGroups(), expected.getGroups()); verifyRollouts(actual.getRollouts(), expected.getRollouts()); + verifyIntegrations(actual.getIntegrations(), expected.getIntegrations()); } /** @@ -491,7 +492,7 @@ private static void verifyExperiments(List actual, List assertThat(actualExperiment.getGroupId(), is(expectedExperiment.getGroupId())); assertThat(actualExperiment.getStatus(), is(expectedExperiment.getStatus())); assertThat(actualExperiment.getAudienceIds(), is(expectedExperiment.getAudienceIds())); - assertEquals(actualExperiment.getAudienceConditions(), expectedExperiment.getAudienceConditions()); + assertThat(actualExperiment.getAudienceConditions(), is(expectedExperiment.getAudienceConditions())); assertThat(actualExperiment.getUserIdToVariationKeyMap(), is(expectedExperiment.getUserIdToVariationKeyMap())); @@ -627,6 +628,23 @@ private static void verifyRollouts(List actual, List expected) } } + private static void verifyIntegrations(List actual, List expected) { + if (expected == null) { + assertNull(actual); + } else { + assertEquals(expected.size(), actual.size()); + + for (int i = 0; i < actual.size(); i++) { + Integration actualIntegrations = actual.get(i); + Integration expectedIntegration = expected.get(i); + + assertEquals(expectedIntegration.getKey(), actualIntegrations.getKey()); + assertEquals(expectedIntegration.getHost(), actualIntegrations.getHost()); + assertEquals(expectedIntegration.getPublicKey(), actualIntegrations.getPublicKey()); + } + } + } + /** * Verify that the provided variation-level feature variable usage instances are equivalent. */ diff --git a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java index f8ea02231..0ed8d5945 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java +++ b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java @@ -1360,6 +1360,7 @@ public class ValidProjectConfigV4 { VARIABLE_INTEGER_VARIABLE ) ); + public static final Integration odpIntegration = new Integration("odp", "https://example.com", "test-key"); public static ProjectConfig generateValidProjectConfigV4() { @@ -1429,6 +1430,9 @@ public static ProjectConfig generateValidProjectConfigV4() { rollouts.add(ROLLOUT_2); rollouts.add(ROLLOUT_3); + List integrations = new ArrayList<>(); + integrations.add(odpIntegration); + return new DatafileProjectConfig( ACCOUNT_ID, ANONYMIZE_IP, @@ -1446,7 +1450,8 @@ public static ProjectConfig generateValidProjectConfigV4() { experiments, featureFlags, groups, - rollouts + rollouts, + integrations ); } } diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java index 07a0c1ad1..5a69b35ee 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java @@ -17,13 +17,16 @@ package com.optimizely.ab.config.audience; import ch.qos.logback.classic.Level; -import com.fasterxml.jackson.databind.deser.std.MapEntryDeserializer; +import com.optimizely.ab.OptimizelyUserContext; import com.optimizely.ab.internal.LogbackVerifier; +import com.optimizely.ab.testutils.OTUtils; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.mockito.Mock; import org.mockito.internal.matchers.Or; +import org.mockito.internal.util.reflection.Whitebox; import java.math.BigInteger; import java.util.*; @@ -129,7 +132,7 @@ public void userAttributeEvaluateTrue() throws Exception { assertNull(testInstance.getMatch()); assertEquals(testInstance.getName(), "browser_type"); assertEquals(testInstance.getType(), "custom_attribute"); - assertTrue(testInstance.evaluate(null, testUserAttributes)); + assertTrue(testInstance.evaluate(null, OTUtils.user(testUserAttributes))); } /** @@ -138,7 +141,7 @@ public void userAttributeEvaluateTrue() throws Exception { @Test public void userAttributeEvaluateFalse() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", null, "firefox"); - assertFalse(testInstance.evaluate(null, testUserAttributes)); + assertFalse(testInstance.evaluate(null, OTUtils.user(testUserAttributes))); } /** @@ -147,7 +150,7 @@ public void userAttributeEvaluateFalse() throws Exception { @Test public void userAttributeUnknownAttribute() throws Exception { UserAttribute testInstance = new UserAttribute("unknown_dim", "custom_attribute", null, "unknown"); - assertFalse(testInstance.evaluate(null, testUserAttributes)); + assertFalse(testInstance.evaluate(null, OTUtils.user(testUserAttributes))); } /** @@ -156,7 +159,7 @@ public void userAttributeUnknownAttribute() throws Exception { @Test public void invalidMatchCondition() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "unknown_dimension", null, "chrome"); - assertNull(testInstance.evaluate(null, testUserAttributes)); + assertNull(testInstance.evaluate(null, OTUtils.user(testUserAttributes))); } /** @@ -165,7 +168,7 @@ public void invalidMatchCondition() throws Exception { @Test public void invalidMatch() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "blah", "chrome"); - assertNull(testInstance.evaluate(null, testUserAttributes)); + assertNull(testInstance.evaluate(null, OTUtils.user(testUserAttributes))); logbackVerifier.expectMessage(Level.WARN, "Audience condition \"{name='browser_type', type='custom_attribute', match='blah', value='chrome'}\" uses an unknown match type. You may need to upgrade to a newer release of the Optimizely SDK"); } @@ -176,7 +179,7 @@ public void invalidMatch() throws Exception { @Test public void unexpectedAttributeType() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "gt", 20); - assertNull(testInstance.evaluate(null, testUserAttributes)); + assertNull(testInstance.evaluate(null, OTUtils.user(testUserAttributes))); logbackVerifier.expectMessage(Level.WARN, "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because a value of type \"java.lang.String\" was passed for user attribute \"browser_type\""); } @@ -187,7 +190,7 @@ public void unexpectedAttributeType() throws Exception { @Test public void unexpectedAttributeTypeNull() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "gt", 20); - assertNull(testInstance.evaluate(null, Collections.singletonMap("browser_type", null))); + assertNull(testInstance.evaluate(null, OTUtils.user(Collections.singletonMap("browser_type", null)))); logbackVerifier.expectMessage(Level.DEBUG, "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because a null value was passed for user attribute \"browser_type\""); } @@ -215,7 +218,7 @@ public void missingAttribute_returnsNullAndLogDebugMessage() throws Exception { for (Map.Entry entry : items.entrySet()) { for (Object value : entry.getValue()) { UserAttribute testInstance = new UserAttribute("n", "custom_attribute", entry.getKey(), value); - assertNull(testInstance.evaluate(null, Collections.EMPTY_MAP)); + assertNull(testInstance.evaluate(null, OTUtils.user(Collections.EMPTY_MAP))); String valueStr = (value instanceof String) ? ("'" + value + "'") : value.toString(); logbackVerifier.expectMessage(Level.DEBUG, "Audience condition \"{name='n', type='custom_attribute', match='" + entry.getKey() + "', value=" + valueStr + "}\" evaluated to UNKNOWN because no value was passed for user attribute \"n\""); @@ -226,10 +229,11 @@ public void missingAttribute_returnsNullAndLogDebugMessage() throws Exception { /** * Verify that UserAttribute.evaluate returns null on passing null attribute object. */ + @SuppressFBWarnings("NP_NULL_PARAM_DEREF_NONVIRTUAL") @Test public void nullAttribute() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "gt", 20); - assertNull(testInstance.evaluate(null, null)); + assertNull(testInstance.evaluate(null, OTUtils.user(null))); logbackVerifier.expectMessage(Level.DEBUG, "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because no value was passed for user attribute \"browser_type\""); } @@ -240,7 +244,7 @@ public void nullAttribute() throws Exception { @Test public void unknownConditionType() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "blah", "exists", "firefox"); - assertNull(testInstance.evaluate(null, testUserAttributes)); + assertNull(testInstance.evaluate(null, OTUtils.user(testUserAttributes))); logbackVerifier.expectMessage(Level.WARN, "Audience condition \"{name='browser_type', type='blah', match='exists', value='firefox'}\" uses an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK."); } @@ -254,9 +258,9 @@ public void existsMatchConditionEmptyStringEvaluatesTrue() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "exists", "firefox"); Map attributes = new HashMap<>(); attributes.put("browser_type", ""); - assertTrue(testInstance.evaluate(null, attributes)); + assertTrue(testInstance.evaluate(null, OTUtils.user(attributes))); attributes.put("browser_type", null); - assertFalse(testInstance.evaluate(null, attributes)); + assertFalse(testInstance.evaluate(null, OTUtils.user(attributes))); } /** @@ -266,16 +270,16 @@ public void existsMatchConditionEmptyStringEvaluatesTrue() throws Exception { @Test public void existsMatchConditionEvaluatesTrue() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "exists", "firefox"); - assertTrue(testInstance.evaluate(null, testUserAttributes)); + assertTrue(testInstance.evaluate(null, OTUtils.user(testUserAttributes))); UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute", "exists", false); UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "exists", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "exists", 4.55); UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "exists", testUserAttributes); - assertTrue(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceObject.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceBoolean.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertTrue(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertTrue(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertTrue(testInstanceObject.evaluate(null, OTUtils.user(testTypedUserAttributes))); } /** @@ -286,8 +290,8 @@ public void existsMatchConditionEvaluatesTrue() throws Exception { public void existsMatchConditionEvaluatesFalse() throws Exception { UserAttribute testInstance = new UserAttribute("bad_var", "custom_attribute", "exists", "chrome"); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "exists", "chrome"); - assertFalse(testInstance.evaluate(null, testTypedUserAttributes)); - assertFalse(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstance.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertFalse(testInstanceNull.evaluate(null, OTUtils.user(testTypedUserAttributes))); } /** @@ -302,11 +306,11 @@ public void exactMatchConditionEvaluatesTrue() { UserAttribute testInstanceFloat = new UserAttribute("num_size", "custom_attribute", "exact", (float) 3); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "exact", 3.55); - assertTrue(testInstanceString.evaluate(null, testUserAttributes)); - assertTrue(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 3))); - assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testUserAttributes))); + assertTrue(testInstanceBoolean.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertTrue(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertTrue(testInstanceFloat.evaluate(null, OTUtils.user(Collections.singletonMap("num_size", (float) 3)))); + assertTrue(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); } /** @@ -339,22 +343,22 @@ public void exactMatchConditionEvaluatesNullWithInvalidUserAttr() { assertNull(testInstanceInteger.evaluate( null, - Collections.singletonMap("num_size", bigInteger))); + OTUtils.user(Collections.singletonMap("num_size", bigInteger)))); assertNull(testInstanceFloat.evaluate( null, - Collections.singletonMap("num_size", invalidFloatValue))); + OTUtils.user(Collections.singletonMap("num_size", invalidFloatValue)))); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); + OTUtils.user(Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble)))); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); + OTUtils.user(Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble)))); assertNull(testInstanceDouble.evaluate( - null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", infiniteNANDouble)))); + null, OTUtils.user(Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", infiniteNANDouble))))); assertNull(testInstanceDouble.evaluate( - null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", largeDouble)))); + null, OTUtils.user(Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", largeDouble))))); } /** @@ -372,10 +376,10 @@ public void invalidExactMatchConditionEvaluatesNull() { UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "exact", infiniteNegativeInfiniteDouble); UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "exact", infiniteNANDouble); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstancePositiveInfinite.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNANDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); } /** @@ -389,10 +393,10 @@ public void exactMatchConditionEvaluatesFalse() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "exact", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "exact", 5.55); - assertFalse(testInstanceString.evaluate(null, testUserAttributes)); - assertFalse(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceString.evaluate(null, OTUtils.user(testUserAttributes))); + assertFalse(testInstanceBoolean.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertFalse(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertFalse(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); } /** @@ -408,15 +412,15 @@ public void exactMatchConditionEvaluatesNull() { UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "exact", "3.55"); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "exact", "null_val"); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceString.evaluate(null, testUserAttributes)); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceObject.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testUserAttributes))); + assertNull(testInstanceBoolean.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNull.evaluate(null, OTUtils.user(testTypedUserAttributes))); Map attr = new HashMap<>(); attr.put("browser_type", "true"); - assertNull(testInstanceString.evaluate(null, attr)); + assertNull(testInstanceString.evaluate(null, OTUtils.user(attr))); } /** @@ -430,13 +434,13 @@ public void gtMatchConditionEvaluatesTrue() { UserAttribute testInstanceFloat = new UserAttribute("num_size", "custom_attribute", "gt", (float) 2); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "gt", 2.55); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 3))); - assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertTrue(testInstanceFloat.evaluate(null, OTUtils.user(Collections.singletonMap("num_size", (float) 3)))); + assertTrue(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); Map badAttributes = new HashMap<>(); badAttributes.put("num_size", "bobs burgers"); - assertNull(testInstanceInteger.evaluate(null, badAttributes)); + assertNull(testInstanceInteger.evaluate(null, OTUtils.user(badAttributes))); } /** @@ -470,22 +474,22 @@ public void gtMatchConditionEvaluatesNullWithInvalidUserAttr() { assertNull(testInstanceInteger.evaluate( null, - Collections.singletonMap("num_size", bigInteger))); + OTUtils.user(Collections.singletonMap("num_size", bigInteger)))); assertNull(testInstanceFloat.evaluate( null, - Collections.singletonMap("num_size", invalidFloatValue))); + OTUtils.user(Collections.singletonMap("num_size", invalidFloatValue)))); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); + OTUtils.user(Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble)))); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); + OTUtils.user(Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble)))); assertNull(testInstanceDouble.evaluate( - null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", infiniteNANDouble)))); + null, OTUtils.user(Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", infiniteNANDouble))))); assertNull(testInstanceDouble.evaluate( - null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", largeDouble)))); + null, OTUtils.user(Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", largeDouble))))); } /** @@ -503,10 +507,10 @@ public void gtMatchConditionEvaluatesNullWithInvalidAttr() { UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "gt", infiniteNegativeInfiniteDouble); UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "gt", infiniteNANDouble); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstancePositiveInfinite.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNANDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); } /** @@ -519,8 +523,8 @@ public void gtMatchConditionEvaluatesFalse() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "gt", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "gt", 5.55); - assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertFalse(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); } /** @@ -534,10 +538,10 @@ public void gtMatchConditionEvaluatesNull() { UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "gt", 3.5); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "gt", 3.5); - assertNull(testInstanceString.evaluate(null, testUserAttributes)); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testUserAttributes))); + assertNull(testInstanceBoolean.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceObject.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNull.evaluate(null, OTUtils.user(testTypedUserAttributes))); } @@ -552,13 +556,13 @@ public void geMatchConditionEvaluatesTrue() { UserAttribute testInstanceFloat = new UserAttribute("num_size", "custom_attribute", "ge", (float) 2); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "ge", 2.55); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 2))); - assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertTrue(testInstanceFloat.evaluate(null, OTUtils.user(Collections.singletonMap("num_size", (float) 2)))); + assertTrue(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); Map badAttributes = new HashMap<>(); badAttributes.put("num_size", "bobs burgers"); - assertNull(testInstanceInteger.evaluate(null, badAttributes)); + assertNull(testInstanceInteger.evaluate(null, OTUtils.user(badAttributes))); } /** @@ -592,22 +596,22 @@ public void geMatchConditionEvaluatesNullWithInvalidUserAttr() { assertNull(testInstanceInteger.evaluate( null, - Collections.singletonMap("num_size", bigInteger))); + OTUtils.user(Collections.singletonMap("num_size", bigInteger)))); assertNull(testInstanceFloat.evaluate( null, - Collections.singletonMap("num_size", invalidFloatValue))); + OTUtils.user(Collections.singletonMap("num_size", invalidFloatValue)))); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); + OTUtils.user(Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble)))); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); + OTUtils.user(Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble)))); assertNull(testInstanceDouble.evaluate( - null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", infiniteNANDouble)))); + null, OTUtils.user(Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", infiniteNANDouble))))); assertNull(testInstanceDouble.evaluate( - null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", largeDouble)))); + null, OTUtils.user(Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", largeDouble))))); } /** @@ -625,10 +629,10 @@ public void geMatchConditionEvaluatesNullWithInvalidAttr() { UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "ge", infiniteNegativeInfiniteDouble); UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "ge", infiniteNANDouble); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstancePositiveInfinite.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNANDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); } /** @@ -641,8 +645,8 @@ public void geMatchConditionEvaluatesFalse() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "ge", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "ge", 5.55); - assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertFalse(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); } /** @@ -656,10 +660,10 @@ public void geMatchConditionEvaluatesNull() { UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "ge", 3.5); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "ge", 3.5); - assertNull(testInstanceString.evaluate(null, testUserAttributes)); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testUserAttributes))); + assertNull(testInstanceBoolean.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceObject.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNull.evaluate(null, OTUtils.user(testTypedUserAttributes))); } @@ -673,8 +677,8 @@ public void ltMatchConditionEvaluatesTrue() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "lt", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "lt", 5.55); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertTrue(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); } /** @@ -687,8 +691,8 @@ public void ltMatchConditionEvaluatesFalse() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "lt", 2); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "lt", 2.55); - assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertFalse(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); } /** @@ -702,10 +706,10 @@ public void ltMatchConditionEvaluatesNull() { UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "lt", 3.5); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "lt", 3.5); - assertNull(testInstanceString.evaluate(null, testUserAttributes)); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testUserAttributes))); + assertNull(testInstanceBoolean.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceObject.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNull.evaluate(null, OTUtils.user(testTypedUserAttributes))); } /** @@ -739,22 +743,22 @@ public void ltMatchConditionEvaluatesNullWithInvalidUserAttr() { assertNull(testInstanceInteger.evaluate( null, - Collections.singletonMap("num_size", bigInteger))); + OTUtils.user(Collections.singletonMap("num_size", bigInteger)))); assertNull(testInstanceFloat.evaluate( null, - Collections.singletonMap("num_size", invalidFloatValue))); + OTUtils.user(Collections.singletonMap("num_size", invalidFloatValue)))); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); + OTUtils.user(Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble)))); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); + OTUtils.user(Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble)))); assertNull(testInstanceDouble.evaluate( - null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", infiniteNANDouble)))); + null, OTUtils.user(Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", infiniteNANDouble))))); assertNull(testInstanceDouble.evaluate( - null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", largeDouble)))); + null, OTUtils.user(Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", largeDouble))))); } /** @@ -772,10 +776,10 @@ public void ltMatchConditionEvaluatesNullWithInvalidAttributes() { UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "lt", infiniteNegativeInfiniteDouble); UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "lt", infiniteNANDouble); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstancePositiveInfinite.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNANDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); } @@ -789,8 +793,8 @@ public void leMatchConditionEvaluatesTrue() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "le", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "le", 5.55); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceDouble.evaluate(null, Collections.singletonMap("num_counts", 5.55))); + assertTrue(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertTrue(testInstanceDouble.evaluate(null, OTUtils.user(Collections.singletonMap("num_counts", 5.55)))); } /** @@ -803,8 +807,8 @@ public void leMatchConditionEvaluatesFalse() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "le", 2); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "le", 2.55); - assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertFalse(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); } /** @@ -818,10 +822,10 @@ public void leMatchConditionEvaluatesNull() { UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "le", 3.5); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "le", 3.5); - assertNull(testInstanceString.evaluate(null, testUserAttributes)); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testUserAttributes))); + assertNull(testInstanceBoolean.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceObject.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNull.evaluate(null, OTUtils.user(testTypedUserAttributes))); } /** @@ -855,22 +859,22 @@ public void leMatchConditionEvaluatesNullWithInvalidUserAttr() { assertNull(testInstanceInteger.evaluate( null, - Collections.singletonMap("num_size", bigInteger))); + OTUtils.user(Collections.singletonMap("num_size", bigInteger)))); assertNull(testInstanceFloat.evaluate( null, - Collections.singletonMap("num_size", invalidFloatValue))); + OTUtils.user(Collections.singletonMap("num_size", invalidFloatValue)))); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); + OTUtils.user(Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble)))); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); + OTUtils.user(Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble)))); assertNull(testInstanceDouble.evaluate( - null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", infiniteNANDouble)))); + null, OTUtils.user(Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", infiniteNANDouble))))); assertNull(testInstanceDouble.evaluate( - null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", largeDouble)))); + null, OTUtils.user(Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", largeDouble))))); } /** @@ -888,10 +892,10 @@ public void leMatchConditionEvaluatesNullWithInvalidAttributes() { UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "le", infiniteNegativeInfiniteDouble); UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "le", infiniteNANDouble); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstancePositiveInfinite.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNANDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); } /** @@ -901,7 +905,7 @@ public void leMatchConditionEvaluatesNullWithInvalidAttributes() { @Test public void substringMatchConditionEvaluatesTrue() { UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "substring", "chrome"); - assertTrue(testInstanceString.evaluate(null, testUserAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testUserAttributes))); } /** @@ -911,7 +915,7 @@ public void substringMatchConditionEvaluatesTrue() { @Test public void substringMatchConditionPartialMatchEvaluatesTrue() { UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "substring", "chro"); - assertTrue(testInstanceString.evaluate(null, testUserAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testUserAttributes))); } /** @@ -921,7 +925,7 @@ public void substringMatchConditionPartialMatchEvaluatesTrue() { @Test public void substringMatchConditionEvaluatesFalse() { UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "substring", "chr0me"); - assertFalse(testInstanceString.evaluate(null, testUserAttributes)); + assertFalse(testInstanceString.evaluate(null, OTUtils.user(testUserAttributes))); } /** @@ -936,11 +940,11 @@ public void substringMatchConditionEvaluatesNull() { UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "substring", "chrome1"); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "substring", "chrome1"); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceBoolean.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceObject.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNull.evaluate(null, OTUtils.user(testTypedUserAttributes))); } //======== Semantic version evaluation tests ========// @@ -951,7 +955,7 @@ public void testSemanticVersionEqualsMatchInvalidInput() { Map testAttributes = new HashMap(); testAttributes.put("version", 2.0); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } @Test @@ -959,7 +963,7 @@ public void semanticVersionInvalidMajorShouldBeNumberOnly() { Map testAttributes = new HashMap(); testAttributes.put("version", "a.1.2"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } @Test @@ -967,7 +971,7 @@ public void semanticVersionInvalidMinorShouldBeNumberOnly() { Map testAttributes = new HashMap(); testAttributes.put("version", "1.b.2"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } @Test @@ -975,7 +979,7 @@ public void semanticVersionInvalidPatchShouldBeNumberOnly() { Map testAttributes = new HashMap(); testAttributes.put("version", "1.2.c"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test SemanticVersionEqualsMatch returns null if given invalid UserCondition Variable type @@ -984,7 +988,7 @@ public void testSemanticVersionEqualsMatchInvalidUserConditionVariable() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.0"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", 2.0); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test SemanticVersionGTMatch returns null if given invalid value type @@ -993,7 +997,7 @@ public void testSemanticVersionGTMatchInvalidInput() { Map testAttributes = new HashMap(); testAttributes.put("version", false); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test SemanticVersionGEMatch returns null if given invalid value type @@ -1002,7 +1006,7 @@ public void testSemanticVersionGEMatchInvalidInput() { Map testAttributes = new HashMap(); testAttributes.put("version", 2); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test SemanticVersionLTMatch returns null if given invalid value type @@ -1011,7 +1015,7 @@ public void testSemanticVersionLTMatchInvalidInput() { Map testAttributes = new HashMap(); testAttributes.put("version", 2); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test SemanticVersionLEMatch returns null if given invalid value type @@ -1020,7 +1024,7 @@ public void testSemanticVersionLEMatchInvalidInput() { Map testAttributes = new HashMap(); testAttributes.put("version", 2); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test if not same when targetVersion is only major.minor.patch and version is major.minor @@ -1029,7 +1033,7 @@ public void testIsSemanticNotSameConditionValueMajorMinorPatch() { Map testAttributes = new HashMap(); testAttributes.put("version", "1.2"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "1.2.0"); - assertFalse(testInstanceString.evaluate(null, testAttributes)); + assertFalse(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test if same when target is only major but user condition checks only major.minor,patch @@ -1038,7 +1042,7 @@ public void testIsSemanticSameSingleDigit() { Map testAttributes = new HashMap(); testAttributes.put("version", "3.0.0"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test if greater when User value patch is greater even when its beta @@ -1047,7 +1051,7 @@ public void testIsSemanticGreaterWhenUserConditionComparesMajorMinorAndPatchVers Map testAttributes = new HashMap(); testAttributes.put("version", "3.1.1-beta"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.0"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test if greater when preRelease is greater alphabetically @@ -1056,7 +1060,7 @@ public void testIsSemanticGreaterWhenMajorMinorPatchReleaseVersionCharacter() { Map testAttributes = new HashMap(); testAttributes.put("version", "3.1.1-beta.y.1+1.1"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.1-beta.x.1+1.1"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test if greater when preRelease version number is greater @@ -1065,7 +1069,7 @@ public void testIsSemanticGreaterWhenMajorMinorPatchPreReleaseVersionNum() { Map testAttributes = new HashMap(); testAttributes.put("version", "3.1.1-beta.x.2+1.1"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.1-beta.x.1+1.1"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test if equals semantic version even when only same preRelease is passed in user attribute and no build meta @@ -1074,7 +1078,7 @@ public void testIsSemanticEqualWhenMajorMinorPatchPreReleaseVersionNum() { Map testAttributes = new HashMap(); testAttributes.put("version", "3.1.1-beta.x.1"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3.1.1-beta.x.1"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test if not same @@ -1083,7 +1087,7 @@ public void testIsSemanticNotSameReturnsFalse() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.2"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.1.1"); - assertFalse(testInstanceString.evaluate(null, testAttributes)); + assertFalse(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test when target is full semantic version major.minor.patch @@ -1092,7 +1096,7 @@ public void testIsSemanticSameFull() { Map testAttributes = new HashMap(); testAttributes.put("version", "3.0.1"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3.0.1"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test compare less when user condition checks only major.minor @@ -1101,7 +1105,7 @@ public void testIsSemanticLess() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.6"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.2"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // When user condition checks major.minor but target is major.minor.patch then its equals @@ -1110,7 +1114,7 @@ public void testIsSemanticLessFalse() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.0"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.1"); - assertFalse(testInstanceString.evaluate(null, testAttributes)); + assertFalse(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test compare less when target is full major.minor.patch @@ -1119,7 +1123,7 @@ public void testIsSemanticFullLess() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.6"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.1.9"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test compare greater when user condition checks only major.minor @@ -1128,7 +1132,7 @@ public void testIsSemanticMore() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.3.6"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.2"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test compare greater when both are major.minor.patch-beta but target is greater than user condition @@ -1137,7 +1141,7 @@ public void testIsSemanticMoreWhenBeta() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.3.6-beta"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.3.5-beta"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test compare greater when target is major.minor.patch @@ -1146,7 +1150,7 @@ public void testIsSemanticFullMore() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.7"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.6"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test compare greater when target is major.minor.patch is smaller then it returns false @@ -1155,7 +1159,7 @@ public void testSemanticVersionGTFullMoreReturnsFalse() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.10"); - assertFalse(testInstanceString.evaluate(null, testAttributes)); + assertFalse(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test compare equal when both are exactly same - major.minor.patch-beta @@ -1164,7 +1168,7 @@ public void testIsSemanticFullEqual() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9-beta"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.1.9-beta"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test compare equal when both major.minor.patch is same, but due to beta user condition is smaller @@ -1173,7 +1177,7 @@ public void testIsSemanticLessWhenBeta() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.9-beta"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test compare greater when target is major.minor.patch-beta and user condition only compares major.minor.patch @@ -1182,7 +1186,7 @@ public void testIsSemanticGreaterBeta() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.9-beta"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test compare equal when target is major.minor.patch @@ -1191,7 +1195,7 @@ public void testIsSemanticLessEqualsWhenEqualsReturnsTrue() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.1.9"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test compare less when target is major.minor.patch @@ -1200,7 +1204,7 @@ public void testIsSemanticLessEqualsWhenLessReturnsTrue() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.132.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.233.91"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test compare less when target is major.minor.patch @@ -1209,7 +1213,7 @@ public void testIsSemanticLessEqualsWhenGreaterReturnsFalse() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.233.91"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.132.009"); - assertFalse(testInstanceString.evaluate(null, testAttributes)); + assertFalse(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test compare equal when target is major.minor.patch @@ -1218,7 +1222,7 @@ public void testIsSemanticGreaterEqualsWhenEqualsReturnsTrue() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.1.9"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test compare less when target is major.minor.patch @@ -1227,7 +1231,7 @@ public void testIsSemanticGreaterEqualsWhenLessReturnsTrue() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.233.91"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.132.9"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } // Test compare less when target is major.minor.patch @@ -1236,7 +1240,7 @@ public void testIsSemanticGreaterEqualsWhenLessReturnsFalse() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.132.009"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.233.91"); - assertFalse(testInstanceString.evaluate(null, testAttributes)); + assertFalse(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } /** @@ -1245,7 +1249,7 @@ public void testIsSemanticGreaterEqualsWhenLessReturnsFalse() { @Test public void notConditionEvaluateNull() { NotCondition notCondition = new NotCondition(new NullCondition()); - assertNull(notCondition.evaluate(null, testUserAttributes)); + assertNull(notCondition.evaluate(null, OTUtils.user(testUserAttributes))); } /** @@ -1253,12 +1257,13 @@ public void notConditionEvaluateNull() { */ @Test public void notConditionEvaluateTrue() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); UserAttribute userAttribute = mock(UserAttribute.class); - when(userAttribute.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute.evaluate(null, user)).thenReturn(false); NotCondition notCondition = new NotCondition(userAttribute); - assertTrue(notCondition.evaluate(null, testUserAttributes)); - verify(userAttribute, times(1)).evaluate(null, testUserAttributes); + assertTrue(notCondition.evaluate(null, user)); + verify(userAttribute, times(1)).evaluate(null, user); } /** @@ -1266,12 +1271,13 @@ public void notConditionEvaluateTrue() { */ @Test public void notConditionEvaluateFalse() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); UserAttribute userAttribute = mock(UserAttribute.class); - when(userAttribute.evaluate(null, testUserAttributes)).thenReturn(true); + when(userAttribute.evaluate(null, user)).thenReturn(true); NotCondition notCondition = new NotCondition(userAttribute); - assertFalse(notCondition.evaluate(null, testUserAttributes)); - verify(userAttribute, times(1)).evaluate(null, testUserAttributes); + assertFalse(notCondition.evaluate(null, user)); + verify(userAttribute, times(1)).evaluate(null, user); } /** @@ -1279,21 +1285,22 @@ public void notConditionEvaluateFalse() { */ @Test public void orConditionEvaluateTrue() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(null, testUserAttributes)).thenReturn(true); + when(userAttribute1.evaluate(null, user)).thenReturn(true); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute2.evaluate(null, user)).thenReturn(false); List conditions = new ArrayList(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertTrue(orCondition.evaluate(null, testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); + assertTrue(orCondition.evaluate(null, user)); + verify(userAttribute1, times(1)).evaluate(null, user); // shouldn't be called due to short-circuiting in 'Or' evaluation - verify(userAttribute2, times(0)).evaluate(null, testUserAttributes); + verify(userAttribute2, times(0)).evaluate(null, user); } /** @@ -1301,21 +1308,22 @@ public void orConditionEvaluateTrue() { */ @Test public void orConditionEvaluateTrueWithNullAndTrue() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(null, testUserAttributes)).thenReturn(null); + when(userAttribute1.evaluate(null, user)).thenReturn(null); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(true); + when(userAttribute2.evaluate(null, user)).thenReturn(true); List conditions = new ArrayList(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertTrue(orCondition.evaluate(null, testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); + assertTrue(orCondition.evaluate(null, user)); + verify(userAttribute1, times(1)).evaluate(null, user); // shouldn't be called due to short-circuiting in 'Or' evaluation - verify(userAttribute2, times(1)).evaluate(null, testUserAttributes); + verify(userAttribute2, times(1)).evaluate(null, user); } /** @@ -1323,21 +1331,22 @@ public void orConditionEvaluateTrueWithNullAndTrue() { */ @Test public void orConditionEvaluateNullWithNullAndFalse() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(null, testUserAttributes)).thenReturn(null); + when(userAttribute1.evaluate(null, user)).thenReturn(null); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute2.evaluate(null, user)).thenReturn(false); List conditions = new ArrayList(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertNull(orCondition.evaluate(null, testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); + assertNull(orCondition.evaluate(null, user)); + verify(userAttribute1, times(1)).evaluate(null, user); // shouldn't be called due to short-circuiting in 'Or' evaluation - verify(userAttribute2, times(1)).evaluate(null, testUserAttributes); + verify(userAttribute2, times(1)).evaluate(null, user); } /** @@ -1345,21 +1354,22 @@ public void orConditionEvaluateNullWithNullAndFalse() { */ @Test public void orConditionEvaluateFalseWithFalseAndFalse() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute1.evaluate(null, user)).thenReturn(false); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute2.evaluate(null, user)).thenReturn(false); List conditions = new ArrayList(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertFalse(orCondition.evaluate(null, testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); + assertFalse(orCondition.evaluate(null, user)); + verify(userAttribute1, times(1)).evaluate(null, user); // shouldn't be called due to short-circuiting in 'Or' evaluation - verify(userAttribute2, times(1)).evaluate(null, testUserAttributes); + verify(userAttribute2, times(1)).evaluate(null, user); } /** @@ -1367,20 +1377,21 @@ public void orConditionEvaluateFalseWithFalseAndFalse() { */ @Test public void orConditionEvaluateFalse() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute1.evaluate(null, user)).thenReturn(false); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute2.evaluate(null, user)).thenReturn(false); List conditions = new ArrayList(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertFalse(orCondition.evaluate(null, testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); - verify(userAttribute2, times(1)).evaluate(null, testUserAttributes); + assertFalse(orCondition.evaluate(null, user)); + verify(userAttribute1, times(1)).evaluate(null, user); + verify(userAttribute2, times(1)).evaluate(null, user); } /** @@ -1388,20 +1399,21 @@ public void orConditionEvaluateFalse() { */ @Test public void andConditionEvaluateTrue() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); OrCondition orCondition1 = mock(OrCondition.class); - when(orCondition1.evaluate(null, testUserAttributes)).thenReturn(true); + when(orCondition1.evaluate(null, user)).thenReturn(true); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(null, testUserAttributes)).thenReturn(true); + when(orCondition2.evaluate(null, user)).thenReturn(true); List conditions = new ArrayList(); conditions.add(orCondition1); conditions.add(orCondition2); AndCondition andCondition = new AndCondition(conditions); - assertTrue(andCondition.evaluate(null, testUserAttributes)); - verify(orCondition1, times(1)).evaluate(null, testUserAttributes); - verify(orCondition2, times(1)).evaluate(null, testUserAttributes); + assertTrue(andCondition.evaluate(null, user)); + verify(orCondition1, times(1)).evaluate(null, user); + verify(orCondition2, times(1)).evaluate(null, user); } /** @@ -1409,20 +1421,21 @@ public void andConditionEvaluateTrue() { */ @Test public void andConditionEvaluateFalseWithNullAndFalse() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); OrCondition orCondition1 = mock(OrCondition.class); - when(orCondition1.evaluate(null, testUserAttributes)).thenReturn(null); + when(orCondition1.evaluate(null, user)).thenReturn(null); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(null, testUserAttributes)).thenReturn(false); + when(orCondition2.evaluate(null, user)).thenReturn(false); List conditions = new ArrayList(); conditions.add(orCondition1); conditions.add(orCondition2); AndCondition andCondition = new AndCondition(conditions); - assertFalse(andCondition.evaluate(null, testUserAttributes)); - verify(orCondition1, times(1)).evaluate(null, testUserAttributes); - verify(orCondition2, times(1)).evaluate(null, testUserAttributes); + assertFalse(andCondition.evaluate(null, user)); + verify(orCondition1, times(1)).evaluate(null, user); + verify(orCondition2, times(1)).evaluate(null, user); } /** @@ -1430,20 +1443,21 @@ public void andConditionEvaluateFalseWithNullAndFalse() { */ @Test public void andConditionEvaluateNullWithNullAndTrue() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); OrCondition orCondition1 = mock(OrCondition.class); - when(orCondition1.evaluate(null, testUserAttributes)).thenReturn(null); + when(orCondition1.evaluate(null, user)).thenReturn(null); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(null, testUserAttributes)).thenReturn(true); + when(orCondition2.evaluate(null, user)).thenReturn(true); List conditions = new ArrayList(); conditions.add(orCondition1); conditions.add(orCondition2); AndCondition andCondition = new AndCondition(conditions); - assertNull(andCondition.evaluate(null, testUserAttributes)); - verify(orCondition1, times(1)).evaluate(null, testUserAttributes); - verify(orCondition2, times(1)).evaluate(null, testUserAttributes); + assertNull(andCondition.evaluate(null, user)); + verify(orCondition1, times(1)).evaluate(null, user); + verify(orCondition2, times(1)).evaluate(null, user); } /** @@ -1451,11 +1465,12 @@ public void andConditionEvaluateNullWithNullAndTrue() { */ @Test public void andConditionEvaluateFalse() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); OrCondition orCondition1 = mock(OrCondition.class); - when(orCondition1.evaluate(null, testUserAttributes)).thenReturn(false); + when(orCondition1.evaluate(null, user)).thenReturn(false); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(null, testUserAttributes)).thenReturn(true); + when(orCondition2.evaluate(null, user)).thenReturn(true); // and[false, true] List conditions = new ArrayList(); @@ -1463,13 +1478,13 @@ public void andConditionEvaluateFalse() { conditions.add(orCondition2); AndCondition andCondition = new AndCondition(conditions); - assertFalse(andCondition.evaluate(null, testUserAttributes)); - verify(orCondition1, times(1)).evaluate(null, testUserAttributes); + assertFalse(andCondition.evaluate(null, user)); + verify(orCondition1, times(1)).evaluate(null, user); // shouldn't be called due to short-circuiting in 'And' evaluation - verify(orCondition2, times(0)).evaluate(null, testUserAttributes); + verify(orCondition2, times(0)).evaluate(null, user); OrCondition orCondition3 = mock(OrCondition.class); - when(orCondition3.evaluate(null, testUserAttributes)).thenReturn(null); + when(orCondition3.evaluate(null, user)).thenReturn(null); // and[null, false] List conditions2 = new ArrayList(); @@ -1477,7 +1492,7 @@ public void andConditionEvaluateFalse() { conditions2.add(orCondition1); AndCondition andCondition2 = new AndCondition(conditions2); - assertFalse(andCondition2.evaluate(null, testUserAttributes)); + assertFalse(andCondition2.evaluate(null, user)); // and[true, false, null] List conditions3 = new ArrayList(); @@ -1486,7 +1501,310 @@ public void andConditionEvaluateFalse() { conditions3.add(orCondition1); AndCondition andCondition3 = new AndCondition(conditions3); - assertFalse(andCondition3.evaluate(null, testUserAttributes)); + assertFalse(andCondition3.evaluate(null, user)); + } + + /** + * Verify that with odp segment evaluator single ODP audience evaluates true + */ + @Test + public void singleODPAudienceEvaluateTrueIfSegmentExist() throws Exception { + + OptimizelyUserContext mockedUser = OTUtils.user(); + + UserAttribute testInstanceSingleAudience = new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-1"); + List userConditions = new ArrayList<>(); + userConditions.add(testInstanceSingleAudience); + AndCondition andCondition = new AndCondition(userConditions); + + // Should evaluate true if qualified segment exist + Whitebox.setInternalState(mockedUser, "qualifiedSegments", Collections.singletonList("odp-segment-1")); + + assertTrue(andCondition.evaluate(null, mockedUser)); + } + + /** + * Verify that with odp segment evaluator single ODP audience evaluates false + */ + @Test + public void singleODPAudienceEvaluateFalseIfSegmentNotExist() throws Exception { + + OptimizelyUserContext mockedUser = OTUtils.user(); + + UserAttribute testInstanceSingleAudience = new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-1"); + List userConditions = new ArrayList<>(); + userConditions.add(testInstanceSingleAudience); + AndCondition andCondition = new AndCondition(userConditions); + + // Should evaluate false if qualified segment does not exist + Whitebox.setInternalState(mockedUser, "qualifiedSegments", Collections.singletonList("odp-segment-2")); + + assertFalse(andCondition.evaluate(null, mockedUser)); + } + + /** + * Verify that with odp segment evaluator single ODP audience evaluates false when segments not provided + */ + @Test + public void singleODPAudienceEvaluateFalseIfSegmentNotProvided() throws Exception { + OptimizelyUserContext mockedUser = OTUtils.user(); + + UserAttribute testInstanceSingleAudience = new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-1"); + List userConditions = new ArrayList<>(); + userConditions.add(testInstanceSingleAudience); + AndCondition andCondition = new AndCondition(userConditions); + + // Should evaluate false if qualified segment does not exist + Whitebox.setInternalState(mockedUser, "qualifiedSegments", Collections.emptyList()); + + assertFalse(andCondition.evaluate(null, mockedUser)); + } + + /** + * Verify that with odp segment evaluator evaluates multiple ODP audience true when segment provided exist + */ + @Test + public void singleODPAudienceEvaluateMultipleOdpConditions() { + OptimizelyUserContext mockedUser = OTUtils.user(); + + Condition andCondition = createMultipleConditionAudienceAndOrODP(); + // Should evaluate correctly based on the given segments + List qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + qualifiedSegments.add("odp-segment-3"); + + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertTrue(andCondition.evaluate(null, mockedUser)); + + qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + qualifiedSegments.add("odp-segment-4"); + + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertTrue(andCondition.evaluate(null, mockedUser)); + + qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + qualifiedSegments.add("odp-segment-3"); + qualifiedSegments.add("odp-segment-4"); + + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertTrue(andCondition.evaluate(null, mockedUser)); + } + + /** + * Verify that with odp segment evaluator evaluates multiple ODP audience true when segment provided exist + */ + @Test + public void singleODPAudienceEvaluateMultipleOdpConditionsEvaluateFalse() { + OptimizelyUserContext mockedUser = OTUtils.user(); + + Condition andCondition = createMultipleConditionAudienceAndOrODP(); + // Should evaluate correctly based on the given segments + List qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-3"); + qualifiedSegments.add("odp-segment-4"); + + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertFalse(andCondition.evaluate(null, mockedUser)); + + qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-2"); + qualifiedSegments.add("odp-segment-3"); + qualifiedSegments.add("odp-segment-4"); + + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertFalse(andCondition.evaluate(null, mockedUser)); + } + + /** + * Verify that with odp segment evaluator evaluates multiple ODP audience with multiple conditions true or false when segment conditions meet + */ + @Test + public void multipleAudienceEvaluateMultipleOdpConditionsEvaluate() { + OptimizelyUserContext mockedUser = OTUtils.user(); + + // ["and", "1", "2"] + List audience1And2 = new ArrayList<>(); + audience1And2.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-1")); + audience1And2.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-2")); + AndCondition audienceCondition1 = new AndCondition(audience1And2); + + // ["and", "3", "4"] + List audience3And4 = new ArrayList<>(); + audience3And4.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-3")); + audience3And4.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-4")); + AndCondition audienceCondition2 = new AndCondition(audience3And4); + + // ["or", "5", "6"] + List audience5And6 = new ArrayList<>(); + audience5And6.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-5")); + audience5And6.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-6")); + OrCondition audienceCondition3 = new OrCondition(audience5And6); + + + //Scenario 1- ['or', '1', '2', '3'] + List conditions = new ArrayList<>(); + conditions.add(audienceCondition1); + conditions.add(audienceCondition2); + conditions.add(audienceCondition3); + + OrCondition implicitOr = new OrCondition(conditions); + // Should evaluate correctly based on the given segments + List qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertTrue(implicitOr.evaluate(null, mockedUser)); + + + //Scenario 2- ['and', '1', '2', '3'] + AndCondition implicitAnd = new AndCondition(conditions); + // Should evaluate correctly based on the given segments + qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertFalse(implicitAnd.evaluate(null, mockedUser)); + + // Should evaluate correctly based on the given segments + qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + qualifiedSegments.add("odp-segment-3"); + qualifiedSegments.add("odp-segment-4"); + qualifiedSegments.add("odp-segment-6"); + + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertTrue(implicitAnd.evaluate(null, mockedUser)); + + + ////Scenario 3- ['and', '1', '2',['not', '3']] + conditions = new ArrayList<>(); + conditions.add(audienceCondition1); + conditions.add(audienceCondition2); + conditions.add(new NotCondition(audienceCondition3)); + implicitAnd = new AndCondition(conditions); + + // Should evaluate correctly based on the given segments + qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + qualifiedSegments.add("odp-segment-3"); + qualifiedSegments.add("odp-segment-4"); + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertTrue(implicitAnd.evaluate(null, mockedUser)); + + // Should evaluate correctly based on the given segments + qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + qualifiedSegments.add("odp-segment-3"); + qualifiedSegments.add("odp-segment-4"); + qualifiedSegments.add("odp-segment-5"); + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertFalse(implicitAnd.evaluate(null, mockedUser)); + } + + /** + * Verify that with odp segment evaluator evaluates multiple ODP audience with multiple type of evaluators + */ + @Test + public void multipleAudienceEvaluateMultipleOdpConditionsEvaluateWithMultipleTypeOfEvaluator() { + OptimizelyUserContext mockedUser = OTUtils.user(); + + // ["and", "1", "2"] + List audience1And2 = new ArrayList<>(); + audience1And2.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-1")); + audience1And2.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-2")); + AndCondition audienceCondition1 = new AndCondition(audience1And2); + + // ["and", "3", "4"] + List audience3And4 = new ArrayList<>(); + audience3And4.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-3")); + audience3And4.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-4")); + AndCondition audienceCondition2 = new AndCondition(audience3And4); + + // ["or", "chrome", "safari"] + List chromeUserAudience = new ArrayList<>(); + chromeUserAudience.add(new UserAttribute("browser_type", "custom_attribute", "exact", "chrome")); + chromeUserAudience.add(new UserAttribute("browser_type", "custom_attribute", "exact", "safari")); + OrCondition audienceCondition3 = new OrCondition(chromeUserAudience); + + + //Scenario 1- ['or', '1', '2', '3'] + List conditions = new ArrayList<>(); + conditions.add(audienceCondition1); + conditions.add(audienceCondition2); + conditions.add(audienceCondition3); + + OrCondition implicitOr = new OrCondition(conditions); + // Should evaluate correctly based on the given segments + List qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertTrue(implicitOr.evaluate(null, mockedUser)); + + + //Scenario 2- ['and', '1', '2', '3'] + AndCondition implicitAnd = new AndCondition(conditions); + // Should evaluate correctly based on the given segments + qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + + mockedUser = OTUtils.user(Collections.singletonMap("browser_type", "chrome")); + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertFalse(implicitAnd.evaluate(null, mockedUser)); + + // Should evaluate correctly based on the given segments + qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + qualifiedSegments.add("odp-segment-3"); + qualifiedSegments.add("odp-segment-4"); + + mockedUser = OTUtils.user(Collections.singletonMap("browser_type", "chrome")); + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertTrue(implicitAnd.evaluate(null, mockedUser)); + + // Should evaluate correctly based on the given segments + qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + qualifiedSegments.add("odp-segment-3"); + qualifiedSegments.add("odp-segment-4"); + + mockedUser = OTUtils.user(Collections.singletonMap("browser_type", "not_chrome")); + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertFalse(implicitAnd.evaluate(null, mockedUser)); + } + + public Condition createMultipleConditionAudienceAndOrODP() { + UserAttribute testInstanceSingleAudience1 = new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-1"); + UserAttribute testInstanceSingleAudience2 = new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-2"); + UserAttribute testInstanceSingleAudience3 = new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-3"); + UserAttribute testInstanceSingleAudience4 = new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-4"); + + List userConditionsOR = new ArrayList<>(); + userConditionsOR.add(testInstanceSingleAudience3); + userConditionsOR.add(testInstanceSingleAudience4); + OrCondition orCondition = new OrCondition(userConditionsOR); + List userConditionsAnd = new ArrayList<>(); + userConditionsAnd.add(testInstanceSingleAudience1); + userConditionsAnd.add(testInstanceSingleAudience2); + userConditionsAnd.add(orCondition); + AndCondition andCondition = new AndCondition(userConditionsAnd); + + return andCondition; } /** @@ -1498,7 +1816,7 @@ public void andConditionEvaluateFalse() { // } /** - * Verify that {@link Condition#evaluate(com.optimizely.ab.config.ProjectConfig, java.util.Map)} + * Verify that {@link Condition#evaluate(com.optimizely.ab.config.ProjectConfig, com.optimizely.ab.OptimizelyUserContext)} * called when its attribute value is null * returns True when the user's attribute value is also null * True when the attribute is not in the map @@ -1518,8 +1836,8 @@ public void nullValueEvaluate() { attributeValue ); - assertNull(nullValueAttribute.evaluate(null, Collections.emptyMap())); - assertNull(nullValueAttribute.evaluate(null, Collections.singletonMap(attributeName, attributeValue))); - assertNull(nullValueAttribute.evaluate(null, (Collections.singletonMap(attributeName, "")))); + assertNull(nullValueAttribute.evaluate(null, OTUtils.user(Collections.emptyMap()))); + assertNull(nullValueAttribute.evaluate(null, OTUtils.user(Collections.singletonMap(attributeName, attributeValue)))); + assertNull(nullValueAttribute.evaluate(null, OTUtils.user((Collections.singletonMap(attributeName, ""))))); } } diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/DefaultConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/DefaultConfigParserTest.java index dfc130f21..c43f29599 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/DefaultConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/DefaultConfigParserTest.java @@ -20,6 +20,7 @@ import com.optimizely.ab.internal.PropertyUtils; import org.hamcrest.CoreMatchers; import org.junit.Assert; +import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java index 7cf4610ca..ea0d9cac8 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java @@ -29,6 +29,7 @@ import com.optimizely.ab.config.audience.TypedAudience; import com.optimizely.ab.internal.InvalidAudienceCondition; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; @@ -312,6 +313,69 @@ public void nullJsonExceptionWrapping() throws Exception { parser.parseProjectConfig(null); } + @Test + public void integrationsArrayAbsent() throws Exception { + GsonConfigParser parser = new GsonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(nullFeatureEnabledConfigJsonV4()); + assertEquals(actual.getHostForODP(), ""); + assertEquals(actual.getPublicKeyForODP(), ""); + } + + @Test + public void integrationsArrayHasODP() throws Exception { + GsonConfigParser parser = new GsonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + assertEquals(actual.getHostForODP(), "https://example.com"); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + + @Test + public void integrationsArrayHasOtherIntegration() throws Exception { + GsonConfigParser parser = new GsonConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"not-odp\", " + + "\"host\": \"https://example.com\", " + + "\"publicKey\": \"test-key\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getIntegrations().size(), 1); + assertEquals(actual.getHostForODP(), ""); + assertEquals(actual.getPublicKeyForODP(), ""); + } + + @Test + public void integrationsArrayHasMissingHost() throws Exception { + GsonConfigParser parser = new GsonConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"odp\", " + + "\"publicKey\": \"test-key\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getHostForODP(), null); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + + @Test + public void integrationsArrayHasOtherKeys() throws Exception { + GsonConfigParser parser = new GsonConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"odp\", " + + "\"host\": \"https://example.com\", " + + "\"publicKey\": \"test-key\", " + + "\"new-key\": \"new-value\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getIntegrations().size(), 1); + assertEquals(actual.getHostForODP(), "https://example.com"); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + @Test public void testToJson() { Map map = new HashMap<>(); diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java index 2e5dcb672..733ae49a5 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java @@ -26,6 +26,7 @@ import com.optimizely.ab.config.audience.TypedAudience; import com.optimizely.ab.internal.InvalidAudienceCondition; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; @@ -304,6 +305,69 @@ public void nullJsonExceptionWrapping() throws Exception { parser.parseProjectConfig(null); } + @Test + public void integrationsArrayAbsent() throws Exception { + JacksonConfigParser parser = new JacksonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(nullFeatureEnabledConfigJsonV4()); + assertEquals(actual.getHostForODP(), ""); + assertEquals(actual.getPublicKeyForODP(), ""); + } + + @Test + public void integrationsArrayHasODP() throws Exception { + JacksonConfigParser parser = new JacksonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + assertEquals(actual.getHostForODP(), "https://example.com"); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + + @Test + public void integrationsArrayHasOtherIntegration() throws Exception { + JacksonConfigParser parser = new JacksonConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"not-odp\", " + + "\"host\": \"https://example.com\", " + + "\"publicKey\": \"test-key\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getIntegrations().size(), 1); + assertEquals(actual.getHostForODP(), ""); + assertEquals(actual.getPublicKeyForODP(), ""); + } + + @Test + public void integrationsArrayHasMissingHost() throws Exception { + JacksonConfigParser parser = new JacksonConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"odp\", " + + "\"publicKey\": \"test-key\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getHostForODP(), null); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + + @Test + public void integrationsArrayHasOtherKeys() throws Exception { + JacksonConfigParser parser = new JacksonConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"odp\", " + + "\"host\": \"https://example.com\", " + + "\"publicKey\": \"test-key\", " + + "\"new-key\": \"new-value\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getIntegrations().size(), 1); + assertEquals(actual.getHostForODP(), "https://example.com"); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + @Test public void testToJson() { Map map = new HashMap<>(); diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java index d78f57e75..844d7448b 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java @@ -27,6 +27,7 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.json.JSONArray; import org.json.JSONObject; +import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; @@ -255,6 +256,68 @@ public void nullJsonExceptionWrapping() throws Exception { parser.parseProjectConfig(null); } + @Test + public void integrationsArrayAbsent() throws Exception { + JsonConfigParser parser = new JsonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(nullFeatureEnabledConfigJsonV4()); + assertEquals(actual.getHostForODP(), ""); + assertEquals(actual.getPublicKeyForODP(), ""); + } + + @Test + public void integrationsArrayHasODP() throws Exception { + JsonConfigParser parser = new JsonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + assertEquals(actual.getHostForODP(), "https://example.com"); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + + @Test + public void integrationsArrayHasOtherIntegration() throws Exception { + JsonConfigParser parser = new JsonConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"not-odp\", " + + "\"host\": \"https://example.com\", " + + "\"publicKey\": \"test-key\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getIntegrations().size(), 1); + assertEquals(actual.getHostForODP(), ""); + assertEquals(actual.getPublicKeyForODP(), ""); + } + + @Test + public void integrationsArrayHasMissingHost() throws Exception { + JsonConfigParser parser = new JsonConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"odp\", " + + "\"publicKey\": \"test-key\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getHostForODP(), null); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + + @Test + public void integrationsArrayHasOtherKeys() throws Exception { + JsonConfigParser parser = new JsonConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"odp\", " + + "\"host\": \"https://example.com\", " + + "\"publicKey\": \"test-key\", " + + "\"new-key\": \"new-value\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getIntegrations().size(), 1); + assertEquals(actual.getHostForODP(), "https://example.com"); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } @Test public void testToJson() { Map map = new HashMap<>(); diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java index 6c5dca1eb..1844fa967 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java @@ -27,6 +27,7 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.json.JSONArray; import org.json.JSONObject; +import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; @@ -254,6 +255,69 @@ public void nullJsonExceptionWrapping() throws Exception { parser.parseProjectConfig(null); } + @Test + public void integrationsArrayAbsent() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(nullFeatureEnabledConfigJsonV4()); + assertEquals(actual.getHostForODP(), ""); + assertEquals(actual.getPublicKeyForODP(), ""); + } + + @Test + public void integrationsArrayHasODP() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + assertEquals(actual.getHostForODP(), "https://example.com"); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + + @Test + public void integrationsArrayHasOtherIntegration() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"not-odp\", " + + "\"host\": \"https://example.com\", " + + "\"publicKey\": \"test-key\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getIntegrations().size(), 1); + assertEquals(actual.getHostForODP(), ""); + assertEquals(actual.getPublicKeyForODP(), ""); + } + + @Test + public void integrationsArrayHasMissingHost() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"odp\", " + + "\"publicKey\": \"test-key\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getHostForODP(), null); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + + @Test + public void integrationsArrayHasOtherKeys() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"odp\", " + + "\"host\": \"https://example.com\", " + + "\"publicKey\": \"test-key\", " + + "\"new-key\": \"new-value\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getIntegrations().size(), 1); + assertEquals(actual.getHostForODP(), "https://example.com"); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + @Test public void testToJson() { Map map = new HashMap<>(); diff --git a/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java b/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java index fd1529aaf..d7965ccac 100644 --- a/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java +++ b/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java @@ -1,12 +1,12 @@ /** * - * Copyright 2017, 2019-2020, Optimizely and contributors + * Copyright 2017, 2019-2020, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -25,6 +25,7 @@ import com.optimizely.ab.config.audience.Audience; import com.optimizely.ab.config.audience.Condition; import com.optimizely.ab.config.audience.TypedAudience; +import com.optimizely.ab.testutils.OTUtils; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.BeforeClass; import org.junit.Rule; @@ -128,7 +129,7 @@ public void isExperimentActiveReturnsFalseWhenTheExperimentIsNotStarted() { @Test public void doesUserMeetAudienceConditionsReturnsTrueIfExperimentHasNoAudiences() { Experiment experiment = noAudienceProjectConfig.getExperiments().get(0); - assertTrue(doesUserMeetAudienceConditions(noAudienceProjectConfig, experiment, Collections.emptyMap(), RULE, "Everyone Else").getResult()); + assertTrue(doesUserMeetAudienceConditions(noAudienceProjectConfig, experiment, OTUtils.user(Collections.emptyMap()), RULE, "Everyone Else").getResult()); } /** @@ -138,7 +139,7 @@ public void doesUserMeetAudienceConditionsReturnsTrueIfExperimentHasNoAudiences( @Test public void doesUserMeetAudienceConditionsEvaluatesEvenIfExperimentHasAudiencesButUserHasNoAttributes() { Experiment experiment = projectConfig.getExperiments().get(0); - Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, Collections.emptyMap(), EXPERIMENT, experiment.getKey()).getResult(); + Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, OTUtils.user(Collections.emptyMap()), EXPERIMENT, experiment.getKey()).getResult(); assertTrue(result); logbackVerifier.expectMessage(Level.DEBUG, "Evaluating audiences for experiment \"etag1\": [100]."); @@ -154,11 +155,11 @@ public void doesUserMeetAudienceConditionsEvaluatesEvenIfExperimentHasAudiencesB * If the {@link Experiment} contains at least one {@link Audience}, but attributes is empty, * then {@link ExperimentUtils#doesUserMeetAudienceConditions(ProjectConfig, Experiment, Map, String, String)} should return false. */ - @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") + @SuppressFBWarnings("NP_NULL_PARAM_DEREF_NONVIRTUAL") @Test public void doesUserMeetAudienceConditionsEvaluatesEvenIfExperimentHasAudiencesButUserSendNullAttributes() throws Exception { Experiment experiment = projectConfig.getExperiments().get(0); - Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, null, EXPERIMENT, experiment.getKey()).getResult(); + Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, OTUtils.user(null), EXPERIMENT, experiment.getKey()).getResult(); assertTrue(result); logbackVerifier.expectMessage(Level.DEBUG, @@ -179,7 +180,7 @@ public void doesUserMeetAudienceConditionsEvaluatesEvenIfExperimentHasAudiencesB public void doesUserMeetAudienceConditionsEvaluatesExperimentHasTypedAudiences() { Experiment experiment = v4ProjectConfig.getExperiments().get(1); Map attribute = Collections.singletonMap("booleanKey", true); - Boolean result = doesUserMeetAudienceConditions(v4ProjectConfig, experiment, attribute, EXPERIMENT, experiment.getKey()).getResult(); + Boolean result = doesUserMeetAudienceConditions(v4ProjectConfig, experiment, OTUtils.user(attribute), EXPERIMENT, experiment.getKey()).getResult(); assertTrue(result); logbackVerifier.expectMessage(Level.DEBUG, @@ -200,7 +201,7 @@ public void doesUserMeetAudienceConditionsEvaluatesExperimentHasTypedAudiences() public void doesUserMeetAudienceConditionsReturnsTrueIfUserSatisfiesAnAudience() { Experiment experiment = projectConfig.getExperiments().get(0); Map attributes = Collections.singletonMap("browser_type", "chrome"); - Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, attributes, EXPERIMENT, experiment.getKey()).getResult(); + Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, OTUtils.user(attributes), EXPERIMENT, experiment.getKey()).getResult(); assertTrue(result); logbackVerifier.expectMessage(Level.DEBUG, @@ -221,7 +222,7 @@ public void doesUserMeetAudienceConditionsReturnsTrueIfUserSatisfiesAnAudience() public void doesUserMeetAudienceConditionsReturnsTrueIfUserDoesNotSatisfyAnyAudiences() { Experiment experiment = projectConfig.getExperiments().get(0); Map attributes = Collections.singletonMap("browser_type", "firefox"); - Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, attributes, EXPERIMENT, experiment.getKey()).getResult(); + Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, OTUtils.user(attributes), EXPERIMENT, experiment.getKey()).getResult(); assertFalse(result); logbackVerifier.expectMessage(Level.DEBUG, @@ -246,8 +247,8 @@ public void doesUserMeetAudienceConditionsHandlesNullValue() { AUDIENCE_WITH_MISSING_VALUE_VALUE); Map nonMatchingMap = Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, "American"); - assertTrue(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, satisfiesFirstCondition, EXPERIMENT, experiment.getKey()).getResult()); - assertFalse(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, nonMatchingMap, EXPERIMENT, experiment.getKey()).getResult()); + assertTrue(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, OTUtils.user(satisfiesFirstCondition), EXPERIMENT, experiment.getKey()).getResult()); + assertFalse(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, OTUtils.user(nonMatchingMap), EXPERIMENT, experiment.getKey()).getResult()); } /** @@ -258,7 +259,7 @@ public void doesUserMeetAudienceConditionsHandlesNullValueAttributesWithNull() { Experiment experiment = v4ProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_WITH_MALFORMED_AUDIENCE_KEY); Map attributesWithNull = Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, null); - assertFalse(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, attributesWithNull, EXPERIMENT, experiment.getKey()).getResult()); + assertFalse(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, OTUtils.user(attributesWithNull), EXPERIMENT, experiment.getKey()).getResult()); logbackVerifier.expectMessage(Level.DEBUG, "Starting to evaluate audience \"2196265320\" with conditions: [and, [or, [or, {name='nationality', type='custom_attribute', match='null', value='English'}, {name='nationality', type='custom_attribute', match='null', value=null}]]]."); @@ -279,7 +280,7 @@ public void doesUserMeetAudienceConditionsHandlesNullConditionValue() { Map attributesEmpty = Collections.emptyMap(); // It should explicitly be set to null otherwise we will return false on empty maps - assertFalse(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, attributesEmpty, EXPERIMENT, experiment.getKey()).getResult()); + assertFalse(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, OTUtils.user(attributesEmpty), EXPERIMENT, experiment.getKey()).getResult()); logbackVerifier.expectMessage(Level.DEBUG, "Starting to evaluate audience \"2196265320\" with conditions: [and, [or, [or, {name='nationality', type='custom_attribute', match='null', value='English'}, {name='nationality', type='custom_attribute', match='null', value=null}]]]."); diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java index 426422ea3..29cbe3695 100644 --- a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java @@ -334,7 +334,8 @@ private ProjectConfig generateOptimizelyConfig() { ) ), Collections.emptyList(), - Collections.emptyList() + Collections.emptyList(), + Collections.emptyList() ); } diff --git a/core-api/src/test/java/com/optimizely/ab/testutils/OTUtils.java b/core-api/src/test/java/com/optimizely/ab/testutils/OTUtils.java new file mode 100644 index 000000000..36c184369 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/testutils/OTUtils.java @@ -0,0 +1,36 @@ +/** + * + * Copyright 2022, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.testutils; + +import com.optimizely.ab.*; +import java.util.Collections; +import java.util.Map; + +public class OTUtils { + public static OptimizelyUserContext user(String userId, Map attributes) { + Optimizely optimizely = new Optimizely.Builder().build(); + return new OptimizelyUserContext(optimizely, userId, attributes); + } + + public static OptimizelyUserContext user(Map attributes) { + return user("any-user", attributes); + } + + public static OptimizelyUserContext user() { + return user("any-user", Collections.emptyMap()); + } +} \ No newline at end of file diff --git a/core-api/src/test/resources/config/valid-project-config-v4.json b/core-api/src/test/resources/config/valid-project-config-v4.json index 01c927a5c..cc0de0908 100644 --- a/core-api/src/test/resources/config/valid-project-config-v4.json +++ b/core-api/src/test/resources/config/valid-project-config-v4.json @@ -945,5 +945,12 @@ } ] } + ], + "integrations": [ + { + "key": "odp", + "host": "https://example.com", + "publicKey": "test-key" + } ] } From 8bd6ea5af43120ae1928d00caa44aca67ea80f68 Mon Sep 17 00:00:00 2001 From: Zeeshan Ashraf <35262377+zashraf1985@users.noreply.github.com> Date: Fri, 22 Jul 2022 09:26:05 -0700 Subject: [PATCH 082/147] chore: Check Jira ticket number in PR description (#478) ## Summary Added a check to verify PR description contains a Jira ticket number. ## Ticket: [OASIS-8321](https://optimizely.atlassian.net/browse/OASIS-8321) --- .github/workflows/ticket_reference_check.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/ticket_reference_check.yml diff --git a/.github/workflows/ticket_reference_check.yml b/.github/workflows/ticket_reference_check.yml new file mode 100644 index 000000000..d2829e0c4 --- /dev/null +++ b/.github/workflows/ticket_reference_check.yml @@ -0,0 +1,16 @@ +name: Jira ticket reference check + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +jobs: + + jira_ticket_reference_check: + runs-on: ubuntu-latest + + steps: + - name: Check for Jira ticket reference + uses: optimizely/github-action-ticket-reference-checker-public@master + with: + bodyRegex: 'OASIS-(?\d+)' From 618377d9989e8a8883d3fb2e83bf63ca28ffc4db Mon Sep 17 00:00:00 2001 From: Zeeshan Ashraf <35262377+zashraf1985@users.noreply.github.com> Date: Tue, 2 Aug 2022 14:45:31 -0700 Subject: [PATCH 083/147] feat: Add ODP GraphQL Api Interface (#481) ## Summary 1. Added an ODPApiManager interface and its Default implementation that implements the graphQL call to fetch qualified segments. 2. The mechanism to get default parser is tightly coupled with Project Config module. I created a separate Factory class to get the default parser. This can be used by any future implementations that need a default parser. 3. Added parsing for all four supported parsers to parse valid and error responses. ## Test plan 1. Added new unit tests to test all the new code. 2. Existing unit tests pass 3. Existing Full stack compatibility suite tests pass ## Issues [OASIS-8384](https://optimizely.atlassian.net/browse/OASIS-8384) --- .../ab/internal/JsonParserProvider.java | 74 ++++++++ .../com/optimizely/ab/odp/ODPApiManager.java | 22 +++ .../ab/odp/parser/ResponseJsonParser.java | 22 +++ .../odp/parser/ResponseJsonParserFactory.java | 49 +++++ .../ab/odp/parser/impl/GsonParser.java | 62 ++++++ .../ab/odp/parser/impl/JacksonParser.java | 66 +++++++ .../ab/odp/parser/impl/JsonParser.java | 63 ++++++ .../ab/odp/parser/impl/JsonSimpleParser.java | 66 +++++++ .../ab/internal/JsonParserProviderTest.java | 46 +++++ .../parser/ResponseJsonParserFactoryTest.java | 51 +++++ .../ab/odp/parser/ResponseJsonParserTest.java | 92 +++++++++ .../ab/odp/DefaultODPApiManager.java | 179 ++++++++++++++++++ .../ab/internal/LogbackVerifier.java | 168 ++++++++++++++++ .../ab/odp/DefaultODPApiManagerTest.java | 116 ++++++++++++ 14 files changed, 1076 insertions(+) create mode 100644 core-api/src/main/java/com/optimizely/ab/internal/JsonParserProvider.java create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParser.java create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactory.java create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/parser/impl/GsonParser.java create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JacksonParser.java create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonParser.java create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonSimpleParser.java create mode 100644 core-api/src/test/java/com/optimizely/ab/internal/JsonParserProviderTest.java create mode 100644 core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactoryTest.java create mode 100644 core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserTest.java create mode 100644 core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java create mode 100644 core-httpclient-impl/src/test/java/com/optimizely/ab/internal/LogbackVerifier.java create mode 100644 core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java diff --git a/core-api/src/main/java/com/optimizely/ab/internal/JsonParserProvider.java b/core-api/src/main/java/com/optimizely/ab/internal/JsonParserProvider.java new file mode 100644 index 000000000..6f6de6516 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/internal/JsonParserProvider.java @@ -0,0 +1,74 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import com.optimizely.ab.config.parser.MissingJsonParserException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public enum JsonParserProvider { + GSON_CONFIG_PARSER("com.google.gson.Gson"), + JACKSON_CONFIG_PARSER("com.fasterxml.jackson.databind.ObjectMapper" ), + JSON_CONFIG_PARSER("org.json.JSONObject"), + JSON_SIMPLE_CONFIG_PARSER("org.json.simple.JSONObject"); + + private static final Logger logger = LoggerFactory.getLogger(JsonParserProvider.class); + + private final String className; + + JsonParserProvider(String className) { + this.className = className; + } + + private boolean isAvailable() { + try { + Class.forName(className); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + + public static JsonParserProvider getDefaultParser() { + String defaultParserName = PropertyUtils.get("default_parser"); + + if (defaultParserName != null) { + try { + JsonParserProvider parser = JsonParserProvider.valueOf(defaultParserName); + if (parser.isAvailable()) { + logger.debug("using json parser: {}, based on override config", parser.className); + return parser; + } + + logger.warn("configured parser {} is not available in the classpath", defaultParserName); + } catch (IllegalArgumentException e) { + logger.warn("configured parser {} is not a valid value", defaultParserName); + } + } + + for (JsonParserProvider parser: JsonParserProvider.values()) { + if (!parser.isAvailable()) { + continue; + } + + logger.info("using json parser: {}", parser.className); + return parser; + } + + throw new MissingJsonParserException("unable to locate a JSON parser. " + + "Please see for more information"); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java new file mode 100644 index 000000000..ed40e37fd --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java @@ -0,0 +1,22 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import java.util.List; + +public interface ODPApiManager { + String fetchQualifiedSegments(String apiKey, String apiEndpoint, String userKey, String userValue, List segmentsToCheck); +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParser.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParser.java new file mode 100644 index 000000000..d494a78d0 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParser.java @@ -0,0 +1,22 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser; + +import java.util.List; + +public interface ResponseJsonParser { + public List parseQualifiedSegments(String responseJson); +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactory.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactory.java new file mode 100644 index 000000000..7762cef0e --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactory.java @@ -0,0 +1,49 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser; + +import com.optimizely.ab.internal.JsonParserProvider; +import com.optimizely.ab.odp.parser.impl.GsonParser; +import com.optimizely.ab.odp.parser.impl.JacksonParser; +import com.optimizely.ab.odp.parser.impl.JsonParser; +import com.optimizely.ab.odp.parser.impl.JsonSimpleParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ResponseJsonParserFactory { + private static final Logger logger = LoggerFactory.getLogger(ResponseJsonParserFactory.class); + + public static ResponseJsonParser getParser() { + JsonParserProvider parserProvider = JsonParserProvider.getDefaultParser(); + ResponseJsonParser jsonParser = null; + switch (parserProvider) { + case GSON_CONFIG_PARSER: + jsonParser = new GsonParser(); + break; + case JACKSON_CONFIG_PARSER: + jsonParser = new JacksonParser(); + break; + case JSON_CONFIG_PARSER: + jsonParser = new JsonParser(); + break; + case JSON_SIMPLE_CONFIG_PARSER: + jsonParser = new JsonSimpleParser(); + break; + } + logger.info("Using " + parserProvider.toString() + " parser"); + return jsonParser; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/GsonParser.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/GsonParser.java new file mode 100644 index 000000000..b27d65078 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/GsonParser.java @@ -0,0 +1,62 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser.impl; + +import com.google.gson.*; +import com.google.gson.JsonParser; +import com.optimizely.ab.odp.parser.ResponseJsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +public class GsonParser implements ResponseJsonParser { + private static final Logger logger = LoggerFactory.getLogger(GsonParser.class); + + @Override + public List parseQualifiedSegments(String responseJson) { + List parsedSegments = new ArrayList<>(); + try { + JsonObject root = JsonParser.parseString(responseJson).getAsJsonObject(); + + if (root.has("errors")) { + JsonArray errors = root.getAsJsonArray("errors"); + StringBuilder logMessage = new StringBuilder(); + for (int i = 0; i < errors.size(); i++) { + if (i > 0) { + logMessage.append(", "); + } + logMessage.append(errors.get(i).getAsJsonObject().get("message").getAsString()); + } + logger.error(logMessage.toString()); + return null; + } + + JsonArray edges = root.getAsJsonObject("data").getAsJsonObject("customer").getAsJsonObject("audiences").getAsJsonArray("edges"); + for (int i = 0; i < edges.size(); i++) { + JsonObject node = edges.get(i).getAsJsonObject().getAsJsonObject("node"); + if (node.has("state") && node.get("state").getAsString().equals("qualified")) { + parsedSegments.add(node.get("name").getAsString()); + } + } + return parsedSegments; + } catch (JsonSyntaxException e) { + logger.error("Error parsing qualified segments from response", e); + return null; + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JacksonParser.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JacksonParser.java new file mode 100644 index 000000000..f1a38eca7 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JacksonParser.java @@ -0,0 +1,66 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser.impl; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.optimizely.ab.odp.parser.ResponseJsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; + +import java.util.List; + +public class JacksonParser implements ResponseJsonParser { + private static final Logger logger = LoggerFactory.getLogger(JacksonParser.class); + + @Override + public List parseQualifiedSegments(String responseJson) { + ObjectMapper objectMapper = new ObjectMapper(); + List parsedSegments = new ArrayList<>(); + JsonNode root; + try { + root = objectMapper.readTree(responseJson); + + if (root.has("errors")) { + JsonNode errors = root.path("errors"); + StringBuilder logMessage = new StringBuilder(); + for (int i = 0; i < errors.size(); i++) { + if (i > 0) { + logMessage.append(", "); + } + logMessage.append(errors.get(i).path("message")); + } + logger.error(logMessage.toString()); + return null; + } + + JsonNode edges = root.path("data").path("customer").path("audiences").path("edges"); + for (JsonNode edgeNode : edges) { + String state = edgeNode.path("node").path("state").asText(); + if (state.equals("qualified")) { + parsedSegments.add(edgeNode.path("node").path("name").asText()); + } + } + return parsedSegments; + } catch (JsonProcessingException e) { + logger.error("Error parsing qualified segments from response", e); + return null; + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonParser.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonParser.java new file mode 100644 index 000000000..fcae748a4 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonParser.java @@ -0,0 +1,63 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser.impl; + +import com.optimizely.ab.odp.parser.ResponseJsonParser; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +public class JsonParser implements ResponseJsonParser { + private static final Logger logger = LoggerFactory.getLogger(JsonParser.class); + + @Override + public List parseQualifiedSegments(String responseJson) { + List parsedSegments = new ArrayList<>(); + try { + JSONObject root = new JSONObject(responseJson); + + if (root.has("errors")) { + JSONArray errors = root.getJSONArray("errors"); + StringBuilder logMessage = new StringBuilder(); + for (int i = 0; i < errors.length(); i++) { + if (i > 0) { + logMessage.append(", "); + } + logMessage.append(errors.getJSONObject(i).getString("message")); + } + logger.error(logMessage.toString()); + return null; + } + + JSONArray edges = root.getJSONObject("data").getJSONObject("customer").getJSONObject("audiences").getJSONArray("edges"); + for (int i = 0; i < edges.length(); i++) { + JSONObject node = edges.getJSONObject(i).getJSONObject("node"); + if (node.has("state") && node.getString("state").equals("qualified")) { + parsedSegments.add(node.getString("name")); + } + } + return parsedSegments; + } catch (JSONException e) { + logger.error("Error parsing qualified segments from response", e); + return null; + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonSimpleParser.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonSimpleParser.java new file mode 100644 index 000000000..1bee81b0a --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonSimpleParser.java @@ -0,0 +1,66 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser.impl; + +import com.optimizely.ab.odp.parser.ResponseJsonParser; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +public class JsonSimpleParser implements ResponseJsonParser { + private static final Logger logger = LoggerFactory.getLogger(JsonSimpleParser.class); + + @Override + public List parseQualifiedSegments(String responseJson) { + List parsedSegments = new ArrayList<>(); + JSONParser parser = new JSONParser(); + JSONObject root = null; + try { + root = (JSONObject) parser.parse(responseJson); + + if (root.containsKey("errors")) { + JSONArray errors = (JSONArray) root.get("errors"); + StringBuilder logMessage = new StringBuilder(); + for (int i = 0; i < errors.size(); i++) { + if (i > 0) { + logMessage.append(", "); + } + logMessage.append((String)((JSONObject) errors.get(i)).get("message")); + } + logger.error(logMessage.toString()); + return null; + } + + JSONArray edges = (JSONArray)((JSONObject)((JSONObject)(((JSONObject) root.get("data"))).get("customer")).get("audiences")).get("edges"); + for (int i = 0; i < edges.size(); i++) { + JSONObject node = (JSONObject) ((JSONObject) edges.get(i)).get("node"); + if (node.containsKey("state") && (node.get("state")).equals("qualified")) { + parsedSegments.add((String) node.get("name")); + } + } + return parsedSegments; + } catch (ParseException | NullPointerException e) { + logger.error("Error parsing qualified segments from response", e); + return null; + } + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/internal/JsonParserProviderTest.java b/core-api/src/test/java/com/optimizely/ab/internal/JsonParserProviderTest.java new file mode 100644 index 000000000..a65e9b6f5 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/internal/JsonParserProviderTest.java @@ -0,0 +1,46 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.*; + +public class JsonParserProviderTest { + @Before + @After + public void clearParserSystemProperty() { + PropertyUtils.clear("default_parser"); + } + + @Test + public void getGsonParserProviderWhenNoDefaultIsSet() { + assertEquals(JsonParserProvider.GSON_CONFIG_PARSER, JsonParserProvider.getDefaultParser()); + } + + @Test + public void getCorrectParserProviderWhenValidDefaultIsProvided() { + PropertyUtils.set("default_parser", "JSON_CONFIG_PARSER"); + assertEquals(JsonParserProvider.JSON_CONFIG_PARSER, JsonParserProvider.getDefaultParser()); + } + + @Test + public void getGsonParserWhenProvidedDefaultParserDoesNotExist() { + PropertyUtils.set("default_parser", "GARBAGE_VALUE"); + assertEquals(JsonParserProvider.GSON_CONFIG_PARSER, JsonParserProvider.getDefaultParser()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactoryTest.java new file mode 100644 index 000000000..f5d8e8c89 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactoryTest.java @@ -0,0 +1,51 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser; + +import com.optimizely.ab.internal.JsonParserProvider; +import com.optimizely.ab.internal.PropertyUtils; +import com.optimizely.ab.odp.parser.impl.GsonParser; +import com.optimizely.ab.odp.parser.impl.JsonParser; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class ResponseJsonParserFactoryTest { + @Before + @After + public void clearParserSystemProperty() { + PropertyUtils.clear("default_parser"); + } + + @Test + public void getGsonParserWhenNoDefaultIsSet() { + assertEquals(GsonParser.class, ResponseJsonParserFactory.getParser().getClass()); + } + + @Test + public void getCorrectParserWhenValidDefaultIsProvided() { + PropertyUtils.set("default_parser", "JSON_CONFIG_PARSER"); + assertEquals(JsonParser.class, ResponseJsonParserFactory.getParser().getClass()); + } + + @Test + public void getGsonParserWhenGivenDefaultParserDoesNotExist() { + PropertyUtils.set("default_parser", "GARBAGE_VALUE"); + assertEquals(GsonParser.class, ResponseJsonParserFactory.getParser().getClass()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserTest.java b/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserTest.java new file mode 100644 index 000000000..1acd5cf15 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserTest.java @@ -0,0 +1,92 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser; + +import ch.qos.logback.classic.Level; +import com.optimizely.ab.internal.LogbackVerifier; +import static junit.framework.TestCase.assertEquals; + +import com.optimizely.ab.odp.parser.impl.*; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.util.Arrays; +import java.util.List; + +@RunWith(Parameterized.class) +public class ResponseJsonParserTest { + private final ResponseJsonParser jsonParser; + + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + + public ResponseJsonParserTest(ResponseJsonParser jsonParser) { + super(); + this.jsonParser = jsonParser; + } + + @Parameterized.Parameters + public static List input() { + return Arrays.asList(new GsonParser(), new JsonParser(), new JsonSimpleParser(), new JacksonParser()); + } + + @Test + public void returnSegmentsListWhenResponseIsCorrect() { + String responseToParse = "{\"data\":{\"customer\":{\"audiences\":{\"edges\":[{\"node\":{\"name\":\"has_email\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"has_email_opted_in\",\"state\":\"qualified\"}}]}}}}"; + List parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + assertEquals(Arrays.asList("has_email", "has_email_opted_in"), parsedSegments); + } + + @Test + public void excludeSegmentsWhenStateNotQualified() { + String responseToParse = "{\"data\":{\"customer\":{\"audiences\":{\"edges\":[{\"node\":{\"name\":\"has_email\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"has_email_opted_in\",\"state\":\"not_qualified\"}}]}}}}"; + List parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + assertEquals(Arrays.asList("has_email"), parsedSegments); + } + + @Test + public void returnEmptyListWhenResponseHasEmptyArray() { + String responseToParse = "{\"data\":{\"customer\":{\"audiences\":{\"edges\":[]}}}}"; + List parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + assertEquals(Arrays.asList(), parsedSegments); + } + + @Test + public void returnNullWhenJsonFormatIsValidButUnexpectedData() { + String responseToParse = "{\"data\"\"consumer\":{\"randomKey\":{\"edges\":[{\"node\":{\"name\":\"has_email\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"has_email_opted_in\",\"state\":\"qualified\"}}]}}}}"; + List parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + logbackVerifier.expectMessage(Level.ERROR, "Error parsing qualified segments from response"); + assertEquals(null, parsedSegments); + } + + @Test + public void returnNullWhenJsonIsMalformed() { + String responseToParse = "{\"data\"\"customer\":{\"audiences\":{\"edges\":[{\"node\":{\"name\":\"has_email\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"has_email_opted_in\",\"state\":\"qualified\"}}]}}}}"; + List parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + logbackVerifier.expectMessage(Level.ERROR, "Error parsing qualified segments from response"); + assertEquals(null, parsedSegments); + } + + @Test + public void returnNullAndLogCorrectErrorWhenErrorResponseIsReturned() { + String responseToParse = "{\"errors\":[{\"message\":\"Exception while fetching data (/customer) : java.lang.RuntimeException: could not resolve _fs_user_id = wrong_id\",\"locations\":[{\"line\":2,\"column\":3}],\"path\":[\"customer\"],\"extensions\":{\"classification\":\"InvalidIdentifierException\"}}],\"data\":{\"customer\":null}}"; + List parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + logbackVerifier.expectMessage(Level.ERROR, "Exception while fetching data (/customer) : java.lang.RuntimeException: could not resolve _fs_user_id = wrong_id"); + assertEquals(null, parsedSegments); + } +} diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java new file mode 100644 index 000000000..f0703dcb0 --- /dev/null +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java @@ -0,0 +1,179 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import com.optimizely.ab.OptimizelyHttpClient; +import com.optimizely.ab.annotations.VisibleForTesting; +import org.apache.http.HttpStatus; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; +import org.apache.http.util.EntityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.util.List; + +public class DefaultODPApiManager implements ODPApiManager { + private static final Logger logger = LoggerFactory.getLogger(DefaultODPApiManager.class); + + private final OptimizelyHttpClient httpClient; + + public DefaultODPApiManager() { + this(OptimizelyHttpClient.builder().build()); + } + + @VisibleForTesting + DefaultODPApiManager(OptimizelyHttpClient httpClient) { + this.httpClient = httpClient; + } + + @VisibleForTesting + String getSegmentsStringForRequest(List segmentsList) { + StringBuilder segmentsString = new StringBuilder(); + for (int i = 0; i < segmentsList.size(); i++) { + if (i > 0) { + segmentsString.append(", "); + } + segmentsString.append("\\\"").append(segmentsList.get(i)).append("\\\""); + } + return segmentsString.toString(); + } + + // ODP GraphQL API + // - https://api.zaius.com/v3/graphql + // - test ODP public API key = "W4WzcEs-ABgXorzY7h1LCQ" + /* + + [GraphQL Request] + + // fetch info with fs_user_id for ["has_email", "has_email_opted_in", "push_on_sale"] segments + curl -i -H 'Content-Type: application/json' -H 'x-api-key: W4WzcEs-ABgXorzY7h1LCQ' -X POST -d '{"query":"query {customer(fs_user_id: \"tester-101\") {audiences(subset:[\"has_email\",\"has_email_opted_in\",\"push_on_sale\"]) {edges {node {name state}}}}}"}' https://api.zaius.com/v3/graphql + // fetch info with vuid for ["has_email", "has_email_opted_in", "push_on_sale"] segments + curl -i -H 'Content-Type: application/json' -H 'x-api-key: W4WzcEs-ABgXorzY7h1LCQ' -X POST -d '{"query":"query {customer(vuid: \"d66a9d81923d4d2f99d8f64338976322\") {audiences(subset:[\"has_email\",\"has_email_opted_in\",\"push_on_sale\"]) {edges {node {name state}}}}}"}' https://api.zaius.com/v3/graphql + query MyQuery { + customer(vuid: "d66a9d81923d4d2f99d8f64338976322") { + audiences(subset:["has_email","has_email_opted_in","push_on_sale"]) { + edges { + node { + name + state + } + } + } + } + } + [GraphQL Response] + + { + "data": { + "customer": { + "audiences": { + "edges": [ + { + "node": { + "name": "has_email", + "state": "qualified", + } + }, + { + "node": { + "name": "has_email_opted_in", + "state": "qualified", + } + }, + ... + ] + } + } + } + } + + [GraphQL Error Response] + { + "errors": [ + { + "message": "Exception while fetching data (/customer) : java.lang.RuntimeException: could not resolve _fs_user_id = asdsdaddddd", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "customer" + ], + "extensions": { + "classification": "InvalidIdentifierException" + } + } + ], + "data": { + "customer": null + } + } + */ + @Override + public String fetchQualifiedSegments(String apiKey, String apiEndpoint, String userKey, String userValue, List segmentsToCheck) { + HttpPost request = new HttpPost(apiEndpoint); + String segmentsString = getSegmentsStringForRequest(segmentsToCheck); + String requestPayload = String.format("{\"query\": \"query {customer(%s: \\\"%s\\\") {audiences(subset: [%s]) {edges {node {name state}}}}}\"}", userKey, userValue, segmentsString); + try { + request.setEntity(new StringEntity(requestPayload)); + } catch (UnsupportedEncodingException e) { + logger.warn("Error encoding request payload", e); + } + request.setHeader("x-api-key", apiKey); + request.setHeader("content-type", "application/json"); + + CloseableHttpResponse response = null; + try { + response = httpClient.execute(request); + } catch (IOException e) { + logger.error("Error retrieving response from ODP service", e); + return null; + } + + if (response.getStatusLine().getStatusCode() >= 400) { + StatusLine statusLine = response.getStatusLine(); + logger.error(String.format("Unexpected response from ODP server, Response code: %d, %s", statusLine.getStatusCode(), statusLine.getReasonPhrase())); + closeHttpResponse(response); + return null; + } + + try { + return EntityUtils.toString(response.getEntity()); + } catch (IOException e) { + logger.error("Error converting ODP segments response to string", e); + } finally { + closeHttpResponse(response); + } + return null; + } + + private static void closeHttpResponse(CloseableHttpResponse response) { + if (response != null) { + try { + response.close(); + } catch (IOException e) { + logger.warn(e.getLocalizedMessage()); + } + } + } +} diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/internal/LogbackVerifier.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/internal/LogbackVerifier.java new file mode 100644 index 000000000..25154e97d --- /dev/null +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/internal/LogbackVerifier.java @@ -0,0 +1,168 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.core.AppenderBase; +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.slf4j.LoggerFactory; + +import java.util.LinkedList; +import java.util.List; +import java.util.ListIterator; + +import static org.junit.Assert.fail; + +/** + * TODO As a usability improvement we should require expected messages be added after the message are expected to be + * logged. This will allow us to map the failure immediately back to the test line number as opposed to the async + * validation now that happens at the end of each individual test. + * + * From http://techblog.kenshoo.com/2013/08/junit-rule-for-verifying-logback-logging.html + */ +public class LogbackVerifier implements TestRule { + + private List expectedEvents = new LinkedList(); + + private CaptureAppender appender; + + @Override + public Statement apply(final Statement base, Description description) { + return new Statement() { + @Override + public void evaluate() throws Throwable { + before(); + try { + base.evaluate(); + verify(); + } finally { + after(); + } + } + }; + } + + public void expectMessage(Level level) { + expectMessage(level, ""); + } + + public void expectMessage(Level level, String msg) { + expectMessage(level, msg, (Class) null); + } + + public void expectMessage(Level level, String msg, Class throwableClass) { + expectMessage(level, msg, null, 1); + } + + public void expectMessage(Level level, String msg, int times) { + expectMessage(level, msg, null, times); + } + + public void expectMessage(Level level, + String msg, + Class throwableClass, + int times) { + for (int i = 0; i < times; i++) { + expectedEvents.add(new ExpectedLogEvent(level, msg, throwableClass)); + } + } + + private void before() { + appender = new CaptureAppender(); + appender.setName("MOCK"); + appender.start(); + ((Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME)).addAppender(appender); + } + + private void verify() throws Throwable { + ListIterator actualIterator = appender.getEvents().listIterator(); + + for (final ExpectedLogEvent expectedEvent : expectedEvents) { + boolean found = false; + while (actualIterator.hasNext()) { + ILoggingEvent actual = actualIterator.next(); + + if (expectedEvent.matches(actual)) { + found = true; + break; + } + } + + if (!found) { + fail(expectedEvent.toString()); + } + } + } + + private void after() { + ((Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME)).detachAppender(appender); + } + + private static class CaptureAppender extends AppenderBase { + + List actualLoggingEvent = new LinkedList<>(); + + @Override + protected void append(ILoggingEvent eventObject) { + actualLoggingEvent.add(eventObject); + } + + public List getEvents() { + return actualLoggingEvent; + } + } + + private final static class ExpectedLogEvent { + private final String message; + private final Level level; + private final Class throwableClass; + + private ExpectedLogEvent(Level level, + String message, + Class throwableClass) { + this.message = message; + this.level = level; + this.throwableClass = throwableClass; + } + + private boolean matches(ILoggingEvent actual) { + boolean match = actual.getFormattedMessage().contains(message); + match &= actual.getLevel().equals(level); + match &= matchThrowables(actual); + return match; + } + + private boolean matchThrowables(ILoggingEvent actual) { + IThrowableProxy eventProxy = actual.getThrowableProxy(); + return throwableClass == null || eventProxy != null && throwableClass.getName().equals(eventProxy.getClassName()); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("ExpectedLogEvent{"); + sb.append("level=").append(level); + sb.append(", message='").append(message).append('\''); + sb.append(", throwableClass=").append(throwableClass); + sb.append('}'); + return sb.toString(); + } + } +} diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java new file mode 100644 index 000000000..c81af2dce --- /dev/null +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java @@ -0,0 +1,116 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import ch.qos.logback.classic.Level; +import com.optimizely.ab.OptimizelyHttpClient; +import com.optimizely.ab.internal.LogbackVerifier; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; +import org.apache.http.util.EntityUtils; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import java.util.ArrayList; +import java.util.Arrays; + +import static org.junit.Assert.*; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.*; + +public class DefaultODPApiManagerTest { + private static final String validResponse = "{\"data\":{\"customer\":{\"audiences\":{\"edges\":[{\"node\":{\"name\":\"has_email\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"has_email_opted_in\",\"state\":\"qualified\"}}]}}}}"; + + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + + OptimizelyHttpClient mockHttpClient; + + @Before + public void setUp() throws Exception { + setupHttpClient(200); + } + + private void setupHttpClient(int statusCode) throws Exception { + mockHttpClient = mock(OptimizelyHttpClient.class); + CloseableHttpResponse httpResponse = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + + when(statusLine.getStatusCode()).thenReturn(statusCode); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(httpResponse.getEntity()).thenReturn(new StringEntity(validResponse)); + + when(mockHttpClient.execute(any(HttpPost.class))) + .thenReturn(httpResponse); + } + + @Test + public void generateCorrectSegmentsStringWhenListHasOneItem() { + DefaultODPApiManager apiManager = new DefaultODPApiManager(); + String expected = "\\\"only_segment\\\""; + String actual = apiManager.getSegmentsStringForRequest(Arrays.asList("only_segment")); + assertEquals(expected, actual); + } + + @Test + public void generateCorrectSegmentsStringWhenListHasMultipleItems() { + DefaultODPApiManager apiManager = new DefaultODPApiManager(); + String expected = "\\\"segment_1\\\", \\\"segment_2\\\", \\\"segment_3\\\""; + String actual = apiManager.getSegmentsStringForRequest(Arrays.asList("segment_1", "segment_2", "segment_3")); + assertEquals(expected, actual); + } + + @Test + public void generateEmptyStringWhenGivenListIsEmpty() { + DefaultODPApiManager apiManager = new DefaultODPApiManager(); + String actual = apiManager.getSegmentsStringForRequest(new ArrayList<>()); + assertEquals("", actual); + } + + @Test + public void generateCorrectRequestBody() throws Exception { + ODPApiManager apiManager = new DefaultODPApiManager(mockHttpClient); + apiManager.fetchQualifiedSegments("key", "endPoint", "fs_user_id", "test_user", Arrays.asList("segment_1", "segment_2")); + verify(mockHttpClient, times(1)).execute(any(HttpPost.class)); + + String expectedResponse = "{\"query\": \"query {customer(fs_user_id: \\\"test_user\\\") {audiences(subset: [\\\"segment_1\\\", \\\"segment_2\\\"]) {edges {node {name state}}}}}\"}"; + ArgumentCaptor request = ArgumentCaptor.forClass(HttpPost.class); + verify(mockHttpClient).execute(request.capture()); + assertEquals(expectedResponse, EntityUtils.toString(request.getValue().getEntity())); + } + + @Test + public void returnResponseStringWhenStatusIs200() throws Exception { + ODPApiManager apiManager = new DefaultODPApiManager(mockHttpClient); + String responseString = apiManager.fetchQualifiedSegments("key", "endPoint", "fs_user_id", "test_user", Arrays.asList("segment_1", "segment_2")); + verify(mockHttpClient, times(1)).execute(any(HttpPost.class)); + assertEquals(validResponse, responseString); + } + + @Test + public void returnNullWhenStatusIsNot200AndLogError() throws Exception { + setupHttpClient(500); + ODPApiManager apiManager = new DefaultODPApiManager(mockHttpClient); + String responseString = apiManager.fetchQualifiedSegments("key", "endPoint", "fs_user_id", "test_user", Arrays.asList("segment_1", "segment_2")); + verify(mockHttpClient, times(1)).execute(any(HttpPost.class)); + logbackVerifier.expectMessage(Level.ERROR, "Unexpected response from ODP server, Response code: 500, null"); + assertEquals(null, responseString); + } +} From fb06ca737d63de0ca960252fe9ce9b43d754e5b5 Mon Sep 17 00:00:00 2001 From: Zeeshan Ashraf <35262377+zashraf1985@users.noreply.github.com> Date: Fri, 5 Aug 2022 12:03:23 -0700 Subject: [PATCH 084/147] feat: Added a generic LRUCache interface and a default implementation (#482) ## Summary Added an interface for a generic LRUCache and a default Implementation. ## Test plan - All existing tests pass. - Added unit tests for the default cache implementation. ## Issues [OASIS-8385](https://optimizely.atlassian.net/browse/OASIS-8385) --- .../com/optimizely/ab/internal/Cache.java | 25 +++ .../ab/internal/DefaultLRUCache.java | 96 ++++++++++ .../ab/internal/DefaultLRUCacheTest.java | 172 ++++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 core-api/src/main/java/com/optimizely/ab/internal/Cache.java create mode 100644 core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java create mode 100644 core-api/src/test/java/com/optimizely/ab/internal/DefaultLRUCacheTest.java diff --git a/core-api/src/main/java/com/optimizely/ab/internal/Cache.java b/core-api/src/main/java/com/optimizely/ab/internal/Cache.java new file mode 100644 index 000000000..ba667ebd2 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/internal/Cache.java @@ -0,0 +1,25 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +public interface Cache { + int DEFAULT_MAX_SIZE = 10000; + int DEFAULT_TIMEOUT_SECONDS = 600; + void save(String key, T value); + T lookup(String key); + void reset(); +} diff --git a/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java b/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java new file mode 100644 index 000000000..a531c5c83 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java @@ -0,0 +1,96 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import com.optimizely.ab.annotations.VisibleForTesting; + +import java.util.*; + +public class DefaultLRUCache implements Cache { + + private final Object lock = new Object(); + + private final Integer maxSize; + + private final Long timeoutMillis; + + @VisibleForTesting + final LinkedHashMap linkedHashMap = new LinkedHashMap(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return this.size() > maxSize; + } + }; + + public DefaultLRUCache() { + this(DEFAULT_MAX_SIZE, DEFAULT_TIMEOUT_SECONDS); + } + + public DefaultLRUCache(Integer maxSize, Integer timeoutSeconds) { + this.maxSize = maxSize < 0 ? Integer.valueOf(0) : maxSize; + this.timeoutMillis = (timeoutSeconds < 0) ? 0 : (timeoutSeconds * 1000L); + } + + public void save(String key, T value) { + if (maxSize == 0) { + // Cache is disabled when maxSize = 0 + return; + } + + synchronized (lock) { + linkedHashMap.put(key, new CacheEntity(value)); + } + } + + public T lookup(String key) { + if (maxSize == 0) { + // Cache is disabled when maxSize = 0 + return null; + } + + synchronized (lock) { + if (linkedHashMap.containsKey(key)) { + CacheEntity entity = linkedHashMap.get(key); + Long nowMs = new Date().getTime(); + + // ttl = 0 means entities never expire. + if (timeoutMillis == 0 || (nowMs - entity.timestamp < timeoutMillis)) { + return entity.value; + } + + linkedHashMap.remove(key); + } + return null; + } + } + + public void reset() { + synchronized (lock) { + linkedHashMap.clear(); + } + } + + private class CacheEntity { + public T value; + public Long timestamp; + + public CacheEntity(T value) { + this.value = value; + this.timestamp = new Date().getTime(); + } + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/internal/DefaultLRUCacheTest.java b/core-api/src/test/java/com/optimizely/ab/internal/DefaultLRUCacheTest.java new file mode 100644 index 000000000..79aa96ff3 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/internal/DefaultLRUCacheTest.java @@ -0,0 +1,172 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.*; + +public class DefaultLRUCacheTest { + + @Test + public void createSaveAndLookupOneItem() { + Cache cache = new DefaultLRUCache<>(); + assertNull(cache.lookup("key1")); + cache.save("key1", "value1"); + assertEquals("value1", cache.lookup("key1")); + } + + @Test + public void saveAndLookupMultipleItems() { + DefaultLRUCache> cache = new DefaultLRUCache<>(); + + cache.save("user1", Arrays.asList("segment1", "segment2")); + cache.save("user2", Arrays.asList("segment3", "segment4")); + cache.save("user3", Arrays.asList("segment5", "segment6")); + + String[] itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + assertEquals("user1", itemKeys[0]); + assertEquals("user2", itemKeys[1]); + assertEquals("user3", itemKeys[2]); + + assertEquals(Arrays.asList("segment1", "segment2"), cache.lookup("user1")); + + itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + // Lookup should move user1 to bottom of the list and push up others. + assertEquals("user2", itemKeys[0]); + assertEquals("user3", itemKeys[1]); + assertEquals("user1", itemKeys[2]); + + assertEquals(Arrays.asList("segment3", "segment4"), cache.lookup("user2")); + + itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + // Lookup should move user2 to bottom of the list and push up others. + assertEquals("user3", itemKeys[0]); + assertEquals("user1", itemKeys[1]); + assertEquals("user2", itemKeys[2]); + + assertEquals(Arrays.asList("segment5", "segment6"), cache.lookup("user3")); + + itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + // Lookup should move user3 to bottom of the list and push up others. + assertEquals("user1", itemKeys[0]); + assertEquals("user2", itemKeys[1]); + assertEquals("user3", itemKeys[2]); + } + + @Test + public void saveShouldReorderList() { + DefaultLRUCache> cache = new DefaultLRUCache<>(); + + cache.save("user1", Arrays.asList("segment1", "segment2")); + cache.save("user2", Arrays.asList("segment3", "segment4")); + cache.save("user3", Arrays.asList("segment5", "segment6")); + + String[] itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + assertEquals("user1", itemKeys[0]); + assertEquals("user2", itemKeys[1]); + assertEquals("user3", itemKeys[2]); + + cache.save("user1", Arrays.asList("segment1", "segment2")); + + itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + // save should move user1 to bottom of the list and push up others. + assertEquals("user2", itemKeys[0]); + assertEquals("user3", itemKeys[1]); + assertEquals("user1", itemKeys[2]); + + cache.save("user2", Arrays.asList("segment3", "segment4")); + + itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + // save should move user2 to bottom of the list and push up others. + assertEquals("user3", itemKeys[0]); + assertEquals("user1", itemKeys[1]); + assertEquals("user2", itemKeys[2]); + + cache.save("user3", Arrays.asList("segment5", "segment6")); + + itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + // save should move user3 to bottom of the list and push up others. + assertEquals("user1", itemKeys[0]); + assertEquals("user2", itemKeys[1]); + assertEquals("user3", itemKeys[2]); + } + + @Test + public void whenCacheIsDisabled() { + DefaultLRUCache> cache = new DefaultLRUCache<>(0,Cache.DEFAULT_TIMEOUT_SECONDS); + + cache.save("user1", Arrays.asList("segment1", "segment2")); + cache.save("user2", Arrays.asList("segment3", "segment4")); + cache.save("user3", Arrays.asList("segment5", "segment6")); + + assertNull(cache.lookup("user1")); + assertNull(cache.lookup("user2")); + assertNull(cache.lookup("user3")); + } + + @Test + public void whenItemsExpire() throws InterruptedException { + DefaultLRUCache> cache = new DefaultLRUCache<>(Cache.DEFAULT_MAX_SIZE, 1); + cache.save("user1", Arrays.asList("segment1", "segment2")); + assertEquals(Arrays.asList("segment1", "segment2"), cache.lookup("user1")); + assertEquals(1, cache.linkedHashMap.size()); + Thread.sleep(1000); + assertNull(cache.lookup("user1")); + assertEquals(0, cache.linkedHashMap.size()); + } + + @Test + public void whenCacheReachesMaxSize() { + DefaultLRUCache> cache = new DefaultLRUCache<>(2, Cache.DEFAULT_TIMEOUT_SECONDS); + + cache.save("user1", Arrays.asList("segment1", "segment2")); + cache.save("user2", Arrays.asList("segment3", "segment4")); + cache.save("user3", Arrays.asList("segment5", "segment6")); + + assertEquals(2, cache.linkedHashMap.size()); + + assertEquals(Arrays.asList("segment5", "segment6"), cache.lookup("user3")); + assertEquals(Arrays.asList("segment3", "segment4"), cache.lookup("user2")); + assertNull(cache.lookup("user1")); + } + + @Test + public void whenCacheIsReset() { + DefaultLRUCache> cache = new DefaultLRUCache<>(); + cache.save("user1", Arrays.asList("segment1", "segment2")); + cache.save("user2", Arrays.asList("segment3", "segment4")); + cache.save("user3", Arrays.asList("segment5", "segment6")); + + assertEquals(Arrays.asList("segment1", "segment2"), cache.lookup("user1")); + assertEquals(Arrays.asList("segment3", "segment4"), cache.lookup("user2")); + assertEquals(Arrays.asList("segment5", "segment6"), cache.lookup("user3")); + + assertEquals(3, cache.linkedHashMap.size()); + + cache.reset(); + + assertNull(cache.lookup("user1")); + assertNull(cache.lookup("user2")); + assertNull(cache.lookup("user3")); + + assertEquals(0, cache.linkedHashMap.size()); + } +} From 5866f7b1622b6b90f31c5f0fe8cab481d3cd7374 Mon Sep 17 00:00:00 2001 From: Zeeshan Ashraf <35262377+zashraf1985@users.noreply.github.com> Date: Wed, 10 Aug 2022 11:00:28 -0700 Subject: [PATCH 085/147] feat: Added support to get All segments used in the project (#483) ## Summary ODP Server needs a subset of segments to look for in the API call. This PR adds functionality to get all segments used in the project inside `DatafileProjectConfig`. ## Test plan - Manually tested thoroughly - Added Unit tests ## Jira [OASIS-8383](https://optimizely.atlassian.net/browse/OASIS-8383) --- .../ab/config/DatafileProjectConfig.java | 14 ++++++++ .../ab/config/audience/AndCondition.java | 1 + .../ab/config/audience/Audience.java | 28 ++++++++++++++- .../config/audience/AudienceIdCondition.java | 6 ++++ .../ab/config/audience/Condition.java | 3 ++ .../ab/config/audience/EmptyCondition.java | 2 +- .../ab/config/audience/LeafCondition.java | 26 ++++++++++++++ .../ab/config/audience/NotCondition.java | 7 ++++ .../ab/config/audience/NullCondition.java | 2 +- .../ab/config/audience/OrCondition.java | 1 + .../ab/config/audience/UserAttribute.java | 2 +- .../AudienceConditionEvaluationTest.java | 36 +++++++++++++++++++ 12 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/config/audience/LeafCondition.java diff --git a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java index 65edf5768..94341c717 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java @@ -74,6 +74,7 @@ public class DatafileProjectConfig implements ProjectConfig { private final List groups; private final List rollouts; private final List integrations; + private final Set allSegments; // key to entity mappings private final Map attributeKeyMapping; @@ -204,6 +205,15 @@ public DatafileProjectConfig(String accountId, this.publicKeyForODP = publicKeyForODP; this.hostForODP = hostForODP; + Set allSegments = new HashSet<>(); + if (typedAudiences != null) { + for(Audience audience: typedAudiences) { + allSegments.addAll(audience.getSegments()); + } + } + + this.allSegments = allSegments; + Map variationIdToExperimentMap = new HashMap(); for (Experiment experiment : this.experiments) { for (Variation variation : experiment.getVariations()) { @@ -424,6 +434,10 @@ public List getExperiments() { return experiments; } + public Set getAllSegments() { + return this.allSegments; + } + @Override public List getExperimentsForEventKey(String eventKey) { EventType event = eventNameMapping.get(eventKey); diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java index 8d855e3e9..7865eb2d2 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java @@ -38,6 +38,7 @@ public AndCondition(@Nonnull List conditions) { this.conditions = conditions; } + @Override public List getConditions() { return conditions; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/Audience.java b/core-api/src/main/java/com/optimizely/ab/config/audience/Audience.java index 8db26844a..bfc1be85d 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/Audience.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/Audience.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, 2019, Optimizely and contributors + * Copyright 2016-2017, 2019, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,6 +22,9 @@ import com.optimizely.ab.config.IdKeyMapped; import javax.annotation.concurrent.Immutable; +import java.util.HashSet; +import java.util.List; +import java.util.Set; /** * Represents the Optimizely Audience configuration. @@ -69,4 +72,27 @@ public String toString() { ", conditions=" + conditions + '}'; } + + public Set getSegments() { + return getSegments(conditions); + } + + private static Set getSegments(Condition conditions) { + List nestedConditions = conditions.getConditions(); + Set segments = new HashSet<>(); + if (nestedConditions != null) { + for (Condition nestedCondition : nestedConditions) { + Set nestedSegments = getSegments(nestedCondition); + segments.addAll(nestedSegments); + } + } else { + if (conditions.getClass() == UserAttribute.class) { + UserAttribute userAttributeCondition = (UserAttribute) conditions; + if (UserAttribute.QUALIFIED.equals(userAttributeCondition.getMatch())) { + segments.add((String)userAttributeCondition.getValue()); + } + } + } + return segments; + } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java index 4b3341d4c..9fc248522 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java @@ -27,6 +27,7 @@ import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -97,6 +98,11 @@ public boolean equals(Object o) { (audienceId.equals(condition.audienceId))); } + @Override + public List getConditions() { + return null; + } + @Override public int hashCode() { diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java index 105c9b8e0..ab3fe99af 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java @@ -20,6 +20,7 @@ import com.optimizely.ab.config.ProjectConfig; import javax.annotation.Nullable; +import java.util.List; import java.util.Map; /** @@ -33,4 +34,6 @@ public interface Condition { String toJson(); String getOperandOrId(); + + List getConditions(); } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java index 61e010317..1f7a87b12 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java @@ -21,7 +21,7 @@ import javax.annotation.Nullable; import java.util.Map; -public class EmptyCondition implements Condition { +public class EmptyCondition extends LeafCondition { @Nullable @Override public Boolean evaluate(ProjectConfig config, OptimizelyUserContext user) { diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/LeafCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/LeafCondition.java new file mode 100644 index 000000000..a61c1650e --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/LeafCondition.java @@ -0,0 +1,26 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience; + +import java.util.List; + +public abstract class LeafCondition implements Condition { + + @Override + public List getConditions() { + return null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java index 9e4b2fb86..45dec6637 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java @@ -22,6 +22,8 @@ import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import javax.annotation.Nonnull; +import java.util.Arrays; +import java.util.List; /** @@ -41,6 +43,11 @@ public Condition getCondition() { return condition; } + @Override + public List getConditions() { + return Arrays.asList(condition); + } + @Nullable public Boolean evaluate(ProjectConfig config, OptimizelyUserContext user) { Boolean conditionEval = condition == null ? null : condition.evaluate(config, user); diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java index 44ddee7ca..1e12b836e 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java @@ -21,7 +21,7 @@ import javax.annotation.Nullable; import java.util.Map; -public class NullCondition implements Condition { +public class NullCondition extends LeafCondition { @Nullable @Override public Boolean evaluate(ProjectConfig config, OptimizelyUserContext user) { diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java index 12fbbb3b2..c0f3603eb 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java @@ -38,6 +38,7 @@ public OrCondition(@Nonnull List conditions) { this.conditions = conditions; } + @Override public List getConditions() { return conditions; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java b/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java index e7ff16f31..c38b6c2a4 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java @@ -38,7 +38,7 @@ */ @Immutable @JsonIgnoreProperties(ignoreUnknown = true) -public class UserAttribute implements Condition { +public class UserAttribute extends LeafCondition { public static final String QUALIFIED = "qualified"; private static final Logger logger = LoggerFactory.getLogger(UserAttribute.class); diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java index 5a69b35ee..5a88e8ad9 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java @@ -1840,4 +1840,40 @@ public void nullValueEvaluate() { assertNull(nullValueAttribute.evaluate(null, OTUtils.user(Collections.singletonMap(attributeName, attributeValue)))); assertNull(nullValueAttribute.evaluate(null, OTUtils.user((Collections.singletonMap(attributeName, ""))))); } + + @Test + public void getAllSegmentsFromAudience() { + Condition condition = createMultipleConditionAudienceAndOrODP(); + Audience audience = new Audience("1", "testAudience", condition); + assertEquals(new HashSet<>(Arrays.asList("odp-segment-1", "odp-segment-2", "odp-segment-3", "odp-segment-4")), audience.getSegments()); + + // ["and", "1", "2"] + List audience1And2 = new ArrayList<>(); + audience1And2.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-1")); + audience1And2.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-2")); + AndCondition audienceCondition1 = new AndCondition(audience1And2); + + // ["and", "3", "4"] + List audience3And4 = new ArrayList<>(); + audience3And4.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-3")); + audience3And4.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-4")); + AndCondition audienceCondition2 = new AndCondition(audience3And4); + + // ["or", "5", "6"] + List audience5And6 = new ArrayList<>(); + audience5And6.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-5")); + audience5And6.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-6")); + OrCondition audienceCondition3 = new OrCondition(audience5And6); + + + //['or', '1', '2', '3'] + List conditions = new ArrayList<>(); + conditions.add(audienceCondition1); + conditions.add(audienceCondition2); + conditions.add(audienceCondition3); + + OrCondition implicitOr = new OrCondition(conditions); + audience = new Audience("1", "testAudience", implicitOr); + assertEquals(new HashSet<>(Arrays.asList("odp-segment-1", "odp-segment-2", "odp-segment-3", "odp-segment-4", "odp-segment-5", "odp-segment-6")), audience.getSegments()); + } } From 1cf2d7214e7b6b54c929ee8b946c32ae40dfb01a Mon Sep 17 00:00:00 2001 From: Zeeshan Ashraf <35262377+zashraf1985@users.noreply.github.com> Date: Wed, 17 Aug 2022 17:08:03 -0700 Subject: [PATCH 086/147] feat: Added ODPSegmentManager (#484) ## Summary Added an ODPSegmentManager which provides functionality to fetch segments from ODP server and cache and re-use them. ## Test plan - Manually tested thoroughly - Added new unit tests - Existing unit tests pass - All Full stack compatibility suite tests pass ## JIRA [OASIS-8383](https://optimizely.atlassian.net/browse/OASIS-8383) --- .../java/com/optimizely/ab/odp/ODPConfig.java | 74 +++++++ .../optimizely/ab/odp/ODPSegmentManager.java | 108 +++++++++ .../optimizely/ab/odp/ODPSegmentOption.java | 25 +++ .../com/optimizely/ab/odp/ODPUserKey.java | 34 +++ .../ab/odp/ODPSegmentManagerTest.java | 207 ++++++++++++++++++ 5 files changed, 448 insertions(+) create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentOption.java create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/ODPUserKey.java create mode 100644 core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java new file mode 100644 index 000000000..ad8667eb4 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java @@ -0,0 +1,74 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import java.util.Collections; +import java.util.List; + +public class ODPConfig { + + private String apiKey; + + private String apiHost; + + private List allSegments; + + public ODPConfig(String apiKey, String apiHost, List allSegments) { + this.apiKey = apiKey; + this.apiHost = apiHost; + this.allSegments = allSegments; + } + + public ODPConfig(String apiKey, String apiHost) { + this(apiKey, apiHost, Collections.emptyList()); + } + + public synchronized Boolean isReady() { + return !( + this.apiKey == null || this.apiKey.isEmpty() + || this.apiHost == null || this.apiHost.isEmpty() + ); + } + + public synchronized Boolean hasSegments() { + return allSegments != null && !allSegments.isEmpty(); + } + + public synchronized void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public synchronized void setApiHost(String apiHost) { + this.apiHost = apiHost; + } + + public synchronized String getApiKey() { + return apiKey; + } + + public synchronized String getApiHost() { + return apiHost; + } + + public synchronized List getAllSegments() { + return allSegments; + } + + public synchronized void setAllSegments(List allSegments) { + this.allSegments = allSegments; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java new file mode 100644 index 000000000..ffda9c19c --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java @@ -0,0 +1,108 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import com.optimizely.ab.internal.Cache; +import com.optimizely.ab.internal.DefaultLRUCache; +import com.optimizely.ab.odp.parser.ResponseJsonParser; +import com.optimizely.ab.odp.parser.ResponseJsonParserFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; +import java.util.List; + +public class ODPSegmentManager { + + private static final Logger logger = LoggerFactory.getLogger(ODPSegmentManager.class); + + private static final String SEGMENT_URL_PATH = "/v3/graphql"; + + private final ODPApiManager apiManager; + + private final ODPConfig odpConfig; + + private final Cache> segmentsCache; + + public ODPSegmentManager(ODPConfig odpConfig, ODPApiManager apiManager) { + this(odpConfig, apiManager, Cache.DEFAULT_MAX_SIZE, Cache.DEFAULT_TIMEOUT_SECONDS); + } + + public ODPSegmentManager(ODPConfig odpConfig, ODPApiManager apiManager, Cache> cache) { + this.apiManager = apiManager; + this.odpConfig = odpConfig; + this.segmentsCache = cache; + } + + public ODPSegmentManager(ODPConfig odpConfig, ODPApiManager apiManager, Integer cacheSize, Integer cacheTimeoutSeconds) { + this.apiManager = apiManager; + this.odpConfig = odpConfig; + this.segmentsCache = new DefaultLRUCache<>(cacheSize, cacheTimeoutSeconds); + } + + public List getQualifiedSegments(String fsUserId) { + return getQualifiedSegments(ODPUserKey.FS_USER_ID, fsUserId, Collections.emptyList()); + } + public List getQualifiedSegments(String fsUserId, List options) { + return getQualifiedSegments(ODPUserKey.FS_USER_ID, fsUserId, options); + } + + public List getQualifiedSegments(ODPUserKey userKey, String userValue) { + return getQualifiedSegments(userKey, userValue, Collections.emptyList()); + } + + public List getQualifiedSegments(ODPUserKey userKey, String userValue, List options) { + if (!odpConfig.isReady()) { + logger.error("Audience segments fetch failed (ODP is not enabled)"); + return Collections.emptyList(); + } + + if (!odpConfig.hasSegments()) { + logger.debug("No Segments are used in the project, Not Fetching segments. Returning empty list"); + return Collections.emptyList(); + } + + List qualifiedSegments; + String cacheKey = getCacheKey(userKey.getKeyString(), userValue); + + if (options.contains(ODPSegmentOption.RESET_CACHE)) { + segmentsCache.reset(); + } else if (!options.contains(ODPSegmentOption.IGNORE_CACHE)) { + qualifiedSegments = segmentsCache.lookup(cacheKey); + if (qualifiedSegments != null) { + logger.debug("ODP Cache Hit. Returning segments from Cache."); + return qualifiedSegments; + } + } + + logger.debug("ODP Cache Miss. Making a call to ODP Server."); + + ResponseJsonParser parser = ResponseJsonParserFactory.getParser(); + String qualifiedSegmentsResponse = apiManager.fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + SEGMENT_URL_PATH, userKey.getKeyString(), userValue, odpConfig.getAllSegments()); + qualifiedSegments = parser.parseQualifiedSegments(qualifiedSegmentsResponse); + + if (qualifiedSegments != null && !options.contains(ODPSegmentOption.IGNORE_CACHE)) { + segmentsCache.save(cacheKey, qualifiedSegments); + } + + return qualifiedSegments; + } + + private String getCacheKey(String userKey, String userValue) { + return userKey + "-$-" + userValue; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentOption.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentOption.java new file mode 100644 index 000000000..8e2eb901b --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentOption.java @@ -0,0 +1,25 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +public enum ODPSegmentOption { + + IGNORE_CACHE, + + RESET_CACHE; + +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPUserKey.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPUserKey.java new file mode 100644 index 000000000..d7cdbb641 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPUserKey.java @@ -0,0 +1,34 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +public enum ODPUserKey { + + VUID("vuid"), + + FS_USER_ID("fs_user_id"); + + private final String keyString; + + ODPUserKey(String keyString) { + this.keyString = keyString; + } + + public String getKeyString() { + return keyString; + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java new file mode 100644 index 000000000..f784d53d0 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java @@ -0,0 +1,207 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import ch.qos.logback.classic.Level; +import com.optimizely.ab.internal.Cache; +import com.optimizely.ab.internal.LogbackVerifier; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.Mockito; + +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; +import static org.junit.Assert.*; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class ODPSegmentManagerTest { + + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + + @Mock + Cache> mockCache; + + @Mock + ODPApiManager mockApiManager; + + private static final String API_RESPONSE = "{\"data\":{\"customer\":{\"audiences\":{\"edges\":[{\"node\":{\"name\":\"segment1\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"segment2\",\"state\":\"qualified\"}}]}}}}"; + + @Before + public void setup() { + mockCache = mock(Cache.class); + mockApiManager = mock(ODPApiManager.class); + } + + @Test + public void cacheHit() { + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", Arrays.asList("segment1", "segment2")); + ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + List segments = segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId"); + + // Cache lookup called with correct key + verify(mockCache, times(1)).lookup("fs_user_id-$-testId"); + + // Cache hit! No api call was made to the server. + verify(mockApiManager, times(0)).fetchQualifiedSegments(any(), any(), any(), any(), any()); + verify(mockCache, times(0)).save(any(), any()); + verify(mockCache, times(0)).reset(); + + logbackVerifier.expectMessage(Level.DEBUG, "ODP Cache Hit. Returning segments from Cache."); + + assertEquals(Arrays.asList("segment1-cached", "segment2-cached"), segments); + } + + @Test + public void cacheMiss() { + Mockito.when(mockCache.lookup(any())).thenReturn(null); + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anyList())) + .thenReturn(API_RESPONSE); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", Arrays.asList("segment1", "segment2")); + ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + List segments = segmentManager.getQualifiedSegments(ODPUserKey.VUID, "testId"); + + // Cache lookup called with correct key + verify(mockCache, times(1)).lookup("vuid-$-testId"); + + // Cache miss! Make api call and save to cache + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "vuid", "testId", Arrays.asList("segment1", "segment2")); + verify(mockCache, times(1)).save("vuid-$-testId", Arrays.asList("segment1", "segment2")); + verify(mockCache, times(0)).reset(); + + logbackVerifier.expectMessage(Level.DEBUG, "ODP Cache Miss. Making a call to ODP Server."); + + assertEquals(Arrays.asList("segment1", "segment2"), segments); + } + + @Test + public void ignoreCache() { + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anyList())) + .thenReturn(API_RESPONSE); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", Arrays.asList("segment1", "segment2")); + ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + List segments = segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", Collections.singletonList(ODPSegmentOption.IGNORE_CACHE)); + + // Cache Ignored! lookup should not be called + verify(mockCache, times(0)).lookup(any()); + + // Cache Ignored! Make API Call but do NOT save because of cacheIgnore + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", Arrays.asList("segment1", "segment2")); + verify(mockCache, times(0)).save(any(), any()); + verify(mockCache, times(0)).reset(); + + assertEquals(Arrays.asList("segment1", "segment2"), segments); + } + + @Test + public void resetCache() { + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anyList())) + .thenReturn(API_RESPONSE); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", Arrays.asList("segment1", "segment2")); + ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + List segments = segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", Collections.singletonList(ODPSegmentOption.RESET_CACHE)); + + // Call reset + verify(mockCache, times(1)).reset(); + + // Cache Reset! lookup should not be called becaues cache would be empty. + verify(mockCache, times(0)).lookup(any()); + + // Cache reset but not Ignored! Make API Call and save to cache + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", Arrays.asList("segment1", "segment2")); + verify(mockCache, times(1)).save("fs_user_id-$-testId", Arrays.asList("segment1", "segment2")); + + assertEquals(Arrays.asList("segment1", "segment2"), segments); + } + + @Test + public void resetAndIgnoreCache() { + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anyList())) + .thenReturn(API_RESPONSE); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", Arrays.asList("segment1", "segment2")); + ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + List segments = segmentManager + .getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", Arrays.asList(ODPSegmentOption.RESET_CACHE, ODPSegmentOption.IGNORE_CACHE)); + + // Call reset + verify(mockCache, times(1)).reset(); + + verify(mockCache, times(0)).lookup(any()); + + // Cache is also Ignored! Make API Call but do not save + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", Arrays.asList("segment1", "segment2")); + verify(mockCache, times(0)).save(any(), any()); + + assertEquals(Arrays.asList("segment1", "segment2"), segments); + } + + @Test + public void odpConfigNotReady() { + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + + ODPConfig odpConfig = new ODPConfig(null, null, Arrays.asList("segment1", "segment2")); + ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + List segments = segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId"); + + // No further methods should be called. + verify(mockCache, times(0)).lookup("fs_user_id-$-testId"); + verify(mockApiManager, times(0)).fetchQualifiedSegments(any(), any(), any(), any(), any()); + verify(mockCache, times(0)).save(any(), any()); + verify(mockCache, times(0)).reset(); + + logbackVerifier.expectMessage(Level.ERROR, "Audience segments fetch failed (ODP is not enabled)"); + + assertEquals(Collections.emptyList(), segments); + } + + @Test + public void noSegmentsInProject() { + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", null); + ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + List segments = segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId"); + + // No further methods should be called. + verify(mockCache, times(0)).lookup("fs_user_id-$-testId"); + verify(mockApiManager, times(0)).fetchQualifiedSegments(any(), any(), any(), any(), any()); + verify(mockCache, times(0)).save(any(), any()); + verify(mockCache, times(0)).reset(); + + logbackVerifier.expectMessage(Level.DEBUG, "No Segments are used in the project, Not Fetching segments. Returning empty list"); + + assertEquals(Collections.emptyList(), segments); + } +} From 8b8a9838cfd3824d13e5c472a7d578a4f4d4ed97 Mon Sep 17 00:00:00 2001 From: Zeeshan Ashraf <35262377+zashraf1985@users.noreply.github.com> Date: Mon, 29 Aug 2022 11:27:37 -0700 Subject: [PATCH 087/147] feat: Added ODP RestApi interface for sending Events to ODP (#485) ## Summary This module provides an internal service for ODP event REST api access. ## Test plan - Manually tested thoroughly. - Added new unit tests. - All Existing Unit tests pass. - All existing Full stack compatibility tests pass. ## Jira [OASIS-8387](https://optimizely.atlassian.net/browse/OASIS-8387) --- .../com/optimizely/ab/odp/ODPApiManager.java | 2 + .../java/com/optimizely/ab/odp/ODPEvent.java | 64 ++++++++++++++ .../ab/odp/serializer/ODPJsonSerializer.java | 24 ++++++ .../serializer/ODPJsonSerializerFactory.java | 49 +++++++++++ .../odp/serializer/impl/GsonSerializer.java | 31 +++++++ .../serializer/impl/JacksonSerializer.java | 36 ++++++++ .../odp/serializer/impl/JsonSerializer.java | 59 +++++++++++++ .../serializer/impl/JsonSimpleSerializer.java | 55 ++++++++++++ .../parser/ResponseJsonParserFactoryTest.java | 1 - .../ODPJsonSerializerFactoryTest.java | 64 ++++++++++++++ .../odp/serializer/ODPJsonSerializerTest.java | 85 +++++++++++++++++++ .../ab/odp/DefaultODPApiManager.java | 57 ++++++++++++- .../ab/odp/DefaultODPApiManagerTest.java | 17 +++- 13 files changed, 541 insertions(+), 3 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/serializer/ODPJsonSerializer.java create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerFactory.java create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/GsonSerializer.java create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JacksonSerializer.java create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JsonSerializer.java create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JsonSimpleSerializer.java create mode 100644 core-api/src/test/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerFactoryTest.java create mode 100644 core-api/src/test/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerTest.java diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java index ed40e37fd..dee9413dd 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java @@ -19,4 +19,6 @@ public interface ODPApiManager { String fetchQualifiedSegments(String apiKey, String apiEndpoint, String userKey, String userValue, List segmentsToCheck); + + Integer sendEvents(String apiKey, String apiEndpoint, String eventPayload); } diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java new file mode 100644 index 000000000..34bd340b6 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java @@ -0,0 +1,64 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import java.util.Map; + +public class ODPEvent { + private String type; + private String action; + private Map identifiers; + private Map data; + + public ODPEvent(String type, String action, Map identifiers, Map data) { + this.type = type; + this.action = action; + this.identifiers = identifiers; + this.data = data; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public Map getIdentifiers() { + return identifiers; + } + + public void setIdentifiers(Map identifiers) { + this.identifiers = identifiers; + } + + public Map getData() { + return data; + } + + public void setData(Map data) { + this.data = data; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/serializer/ODPJsonSerializer.java b/core-api/src/main/java/com/optimizely/ab/odp/serializer/ODPJsonSerializer.java new file mode 100644 index 000000000..4f3922340 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/serializer/ODPJsonSerializer.java @@ -0,0 +1,24 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.serializer; + +import com.optimizely.ab.odp.ODPEvent; + +import java.util.List; + +public interface ODPJsonSerializer { + public String serializeEvents(List events); +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerFactory.java b/core-api/src/main/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerFactory.java new file mode 100644 index 000000000..ca47e3bf4 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerFactory.java @@ -0,0 +1,49 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.serializer; + +import com.optimizely.ab.internal.JsonParserProvider; +import com.optimizely.ab.odp.serializer.impl.GsonSerializer; +import com.optimizely.ab.odp.serializer.impl.JacksonSerializer; +import com.optimizely.ab.odp.serializer.impl.JsonSerializer; +import com.optimizely.ab.odp.serializer.impl.JsonSimpleSerializer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ODPJsonSerializerFactory { + private static final Logger logger = LoggerFactory.getLogger(ODPJsonSerializerFactory.class); + + public static ODPJsonSerializer getSerializer() { + JsonParserProvider parserProvider = JsonParserProvider.getDefaultParser(); + ODPJsonSerializer jsonSerializer = null; + switch (parserProvider) { + case GSON_CONFIG_PARSER: + jsonSerializer = new GsonSerializer(); + break; + case JACKSON_CONFIG_PARSER: + jsonSerializer = new JacksonSerializer(); + break; + case JSON_CONFIG_PARSER: + jsonSerializer = new JsonSerializer(); + break; + case JSON_SIMPLE_CONFIG_PARSER: + jsonSerializer = new JsonSimpleSerializer(); + break; + } + logger.info("Using " + parserProvider.toString() + " serializer"); + return jsonSerializer; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/GsonSerializer.java b/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/GsonSerializer.java new file mode 100644 index 000000000..d72963260 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/GsonSerializer.java @@ -0,0 +1,31 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.serializer.impl; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.optimizely.ab.odp.ODPEvent; +import com.optimizely.ab.odp.serializer.ODPJsonSerializer; + +import java.util.List; + +public class GsonSerializer implements ODPJsonSerializer { + @Override + public String serializeEvents(List events) { + Gson gson = new GsonBuilder().serializeNulls().create(); + return gson.toJson(events); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JacksonSerializer.java b/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JacksonSerializer.java new file mode 100644 index 000000000..80cffa7d0 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JacksonSerializer.java @@ -0,0 +1,36 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.serializer.impl; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.optimizely.ab.odp.ODPEvent; +import com.optimizely.ab.odp.serializer.ODPJsonSerializer; + +import java.util.List; + +public class JacksonSerializer implements ODPJsonSerializer { + @Override + public String serializeEvents(List events) { + ObjectMapper objectMapper = new ObjectMapper(); + try { + return objectMapper.writeValueAsString(events); + } catch (JsonProcessingException e) { + // log error here + } + return null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JsonSerializer.java b/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JsonSerializer.java new file mode 100644 index 000000000..c65c1fda3 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JsonSerializer.java @@ -0,0 +1,59 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.serializer.impl; + +import com.optimizely.ab.odp.ODPEvent; +import com.optimizely.ab.odp.serializer.ODPJsonSerializer; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.List; +import java.util.Map; + +public class JsonSerializer implements ODPJsonSerializer { + @Override + public String serializeEvents(List events) { + JSONArray jsonArray = new JSONArray(); + for (ODPEvent event: events) { + JSONObject eventObject = new JSONObject(); + eventObject.put("type", event.getType()); + eventObject.put("action", event.getAction()); + + if (event.getIdentifiers() != null) { + JSONObject identifiers = new JSONObject(); + for (Map.Entry identifier : event.getIdentifiers().entrySet()) { + identifiers.put(identifier.getKey(), identifier.getValue()); + } + eventObject.put("identifiers", identifiers); + } + + if (event.getData() != null) { + JSONObject data = new JSONObject(); + for (Map.Entry dataEntry : event.getData().entrySet()) { + data.put(dataEntry.getKey(), getJSONObjectValue(dataEntry.getValue())); + } + eventObject.put("data", data); + } + + jsonArray.put(eventObject); + } + return jsonArray.toString(); + } + + private static Object getJSONObjectValue(Object value) { + return value == null ? JSONObject.NULL : value; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JsonSimpleSerializer.java b/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JsonSimpleSerializer.java new file mode 100644 index 000000000..96e5a7357 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JsonSimpleSerializer.java @@ -0,0 +1,55 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.serializer.impl; + +import com.optimizely.ab.odp.ODPEvent; +import com.optimizely.ab.odp.serializer.ODPJsonSerializer; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; + +import java.util.List; +import java.util.Map; + +public class JsonSimpleSerializer implements ODPJsonSerializer { + @Override + public String serializeEvents(List events) { + JSONArray jsonArray = new JSONArray(); + for (ODPEvent event: events) { + JSONObject eventObject = new JSONObject(); + eventObject.put("type", event.getType()); + eventObject.put("action", event.getAction()); + + if (event.getIdentifiers() != null) { + JSONObject identifiers = new JSONObject(); + for (Map.Entry identifier : event.getIdentifiers().entrySet()) { + identifiers.put(identifier.getKey(), identifier.getValue()); + } + eventObject.put("identifiers", identifiers); + } + + if (event.getData() != null) { + JSONObject data = new JSONObject(); + for (Map.Entry dataEntry : event.getData().entrySet()) { + data.put(dataEntry.getKey(), dataEntry.getValue()); + } + eventObject.put("data", data); + } + + jsonArray.add(eventObject); + } + return jsonArray.toJSONString(); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactoryTest.java index f5d8e8c89..a4f51a3a7 100644 --- a/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactoryTest.java +++ b/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactoryTest.java @@ -15,7 +15,6 @@ */ package com.optimizely.ab.odp.parser; -import com.optimizely.ab.internal.JsonParserProvider; import com.optimizely.ab.internal.PropertyUtils; import com.optimizely.ab.odp.parser.impl.GsonParser; import com.optimizely.ab.odp.parser.impl.JsonParser; diff --git a/core-api/src/test/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerFactoryTest.java new file mode 100644 index 000000000..5c47a1f4f --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerFactoryTest.java @@ -0,0 +1,64 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.serializer; + +import com.optimizely.ab.internal.PropertyUtils; +import com.optimizely.ab.odp.serializer.impl.GsonSerializer; +import com.optimizely.ab.odp.serializer.impl.JacksonSerializer; +import com.optimizely.ab.odp.serializer.impl.JsonSerializer; +import com.optimizely.ab.odp.serializer.impl.JsonSimpleSerializer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class ODPJsonSerializerFactoryTest { + @Before + @After + public void clearParserSystemProperty() { + PropertyUtils.clear("default_parser"); + } + + @Test + public void getGsonSerializerWhenNoDefaultIsSet() { + assertEquals(GsonSerializer.class, ODPJsonSerializerFactory.getSerializer().getClass()); + } + + @Test + public void getCorrectSerializerWhenValidDefaultIsProvidedIsJson() { + PropertyUtils.set("default_parser", "JSON_CONFIG_PARSER"); + assertEquals(JsonSerializer.class, ODPJsonSerializerFactory.getSerializer().getClass()); + } + + @Test + public void getCorrectSerializerWhenValidDefaultIsProvidedIsJsonSimple() { + PropertyUtils.set("default_parser", "JSON_SIMPLE_CONFIG_PARSER"); + assertEquals(JsonSimpleSerializer.class, ODPJsonSerializerFactory.getSerializer().getClass()); + } + + @Test + public void getCorrectSerializerWhenValidDefaultIsProvidedIsJackson() { + PropertyUtils.set("default_parser", "JACKSON_CONFIG_PARSER"); + assertEquals(JacksonSerializer.class, ODPJsonSerializerFactory.getSerializer().getClass()); + } + + @Test + public void getGsonSerializerWhenGivenDefaultSerializerDoesNotExist() { + PropertyUtils.set("default_parser", "GARBAGE_VALUE"); + assertEquals(GsonSerializer.class, ODPJsonSerializerFactory.getSerializer().getClass()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerTest.java new file mode 100644 index 000000000..7a9538a8f --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerTest.java @@ -0,0 +1,85 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.serializer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.optimizely.ab.odp.ODPEvent; +import com.optimizely.ab.odp.serializer.impl.*; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.util.*; + +import static junit.framework.TestCase.assertEquals; + +@RunWith(Parameterized.class) +public class ODPJsonSerializerTest { + private final ODPJsonSerializer jsonSerializer; + + public ODPJsonSerializerTest(ODPJsonSerializer jsonSerializer) { + super(); + this.jsonSerializer = jsonSerializer; + } + + @Parameterized.Parameters + public static List input() { + return Arrays.asList(new GsonSerializer(), new JsonSerializer(), new JsonSimpleSerializer(), new JacksonSerializer()); + } + + @Test + public void serializeMultipleEvents() throws JsonProcessingException { + List events = Arrays.asList( + createTestEvent("1"), + createTestEvent("2"), + createTestEvent("3") + ); + + ObjectMapper mapper = new ObjectMapper(); + + String expectedResult = "[{\"type\":\"type-1\",\"action\":\"action-1\",\"identifiers\":{\"vuid-1-3\":\"fs-1-3\",\"vuid-1-1\":\"fs-1-1\",\"vuid-1-2\":\"fs-1-2\"},\"data\":{\"source\":\"java-sdk\",\"data-1\":\"data-value-1\",\"data-num\":1,\"data-bool-true\":true,\"data-bool-false\":false,\"data-float\":2.33,\"data-null\":null}},{\"type\":\"type-2\",\"action\":\"action-2\",\"identifiers\":{\"vuid-2-3\":\"fs-2-3\",\"vuid-2-2\":\"fs-2-2\",\"vuid-2-1\":\"fs-2-1\"},\"data\":{\"source\":\"java-sdk\",\"data-1\":\"data-value-2\",\"data-num\":2,\"data-bool-true\":true,\"data-bool-false\":false,\"data-float\":2.33,\"data-null\":null}},{\"type\":\"type-3\",\"action\":\"action-3\",\"identifiers\":{\"vuid-3-3\":\"fs-3-3\",\"vuid-3-2\":\"fs-3-2\",\"vuid-3-1\":\"fs-3-1\"},\"data\":{\"source\":\"java-sdk\",\"data-1\":\"data-value-3\",\"data-num\":3,\"data-bool-true\":true,\"data-bool-false\":false,\"data-float\":2.33,\"data-null\":null}}]"; + String serializedString = jsonSerializer.serializeEvents(events); + assertEquals(mapper.readTree(expectedResult), mapper.readTree(serializedString)); + } + + @Test + public void serializeEmptyList() throws JsonProcessingException { + List events = Collections.emptyList(); + String expectedResult = "[]"; + String serializedString = jsonSerializer.serializeEvents(events); + assertEquals(expectedResult, serializedString); + } + + private static ODPEvent createTestEvent(String index) { + Map identifiers = new HashMap<>(); + identifiers.put("vuid-" + index + "-1", "fs-" + index + "-1"); + identifiers.put("vuid-" + index + "-2", "fs-" + index + "-2"); + identifiers.put("vuid-" + index + "-3", "fs-" + index + "-3"); + + Map data = new HashMap<>(); + data.put("source", "java-sdk"); + data.put("data-1", "data-value-" + index); + data.put("data-num", Integer.parseInt(index)); + data.put("data-float", 2.33); + data.put("data-bool-true", true); + data.put("data-bool-false", false); + data.put("data-null", null); + + + return new ODPEvent("type-" + index, "action-" + index, identifiers, data); + } +} diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java index f0703dcb0..07ee1b3eb 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java @@ -17,7 +17,6 @@ import com.optimizely.ab.OptimizelyHttpClient; import com.optimizely.ab.annotations.VisibleForTesting; -import org.apache.http.HttpStatus; import org.apache.http.StatusLine; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpPost; @@ -167,6 +166,62 @@ public String fetchQualifiedSegments(String apiKey, String apiEndpoint, String u return null; } + /* + eventPayload Format + [ + { + "action": "identified", + "identifiers": {"vuid": , "fs_user_id": , ....}, + "data": {“source”: , ....}, + "type": " fullstack " + }, + { + "action": "client_initialized", + "identifiers": {"vuid": , ....}, + "data": {“source”: , ....}, + "type": "fullstack" + } + ] + + Returns: + 1. null, When there was a non-recoverable error and no retry is needed. + 2. 0 If an unexpected error occurred and retrying can be useful. + 3. HTTPStatus code if httpclient was able to make the request and was able to receive response. + It is recommended to retry if status code was 5xx. + */ + @Override + public Integer sendEvents(String apiKey, String apiEndpoint, String eventPayload) { + HttpPost request = new HttpPost(apiEndpoint); + + try { + request.setEntity(new StringEntity(eventPayload)); + } catch (UnsupportedEncodingException e) { + logger.error("ODP event send failed (Error encoding request payload)", e); + return null; + } + request.setHeader("x-api-key", apiKey); + request.setHeader("content-type", "application/json"); + + CloseableHttpResponse response = null; + try { + response = httpClient.execute(request); + } catch (IOException e) { + logger.error("Error retrieving response from event request", e); + return 0; + } + + int statusCode = response.getStatusLine().getStatusCode(); + if ( statusCode >= 400) { + StatusLine statusLine = response.getStatusLine(); + logger.error(String.format("ODP event send failed (Response code: %d, %s)", statusLine.getStatusCode(), statusLine.getReasonPhrase())); + } else { + logger.debug("ODP Event Dispatched successfully"); + } + + closeHttpResponse(response); + return statusCode; + } + private static void closeHttpResponse(CloseableHttpResponse response) { if (response != null) { try { diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java index c81af2dce..77440f804 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java @@ -111,6 +111,21 @@ public void returnNullWhenStatusIsNot200AndLogError() throws Exception { String responseString = apiManager.fetchQualifiedSegments("key", "endPoint", "fs_user_id", "test_user", Arrays.asList("segment_1", "segment_2")); verify(mockHttpClient, times(1)).execute(any(HttpPost.class)); logbackVerifier.expectMessage(Level.ERROR, "Unexpected response from ODP server, Response code: 500, null"); - assertEquals(null, responseString); + assertNull(responseString); + } + + @Test + public void eventDispatchSuccess() { + ODPApiManager apiManager = new DefaultODPApiManager(mockHttpClient); + apiManager.sendEvents("testKey", "testEndpoint", "[]"); + logbackVerifier.expectMessage(Level.DEBUG, "ODP Event Dispatched successfully"); + } + + @Test + public void eventDispatchFailStatus() throws Exception { + setupHttpClient(400); + ODPApiManager apiManager = new DefaultODPApiManager(mockHttpClient); + apiManager.sendEvents("testKey", "testEndpoint", "[]]"); + logbackVerifier.expectMessage(Level.ERROR, "ODP event send failed (Response code: 400, null)"); } } From 2a38338dbe39283f86b8a5144ca519aab4dfceb9 Mon Sep 17 00:00:00 2001 From: Zeeshan Ashraf <35262377+zashraf1985@users.noreply.github.com> Date: Fri, 16 Sep 2022 10:24:47 -0700 Subject: [PATCH 088/147] feat: Added ODPEventManager implementation (#487) ## Summary Added `ODPEventManager` implementation. ## Test plan - Unit tests pending. ## Jira [OASIS-8386](https://optimizely.atlassian.net/browse/OASIS-8386) --- .../java/com/optimizely/ab/odp/ODPEvent.java | 9 +- .../optimizely/ab/odp/ODPEventManager.java | 199 +++++++++++++++ .../ab/odp/ODPEventManagerTest.java | 234 ++++++++++++++++++ 3 files changed, 439 insertions(+), 3 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java create mode 100644 core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java index 34bd340b6..903bcf663 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java @@ -15,6 +15,9 @@ */ package com.optimizely.ab.odp; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Collections; import java.util.Map; public class ODPEvent { @@ -23,11 +26,11 @@ public class ODPEvent { private Map identifiers; private Map data; - public ODPEvent(String type, String action, Map identifiers, Map data) { + public ODPEvent(@Nonnull String type, @Nonnull String action, @Nullable Map identifiers, @Nullable Map data) { this.type = type; this.action = action; - this.identifiers = identifiers; - this.data = data; + this.identifiers = identifiers != null ? identifiers : Collections.emptyMap(); + this.data = data != null ? data : Collections.emptyMap(); } public String getType() { diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java new file mode 100644 index 000000000..7cc601f29 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java @@ -0,0 +1,199 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import com.optimizely.ab.event.internal.BuildVersionInfo; +import com.optimizely.ab.event.internal.ClientEngineInfo; +import com.optimizely.ab.odp.serializer.ODPJsonSerializerFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.*; +import java.util.concurrent.*; + +public class ODPEventManager { + private static final Logger logger = LoggerFactory.getLogger(ODPEventManager.class); + private static final int DEFAULT_BATCH_SIZE = 10; + private static final int DEFAULT_QUEUE_SIZE = 10000; + private static final int DEFAULT_FLUSH_INTERVAL = 1000; + private static final int MAX_RETRIES = 3; + private static final String EVENT_URL_PATH = "/v3/events"; + + private final int queueSize; + private final int batchSize; + private final int flushInterval; + + private Boolean isRunning = false; + + // This needs to be volatile because it will be updated in the main thread and the event dispatcher thread + // needs to see the change immediately. + private volatile ODPConfig odpConfig; + private EventDispatcherThread eventDispatcherThread; + + private final ODPApiManager apiManager; + + // The eventQueue needs to be thread safe. We are not doing anything extra for thread safety here + // because `LinkedBlockingQueue` itself is thread safe. + private final BlockingQueue eventQueue = new LinkedBlockingQueue<>(); + + public ODPEventManager(@Nonnull ODPConfig odpConfig, @Nonnull ODPApiManager apiManager) { + this(odpConfig, apiManager, null, null, null); + } + + public ODPEventManager(@Nonnull ODPConfig odpConfig, @Nonnull ODPApiManager apiManager, @Nullable Integer batchSize, @Nullable Integer queueSize, @Nullable Integer flushInterval) { + this.odpConfig = odpConfig; + this.apiManager = apiManager; + this.batchSize = (batchSize != null && batchSize > 1) ? batchSize : DEFAULT_BATCH_SIZE; + this.queueSize = queueSize != null ? queueSize : DEFAULT_QUEUE_SIZE; + this.flushInterval = (flushInterval != null && flushInterval > 0) ? flushInterval : DEFAULT_FLUSH_INTERVAL; + } + + public void start() { + isRunning = true; + eventDispatcherThread = new EventDispatcherThread(); + eventDispatcherThread.start(); + } + + public void updateSettings(ODPConfig odpConfig) { + this.odpConfig = odpConfig; + } + + public void identifyUser(@Nullable String vuid, String userId) { + Map identifiers = new HashMap<>(); + if (vuid != null) { + identifiers.put(ODPUserKey.VUID.getKeyString(), vuid); + } + identifiers.put(ODPUserKey.FS_USER_ID.getKeyString(), userId); + ODPEvent event = new ODPEvent("fullstack", "client_initialized", identifiers, null); + sendEvent(event); + } + + public void sendEvent(ODPEvent event) { + event.setData(augmentCommonData(event.getData())); + processEvent(event); + } + + private Map augmentCommonData(Map sourceData) { + Map data = new HashMap<>(); + data.put("idempotence_id", UUID.randomUUID().toString()); + data.put("data_source_type", "sdk"); + data.put("data_source", ClientEngineInfo.getClientEngine().getClientEngineValue()); + data.put("data_source_version", BuildVersionInfo.getClientVersion()); + data.putAll(sourceData); + return data; + } + + private void processEvent(ODPEvent event) { + if (!isRunning) { + logger.warn("Failed to Process ODP Event. ODPEventManager is not running"); + return; + } + + if (!odpConfig.isReady()) { + logger.debug("Unable to Process ODP Event. ODPConfig is not ready."); + return; + } + + if (eventQueue.size() >= queueSize) { + logger.warn("Failed to Process ODP Event. Event Queue full. queueSize = " + queueSize); + return; + } + + if (!eventQueue.offer(event)) { + logger.error("Failed to Process ODP Event. Event Queue is not accepting any more events"); + } + } + + public void stop() { + logger.debug("Sending stop signal to ODP Event Dispatcher Thread"); + eventDispatcherThread.signalStop(); + } + + private class EventDispatcherThread extends Thread { + + private volatile boolean shouldStop = false; + + private final List currentBatch = new ArrayList<>(); + + private long nextFlushTime = new Date().getTime(); + + @Override + public void run() { + while (true) { + try { + ODPEvent nextEvent; + + // If batch has events, set the timeout to remaining time for flush interval, + // otherwise wait for the new event indefinitely + if (currentBatch.size() > 0) { + nextEvent = eventQueue.poll(nextFlushTime - new Date().getTime(), TimeUnit.MILLISECONDS); + } else { + nextEvent = eventQueue.poll(); + } + + if (nextEvent == null) { + // null means no new events received and flush interval is over, dispatch whatever is in the batch. + if (!currentBatch.isEmpty()) { + flush(); + } + if (shouldStop) { + break; + } + continue; + } + + if (currentBatch.size() == 0) { + // Batch starting, create a new flush time + nextFlushTime = new Date().getTime() + flushInterval; + } + + currentBatch.add(nextEvent); + + if (currentBatch.size() >= batchSize) { + flush(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + logger.debug("Exiting ODP Event Dispatcher Thread."); + } + + private void flush() { + if (odpConfig.isReady()) { + String payload = ODPJsonSerializerFactory.getSerializer().serializeEvents(currentBatch); + String endpoint = odpConfig.getApiHost() + EVENT_URL_PATH; + Integer statusCode; + int numAttempts = 0; + do { + statusCode = apiManager.sendEvents(odpConfig.getApiKey(), endpoint, payload); + numAttempts ++; + } while (numAttempts < MAX_RETRIES && statusCode != null && (statusCode == 0 || statusCode >= 500)); + } else { + logger.debug("ODPConfig not ready, discarding event batch"); + } + currentBatch.clear(); + } + + public void signalStop() { + shouldStop = true; + } + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java new file mode 100644 index 000000000..7be51e415 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java @@ -0,0 +1,234 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import ch.qos.logback.classic.Level; +import com.optimizely.ab.internal.LogbackVerifier; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.runners.MockitoJUnitRunner; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; +import static org.junit.Assert.*; + +@RunWith(MockitoJUnitRunner.class) +public class ODPEventManagerTest { + + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + + @Mock + ODPApiManager mockApiManager; + + @Captor + ArgumentCaptor payloadCaptor; + + @Before + public void setup() { + mockApiManager = mock(ODPApiManager.class); + } + + @Test + public void logAndDiscardEventWhenEventManagerIsNotRunning() { + ODPConfig odpConfig = new ODPConfig("key", "host", null); + ODPEventManager eventManager = new ODPEventManager(odpConfig, mockApiManager); + ODPEvent event = new ODPEvent("test-type", "test-action", Collections.emptyMap(), Collections.emptyMap()); + eventManager.sendEvent(event); + logbackVerifier.expectMessage(Level.WARN, "Failed to Process ODP Event. ODPEventManager is not running"); + } + + @Test + public void logAndDiscardEventWhenODPConfigNotReady() { + ODPConfig odpConfig = new ODPConfig(null, null, null); + ODPEventManager eventManager = new ODPEventManager(odpConfig, mockApiManager); + eventManager.start(); + ODPEvent event = new ODPEvent("test-type", "test-action", Collections.emptyMap(), Collections.emptyMap()); + eventManager.sendEvent(event); + logbackVerifier.expectMessage(Level.DEBUG, "Unable to Process ODP Event. ODPConfig is not ready."); + } + + @Test + public void dispatchEventsInCorrectNumberOfBatches() throws InterruptedException { + Mockito.reset(mockApiManager); + Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); + ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); + ODPEventManager eventManager = new ODPEventManager(odpConfig, mockApiManager); + eventManager.start(); + for (int i = 0; i < 25; i++) { + eventManager.sendEvent(getEvent(i)); + } + Thread.sleep(1500); + Mockito.verify(mockApiManager, times(3)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); + } + + @Test + public void dispatchEventsWithCorrectPayload() throws InterruptedException { + Mockito.reset(mockApiManager); + Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); + int batchSize = 2; + ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); + ODPEventManager eventManager = new ODPEventManager(odpConfig, mockApiManager, batchSize, null, null); + eventManager.start(); + for (int i = 0; i < 6; i++) { + eventManager.sendEvent(getEvent(i)); + } + Thread.sleep(500); + Mockito.verify(mockApiManager, times(3)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), payloadCaptor.capture()); + List payloads = payloadCaptor.getAllValues(); + + for (int i = 0; i < payloads.size(); i++) { + JSONArray events = new JSONArray(payloads.get(i)); + assertEquals(batchSize, events.length()); + for (int j = 0; j < events.length(); j++) { + int id = (batchSize * i) + j; + JSONObject event = events.getJSONObject(j); + assertEquals("test-type-" + id , event.getString("type")); + assertEquals("test-action-" + id , event.getString("action")); + assertEquals("value1-" + id, event.getJSONObject("identifiers").getString("identifier1")); + assertEquals("value2-" + id, event.getJSONObject("identifiers").getString("identifier2")); + assertEquals("data-value1-" + id, event.getJSONObject("data").getString("data1")); + assertEquals(id, event.getJSONObject("data").getInt("data2")); + assertEquals("sdk", event.getJSONObject("data").getString("data_source_type")); + } + } + } + + @Test + public void dispatchEventsWithCorrectFlushInterval() throws InterruptedException { + Mockito.reset(mockApiManager); + Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); + ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); + ODPEventManager eventManager = new ODPEventManager(odpConfig, mockApiManager); + eventManager.start(); + for (int i = 0; i < 25; i++) { + eventManager.sendEvent(getEvent(i)); + } + Thread.sleep(500); + Mockito.verify(mockApiManager, times(2)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); + + // Last batch is incomplete so it needs almost a second to flush. + Thread.sleep(1500); + Mockito.verify(mockApiManager, times(3)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); + } + + @Test + public void retryFailedEvents() throws InterruptedException { + Mockito.reset(mockApiManager); + Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(500); + ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); + ODPEventManager eventManager = new ODPEventManager(odpConfig, mockApiManager); + eventManager.start(); + for (int i = 0; i < 25; i++) { + eventManager.sendEvent(getEvent(i)); + } + Thread.sleep(500); + + // Should be called thrice for each batch + Mockito.verify(mockApiManager, times(6)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); + + // Last batch is incomplete so it needs almost a second to flush. + Thread.sleep(1500); + Mockito.verify(mockApiManager, times(9)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); + } + + @Test + public void shouldFlushAllScheduledEventsBeforeStopping() throws InterruptedException { + Mockito.reset(mockApiManager); + Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); + ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); + ODPEventManager eventManager = new ODPEventManager(odpConfig, mockApiManager); + eventManager.start(); + for (int i = 0; i < 25; i++) { + eventManager.sendEvent(getEvent(i)); + } + eventManager.stop(); + Thread.sleep(1500); + Mockito.verify(mockApiManager, times(3)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); + logbackVerifier.expectMessage(Level.DEBUG, "Exiting ODP Event Dispatcher Thread."); + } + + @Test + public void prepareCorrectPayloadForIdentifyUser() throws InterruptedException { + Mockito.reset(mockApiManager); + Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); + int batchSize = 2; + ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); + ODPEventManager eventManager = new ODPEventManager(odpConfig, mockApiManager, batchSize, null, null); + eventManager.start(); + for (int i = 0; i < 2; i++) { + eventManager.identifyUser("the-vuid-" + i, "the-fs-user-id-" + i); + } + + Thread.sleep(1500); + Mockito.verify(mockApiManager, times(1)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), payloadCaptor.capture()); + + String payload = payloadCaptor.getValue(); + JSONArray events = new JSONArray(payload); + assertEquals(batchSize, events.length()); + for (int i = 0; i < events.length(); i++) { + JSONObject event = events.getJSONObject(i); + assertEquals("fullstack", event.getString("type")); + assertEquals("client_initialized", event.getString("action")); + assertEquals("the-vuid-" + i, event.getJSONObject("identifiers").getString("vuid")); + assertEquals("the-fs-user-id-" + i, event.getJSONObject("identifiers").getString("fs_user_id")); + assertEquals("sdk", event.getJSONObject("data").getString("data_source_type")); + } + } + + @Test + public void applyUpdatedODPConfigWhenAvailable() throws InterruptedException { + Mockito.reset(mockApiManager); + Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); + ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); + ODPEventManager eventManager = new ODPEventManager(odpConfig, mockApiManager); + eventManager.start(); + for (int i = 0; i < 25; i++) { + eventManager.sendEvent(getEvent(i)); + } + Thread.sleep(500); + Mockito.verify(mockApiManager, times(2)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); + eventManager.updateSettings(new ODPConfig("new-key", "http://www.new-odp-host.com")); + Thread.sleep(1500); + Mockito.verify(mockApiManager, times(1)).sendEvents(eq("new-key"), eq("http://www.new-odp-host.com/v3/events"), any()); + } + + private ODPEvent getEvent(int id) { + Map identifiers = new HashMap<>(); + identifiers.put("identifier1", "value1-" + id); + identifiers.put("identifier2", "value2-" + id); + + Map data = new HashMap<>(); + data.put("data1", "data-value1-" + id); + data.put("data2", id); + + return new ODPEvent("test-type-" + id , "test-action-" + id, identifiers, data); + } +} From 913b8e428e01d6a58a4f09dac9d6092861bcbcbc Mon Sep 17 00:00:00 2001 From: Muhammad Shaharyar Date: Wed, 28 Sep 2022 22:18:37 +0500 Subject: [PATCH 089/147] chore : updated prefix of ticket-check action (#488) --- .github/workflows/ticket_reference_check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ticket_reference_check.yml b/.github/workflows/ticket_reference_check.yml index d2829e0c4..b7d52780f 100644 --- a/.github/workflows/ticket_reference_check.yml +++ b/.github/workflows/ticket_reference_check.yml @@ -13,4 +13,4 @@ jobs: - name: Check for Jira ticket reference uses: optimizely/github-action-ticket-reference-checker-public@master with: - bodyRegex: 'OASIS-(?\d+)' + bodyRegex: 'FSSDK-(?\d+)' From 7428c4c91d25a415c4aa3637040d35a6e520e079 Mon Sep 17 00:00:00 2001 From: Zeeshan Ashraf <35262377+zashraf1985@users.noreply.github.com> Date: Thu, 20 Oct 2022 11:31:21 -0700 Subject: [PATCH 090/147] feat: Added ODPManager implementation (#489) ## Summary Added ODPManager Implementation which does the following. 1. Initializes and provides access to ODPEventManager and ODPSegmentManager 2. Provides updated ODPConfig settings to event manager and segment manager. 3. Stops Event Manager thread when closed. ## Test plan 1. Manually tested thoroughly 2. Added unit tests. ## Issues [FSSDK-8388](https://jira.sso.episerver.net/browse/FSSDK-8388) --- .../java/com/optimizely/ab/odp/ODPConfig.java | 8 ++ .../java/com/optimizely/ab/odp/ODPEvent.java | 18 +++ .../optimizely/ab/odp/ODPEventManager.java | 36 ++++- .../com/optimizely/ab/odp/ODPManager.java | 61 +++++++++ .../optimizely/ab/odp/ODPSegmentManager.java | 10 +- .../ab/odp/ODPEventManagerTest.java | 28 +++- .../com/optimizely/ab/odp/ODPManagerTest.java | 123 ++++++++++++++++++ 7 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java create mode 100644 core-api/src/test/java/com/optimizely/ab/odp/ODPManagerTest.java diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java index ad8667eb4..25402b172 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java @@ -71,4 +71,12 @@ public synchronized List getAllSegments() { public synchronized void setAllSegments(List allSegments) { this.allSegments = allSegments; } + + public Boolean equals(ODPConfig toCompare) { + return getApiHost().equals(toCompare.getApiHost()) && getApiKey().equals(toCompare.getApiKey()) && getAllSegments().equals(toCompare.allSegments); + } + + public synchronized ODPConfig getClone() { + return new ODPConfig(apiKey, apiHost, allSegments); + } } diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java index 903bcf663..de7001ca8 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java @@ -17,6 +17,7 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; +import java.beans.Transient; import java.util.Collections; import java.util.Map; @@ -64,4 +65,21 @@ public Map getData() { public void setData(Map data) { this.data = data; } + + @Transient + public Boolean isDataValid() { + for (Object entry: this.data.values()) { + if ( + !( entry instanceof String + || entry instanceof Integer + || entry instanceof Long + || entry instanceof Boolean + || entry instanceof Float + || entry instanceof Double + || entry == null)) { + return false; + } + } + return true; + } } diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java index 7cc601f29..ab4ce301e 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java @@ -50,7 +50,7 @@ public class ODPEventManager { // The eventQueue needs to be thread safe. We are not doing anything extra for thread safety here // because `LinkedBlockingQueue` itself is thread safe. - private final BlockingQueue eventQueue = new LinkedBlockingQueue<>(); + private final BlockingQueue eventQueue = new LinkedBlockingQueue<>(); public ODPEventManager(@Nonnull ODPConfig odpConfig, @Nonnull ODPApiManager apiManager) { this(odpConfig, apiManager, null, null, null); @@ -71,7 +71,9 @@ public void start() { } public void updateSettings(ODPConfig odpConfig) { - this.odpConfig = odpConfig; + if (!this.odpConfig.equals(odpConfig) && eventQueue.offer(new FlushEvent(this.odpConfig))) { + this.odpConfig = odpConfig; + } } public void identifyUser(@Nullable String vuid, String userId) { @@ -85,6 +87,10 @@ public void identifyUser(@Nullable String vuid, String userId) { } public void sendEvent(ODPEvent event) { + if (!event.isDataValid()) { + logger.error("ODP event send failed (ODP data is not valid)"); + return; + } event.setData(augmentCommonData(event.getData())); processEvent(event); } @@ -137,7 +143,7 @@ private class EventDispatcherThread extends Thread { public void run() { while (true) { try { - ODPEvent nextEvent; + Object nextEvent; // If batch has events, set the timeout to remaining time for flush interval, // otherwise wait for the new event indefinitely @@ -158,12 +164,17 @@ public void run() { continue; } + if (nextEvent instanceof FlushEvent) { + flush(((FlushEvent) nextEvent).getOdpConfig()); + continue; + } + if (currentBatch.size() == 0) { // Batch starting, create a new flush time nextFlushTime = new Date().getTime() + flushInterval; } - currentBatch.add(nextEvent); + currentBatch.add((ODPEvent) nextEvent); if (currentBatch.size() >= batchSize) { flush(); @@ -176,7 +187,7 @@ public void run() { logger.debug("Exiting ODP Event Dispatcher Thread."); } - private void flush() { + private void flush(ODPConfig odpConfig) { if (odpConfig.isReady()) { String payload = ODPJsonSerializerFactory.getSerializer().serializeEvents(currentBatch); String endpoint = odpConfig.getApiHost() + EVENT_URL_PATH; @@ -192,8 +203,23 @@ private void flush() { currentBatch.clear(); } + private void flush() { + flush(odpConfig); + } + public void signalStop() { shouldStop = true; } } + + private static class FlushEvent { + private final ODPConfig odpConfig; + public FlushEvent(ODPConfig odpConfig) { + this.odpConfig = odpConfig.getClone(); + } + + public ODPConfig getOdpConfig() { + return odpConfig; + } + } } diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java new file mode 100644 index 000000000..cb7e04f99 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java @@ -0,0 +1,61 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import javax.annotation.Nonnull; +import java.util.List; + +public class ODPManager { + private volatile ODPConfig odpConfig; + private final ODPSegmentManager segmentManager; + private final ODPEventManager eventManager; + + public ODPManager(@Nonnull ODPConfig odpConfig, @Nonnull ODPApiManager apiManager) { + this(odpConfig, new ODPSegmentManager(odpConfig, apiManager), new ODPEventManager(odpConfig, apiManager)); + } + + public ODPManager(@Nonnull ODPConfig odpConfig, @Nonnull ODPSegmentManager segmentManager, @Nonnull ODPEventManager eventManager) { + this.odpConfig = odpConfig; + this.segmentManager = segmentManager; + this.eventManager = eventManager; + this.eventManager.start(); + } + + public ODPSegmentManager getSegmentManager() { + return segmentManager; + } + + public ODPEventManager getEventManager() { + return eventManager; + } + + public Boolean updateSettings(String apiHost, String apiKey, List allSegments) { + ODPConfig newConfig = new ODPConfig(apiKey, apiHost, allSegments); + if (!odpConfig.equals(newConfig)) { + odpConfig = newConfig; + eventManager.updateSettings(odpConfig); + segmentManager.resetCache(); + segmentManager.updateSettings(odpConfig); + return true; + } + return false; + } + + public void close() { + eventManager.stop(); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java index ffda9c19c..352c4ec8f 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java @@ -34,7 +34,7 @@ public class ODPSegmentManager { private final ODPApiManager apiManager; - private final ODPConfig odpConfig; + private volatile ODPConfig odpConfig; private final Cache> segmentsCache; @@ -105,4 +105,12 @@ public List getQualifiedSegments(ODPUserKey userKey, String userValue, L private String getCacheKey(String userKey, String userValue) { return userKey + "-$-" + userValue; } + + public void updateSettings(ODPConfig odpConfig) { + this.odpConfig = odpConfig; + } + + public void resetCache() { + segmentsCache.reset(); + } } diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java index 7be51e415..fd4287e0f 100644 --- a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java @@ -216,10 +216,36 @@ public void applyUpdatedODPConfigWhenAvailable() throws InterruptedException { Thread.sleep(500); Mockito.verify(mockApiManager, times(2)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); eventManager.updateSettings(new ODPConfig("new-key", "http://www.new-odp-host.com")); - Thread.sleep(1500); + + // Should immediately Flush current batch with old ODP config when settings are changed + Thread.sleep(100); + Mockito.verify(mockApiManager, times(3)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); + + // New events should use new config + for (int i = 0; i < 10; i++) { + eventManager.sendEvent(getEvent(i)); + } + Thread.sleep(100); Mockito.verify(mockApiManager, times(1)).sendEvents(eq("new-key"), eq("http://www.new-odp-host.com/v3/events"), any()); } + @Test + public void validateEventData() { + ODPEvent event = new ODPEvent("type", "action", null, null); + Map data = new HashMap<>(); + + data.put("String", "string Value"); + data.put("Integer", 100); + data.put("Float", 33.89); + data.put("Boolean", true); + data.put("null", null); + event.setData(data); + assertTrue(event.isDataValid()); + + data.put("RandomObject", new Object()); + assertFalse(event.isDataValid()); + } + private ODPEvent getEvent(int id) { Map identifiers = new HashMap<>(); identifiers.put("identifier1", "value1-" + id); diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerTest.java new file mode 100644 index 000000000..924c88836 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerTest.java @@ -0,0 +1,123 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.Mockito; + +import java.util.Arrays; + +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; +import static org.junit.Assert.*; + +public class ODPManagerTest { + private static final String API_RESPONSE = "{\"data\":{\"customer\":{\"audiences\":{\"edges\":[{\"node\":{\"name\":\"segment1\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"segment2\",\"state\":\"qualified\"}}]}}}}"; + + @Mock + ODPApiManager mockApiManager; + + @Mock + ODPEventManager mockEventManager; + + @Mock + ODPSegmentManager mockSegmentManager; + + @Before + public void setup() { + mockApiManager = mock(ODPApiManager.class); + mockEventManager = mock(ODPEventManager.class); + mockSegmentManager = mock(ODPSegmentManager.class); + } + + @Test + public void shouldStartEventManagerWhenODPManagerIsInitialized() { + ODPConfig config = new ODPConfig("test-key", "test-host"); + new ODPManager(config, mockSegmentManager, mockEventManager); + verify(mockEventManager, times(1)).start(); + } + + @Test + public void shouldStopEventManagerWhenCloseIsCalled() { + ODPConfig config = new ODPConfig("test-key", "test-host"); + ODPManager odpManager = new ODPManager(config, mockSegmentManager, mockEventManager); + + // Stop is not called in the default flow. + verify(mockEventManager, times(0)).stop(); + + odpManager.close(); + // stop should be called when odpManager is closed. + verify(mockEventManager, times(1)).stop(); + } + + @Test + public void shouldUseNewSettingsInEventManagerWhenODPConfigIsUpdated() throws InterruptedException { + Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(200); + ODPConfig config = new ODPConfig("test-key", "test-host", Arrays.asList("segment1", "segment2")); + ODPManager odpManager = new ODPManager(config, mockApiManager); + + odpManager.getEventManager().identifyUser("vuid", "fsuid"); + Thread.sleep(2000); + verify(mockApiManager, times(1)) + .sendEvents(eq("test-key"), eq("test-host/v3/events"), any()); + + odpManager.updateSettings("test-host-updated", "test-key-updated", Arrays.asList("segment1")); + odpManager.getEventManager().identifyUser("vuid", "fsuid"); + Thread.sleep(1200); + verify(mockApiManager, times(1)) + .sendEvents(eq("test-key-updated"), eq("test-host-updated/v3/events"), any()); + } + + @Test + public void shouldUseNewSettingsInSegmentManagerWhenODPConfigIsUpdated() { + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anyList())) + .thenReturn(API_RESPONSE); + ODPConfig config = new ODPConfig("test-key", "test-host", Arrays.asList("segment1", "segment2")); + ODPManager odpManager = new ODPManager(config, mockApiManager); + + odpManager.getSegmentManager().getQualifiedSegments("test-id"); + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(eq("test-key"), eq("test-host/v3/graphql"), any(), any(), any()); + + odpManager.updateSettings("test-host-updated", "test-key-updated", Arrays.asList("segment1")); + odpManager.getSegmentManager().getQualifiedSegments("test-id"); + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(eq("test-key-updated"), eq("test-host-updated/v3/graphql"), any(), any(), any()); + } + + @Test + public void shouldGetEventManager() { + ODPConfig config = new ODPConfig("test-key", "test-host"); + ODPManager odpManager = new ODPManager(config, mockSegmentManager, mockEventManager); + assertNotNull(odpManager.getEventManager()); + + odpManager = new ODPManager(config, mockApiManager); + assertNotNull(odpManager.getEventManager()); + } + + @Test + public void shouldGetSegmentManager() { + ODPConfig config = new ODPConfig("test-key", "test-host"); + ODPManager odpManager = new ODPManager(config, mockSegmentManager, mockEventManager); + assertNotNull(odpManager.getSegmentManager()); + + odpManager = new ODPManager(config, mockApiManager); + assertNotNull(odpManager.getSegmentManager()); + } +} From b16dff5f7d958ea36b94e1cfd8d0bdd0b2aae7f8 Mon Sep 17 00:00:00 2001 From: Zeeshan Ashraf <35262377+zashraf1985@users.noreply.github.com> Date: Mon, 21 Nov 2022 11:11:34 -0800 Subject: [PATCH 091/147] feat: Integrated ODPManager with UserContext and Optimizely client (#490) ## Summary This PR integrates ODP functionality with UserContext and Optimizely client to make it available for users. There are two ways to integrate ODPManager. 1. ODPManager.Builder. 2. Use default instance from OptimizelyFactory. ## Test plan 1. Manually tested thoroughly 2. Will add unit tests after the first review passes. ## Issues FSSDK-8389, FS-8683 --- .../java/com/optimizely/ab/Optimizely.java | 94 ++++++- .../optimizely/ab/OptimizelyUserContext.java | 74 +++++- .../ab/config/DatafileProjectConfig.java | 1 + .../optimizely/ab/config/ProjectConfig.java | 3 + .../com/optimizely/ab/odp/ODPApiManager.java | 4 +- .../java/com/optimizely/ab/odp/ODPConfig.java | 12 +- .../java/com/optimizely/ab/odp/ODPEvent.java | 6 +- .../optimizely/ab/odp/ODPEventManager.java | 38 ++- .../com/optimizely/ab/odp/ODPManager.java | 142 +++++++++- .../optimizely/ab/odp/ODPSegmentCallback.java | 22 ++ .../optimizely/ab/odp/ODPSegmentManager.java | 65 ++++- .../optimizely/ab/OptimizelyBuilderTest.java | 18 +- .../com/optimizely/ab/OptimizelyTest.java | 134 +++++++++- .../ab/OptimizelyUserContextTest.java | 130 ++++++++- .../ab/odp/ODPEventManagerTest.java | 27 +- .../ab/odp/ODPManagerBuilderTest.java | 76 ++++++ .../com/optimizely/ab/odp/ODPManagerTest.java | 34 +-- .../ab/odp/ODPSegmentManagerTest.java | 246 ++++++++++++++++-- .../com/optimizely/ab/OptimizelyFactory.java | 9 + .../ab/odp/DefaultODPApiManager.java | 17 +- .../ab/odp/DefaultODPApiManagerTest.java | 19 +- 21 files changed, 1045 insertions(+), 126 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentCallback.java create mode 100644 core-api/src/test/java/com/optimizely/ab/odp/ODPManagerBuilderTest.java diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 51335ec4f..0fbacaa3b 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -28,6 +28,7 @@ import com.optimizely.ab.event.internal.*; import com.optimizely.ab.event.internal.payload.EventBatch; import com.optimizely.ab.notification.*; +import com.optimizely.ab.odp.*; import com.optimizely.ab.optimizelyconfig.OptimizelyConfig; import com.optimizely.ab.optimizelyconfig.OptimizelyConfigManager; import com.optimizely.ab.optimizelyconfig.OptimizelyConfigService; @@ -96,6 +97,9 @@ public class Optimizely implements AutoCloseable { @Nullable private final UserProfileService userProfileService; + @Nullable + private final ODPManager odpManager; + private Optimizely(@Nonnull EventHandler eventHandler, @Nonnull EventProcessor eventProcessor, @Nonnull ErrorHandler errorHandler, @@ -104,7 +108,8 @@ private Optimizely(@Nonnull EventHandler eventHandler, @Nonnull ProjectConfigManager projectConfigManager, @Nullable OptimizelyConfigManager optimizelyConfigManager, @Nonnull NotificationCenter notificationCenter, - @Nonnull List defaultDecideOptions + @Nonnull List defaultDecideOptions, + @Nullable ODPManager odpManager ) { this.eventHandler = eventHandler; this.eventProcessor = eventProcessor; @@ -115,6 +120,15 @@ private Optimizely(@Nonnull EventHandler eventHandler, this.optimizelyConfigManager = optimizelyConfigManager; this.notificationCenter = notificationCenter; this.defaultDecideOptions = defaultDecideOptions; + this.odpManager = odpManager; + + if (odpManager != null) { + odpManager.getEventManager().start(); + if (getProjectConfig() != null) { + updateODPSettings(); + } + addUpdateConfigNotificationHandler(configNotification -> { updateODPSettings(); }); + } } /** @@ -128,8 +142,6 @@ public boolean isValid() { return getProjectConfig() != null; } - - /** * Checks if eventHandler {@link EventHandler} and projectConfigManager {@link ProjectConfigManager} * are Closeable {@link Closeable} and calls close on them. @@ -141,6 +153,9 @@ public void close() { tryClose(eventProcessor); tryClose(eventHandler); tryClose(projectConfigManager); + if (odpManager != null) { + tryClose(odpManager); + } } //======== activate calls ========// @@ -674,9 +689,9 @@ public OptimizelyJSON getFeatureVariableJSON(@Nonnull String featureKey, */ @Nullable public OptimizelyJSON getFeatureVariableJSON(@Nonnull String featureKey, - @Nonnull String variableKey, - @Nonnull String userId, - @Nonnull Map attributes) { + @Nonnull String variableKey, + @Nonnull String userId, + @Nonnull Map attributes) { return getFeatureVariableValueForType( featureKey, @@ -688,10 +703,10 @@ public OptimizelyJSON getFeatureVariableJSON(@Nonnull String featureKey, @VisibleForTesting T getFeatureVariableValueForType(@Nonnull String featureKey, - @Nonnull String variableKey, - @Nonnull String userId, - @Nonnull Map attributes, - @Nonnull String variableType) { + @Nonnull String variableKey, + @Nonnull String userId, + @Nonnull Map attributes, + @Nonnull String variableType) { if (featureKey == null) { logger.warn("The featureKey parameter must be nonnull."); return null; @@ -878,7 +893,7 @@ public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, } } else { logger.info("User \"{}\" was not bucketed into any variation for feature flag \"{}\". " + - "The default values are being returned.", userId, featureKey); + "The default values are being returned.", userId, featureKey); } Map valuesMap = new HashMap(); @@ -1142,7 +1157,7 @@ public OptimizelyConfig getOptimizelyConfig() { * @param userId The user ID to be used for bucketing. * @param attributes: A map of attribute names to current user attribute values. * @return An OptimizelyUserContext associated with this OptimizelyClient. - */ + */ public OptimizelyUserContext createUserContext(@Nonnull String userId, @Nonnull Map attributes) { if (userId == null) { @@ -1413,6 +1428,53 @@ public int addNotificationHandler(Class clazz, NotificationHandler han return notificationCenter.addNotificationHandler(clazz, handler); } + public List fetchQualifiedSegments(String userId, @Nonnull List segmentOptions) { + if (odpManager != null) { + synchronized (odpManager) { + return odpManager.getSegmentManager().getQualifiedSegments(userId, segmentOptions); + } + } + logger.error("Audience segments fetch failed (ODP is not enabled)."); + return null; + } + + public void fetchQualifiedSegments(String userId, ODPSegmentManager.ODPSegmentFetchCallback callback, List segmentOptions) { + if (odpManager == null) { + logger.error("Audience segments fetch failed (ODP is not enabled)."); + callback.onCompleted(null); + } else { + odpManager.getSegmentManager().getQualifiedSegments(userId, callback, segmentOptions); + } + } + + @Nullable + public ODPManager getODPManager() { + return odpManager; + } + + public void sendODPEvent(@Nullable String type, @Nonnull String action, @Nullable Map identifiers, @Nullable Map data) { + if (odpManager != null) { + ODPEvent event = new ODPEvent(type, action, identifiers, data); + odpManager.getEventManager().sendEvent(event); + } else { + logger.error("ODP event send failed (ODP is not enabled)"); + } + } + + public void identifyUser(@Nonnull String userId) { + ODPManager odpManager = getODPManager(); + if (odpManager != null) { + odpManager.getEventManager().identifyUser(userId); + } + } + + private void updateODPSettings() { + if (odpManager != null && getProjectConfig() != null) { + ProjectConfig projectConfig = getProjectConfig(); + odpManager.updateSettings(projectConfig.getHostForODP(), projectConfig.getPublicKeyForODP(), projectConfig.getAllSegments()); + } + } + //======== Builder ========// /** @@ -1467,6 +1529,7 @@ public static class Builder { private UserProfileService userProfileService; private NotificationCenter notificationCenter; private List defaultDecideOptions; + private ODPManager odpManager; // For backwards compatibility private AtomicProjectConfigManager fallbackConfigManager = new AtomicProjectConfigManager(); @@ -1562,6 +1625,11 @@ public Builder withDefaultDecideOptions(List defaultDeci return this; } + public Builder withODPManager(ODPManager odpManager) { + this.odpManager = odpManager; + return this; + } + // Helper functions for making testing easier protected Builder withBucketing(Bucketer bucketer) { this.bucketer = bucketer; @@ -1636,7 +1704,7 @@ public Optimizely build() { defaultDecideOptions = Collections.emptyList(); } - return new Optimizely(eventHandler, eventProcessor, errorHandler, decisionService, userProfileService, projectConfigManager, optimizelyConfigManager, notificationCenter, defaultDecideOptions); + return new Optimizely(eventHandler, eventProcessor, errorHandler, decisionService, userProfileService, projectConfigManager, optimizelyConfigManager, notificationCenter, defaultDecideOptions, odpManager); } } } diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java index e59c4f3aa..5ba15b5b4 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -16,7 +16,9 @@ */ package com.optimizely.ab; -import com.optimizely.ab.config.Variation; +import com.optimizely.ab.odp.ODPManager; +import com.optimizely.ab.odp.ODPSegmentCallback; +import com.optimizely.ab.odp.ODPSegmentOption; import com.optimizely.ab.optimizelydecision.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -54,6 +56,15 @@ public OptimizelyUserContext(@Nonnull Optimizely optimizely, @Nonnull Map attributes, @Nullable Map forcedDecisionsMap, @Nullable List qualifiedSegments) { + this(optimizely, userId, attributes, forcedDecisionsMap, qualifiedSegments, true); + } + + public OptimizelyUserContext(@Nonnull Optimizely optimizely, + @Nonnull String userId, + @Nonnull Map attributes, + @Nullable Map forcedDecisionsMap, + @Nullable List qualifiedSegments, + @Nullable Boolean shouldIdentifyUser) { this.optimizely = optimizely; this.userId = userId; if (attributes != null) { @@ -66,6 +77,10 @@ public OptimizelyUserContext(@Nonnull Optimizely optimizely, } this.qualifiedSegments = Collections.synchronizedList( qualifiedSegments == null ? new LinkedList<>(): qualifiedSegments); + + if (shouldIdentifyUser == null || shouldIdentifyUser) { + optimizely.identifyUser(userId); + } } public OptimizelyUserContext(@Nonnull Optimizely optimizely, @Nonnull String userId) { @@ -85,7 +100,7 @@ public Optimizely getOptimizely() { } public OptimizelyUserContext copy() { - return new OptimizelyUserContext(optimizely, userId, attributes, forcedDecisionsMap, qualifiedSegments); + return new OptimizelyUserContext(optimizely, userId, attributes, forcedDecisionsMap, qualifiedSegments, false); } /** @@ -282,6 +297,60 @@ public void setQualifiedSegments(List qualifiedSegments) { this.qualifiedSegments.addAll(qualifiedSegments); } + /** + * Fetch all qualified segments for the user context. + *

      + * The segments fetched will be saved and can be accessed at any time by calling {@link #getQualifiedSegments()}. + */ + public Boolean fetchQualifiedSegments() { + return fetchQualifiedSegments(Collections.emptyList()); + } + + /** + * Fetch all qualified segments for the user context. + *

      + * The segments fetched will be saved and can be accessed at any time by calling {@link #getQualifiedSegments()}. + * + * @param segmentOptions A set of options for fetching qualified segments. + */ + public Boolean fetchQualifiedSegments(@Nonnull List segmentOptions) { + List segments = optimizely.fetchQualifiedSegments(userId, segmentOptions); + if (segments != null) { + setQualifiedSegments(segments); + } + return segments != null; + } + + /** + * Fetch all qualified segments for the user context in a non-blocking manner. This method will fetch segments + * in a separate thread and invoke the provided callback when results are available. + *

      + * The segments fetched will be saved and can be accessed at any time by calling {@link #getQualifiedSegments()}. + * + * @param callback A callback to invoke when results are available. + * @param segmentOptions A set of options for fetching qualified segments. + */ + public void fetchQualifiedSegments(ODPSegmentCallback callback, List segmentOptions) { + optimizely.fetchQualifiedSegments(userId, segments -> { + if (segments != null) { + setQualifiedSegments(segments); + } + callback.onCompleted(segments != null); + }, segmentOptions); + } + + /** + * Fetch all qualified segments for the user context in a non-blocking manner. This method will fetch segments + * in a separate thread and invoke the provided callback when results are available. + *

      + * The segments fetched will be saved and can be accessed at any time by calling {@link #getQualifiedSegments()}. + * + * @param callback A callback to invoke when results are available. + */ + public void fetchQualifiedSegments(ODPSegmentCallback callback) { + fetchQualifiedSegments(callback, Collections.emptyList()); + } + // Utils @Override @@ -309,5 +378,4 @@ public String toString() { ", attributes='" + attributes + '\'' + '}'; } - } diff --git a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java index 94341c717..28ad519a5 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java @@ -434,6 +434,7 @@ public List getExperiments() { return experiments; } + @Override public Set getAllSegments() { return this.allSegments; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java index be512bd04..2073be9ef 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java @@ -24,6 +24,7 @@ import javax.annotation.Nullable; import java.util.List; import java.util.Map; +import java.util.Set; /** * ProjectConfig is an interface capturing the experiment, variation and feature definitions. @@ -69,6 +70,8 @@ Experiment getExperimentForKey(@Nonnull String experimentKey, List getExperiments(); + Set getAllSegments(); + List getExperimentsForEventKey(String eventKey); List getFeatureFlags(); diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java index dee9413dd..6385d2b7b 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java @@ -15,10 +15,10 @@ */ package com.optimizely.ab.odp; -import java.util.List; +import java.util.Set; public interface ODPApiManager { - String fetchQualifiedSegments(String apiKey, String apiEndpoint, String userKey, String userValue, List segmentsToCheck); + String fetchQualifiedSegments(String apiKey, String apiEndpoint, String userKey, String userValue, Set segmentsToCheck); Integer sendEvents(String apiKey, String apiEndpoint, String eventPayload); } diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java index 25402b172..eb055e63f 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java @@ -17,7 +17,7 @@ package com.optimizely.ab.odp; import java.util.Collections; -import java.util.List; +import java.util.Set; public class ODPConfig { @@ -25,16 +25,16 @@ public class ODPConfig { private String apiHost; - private List allSegments; + private Set allSegments; - public ODPConfig(String apiKey, String apiHost, List allSegments) { + public ODPConfig(String apiKey, String apiHost, Set allSegments) { this.apiKey = apiKey; this.apiHost = apiHost; this.allSegments = allSegments; } public ODPConfig(String apiKey, String apiHost) { - this(apiKey, apiHost, Collections.emptyList()); + this(apiKey, apiHost, Collections.emptySet()); } public synchronized Boolean isReady() { @@ -64,11 +64,11 @@ public synchronized String getApiHost() { return apiHost; } - public synchronized List getAllSegments() { + public synchronized Set getAllSegments() { return allSegments; } - public synchronized void setAllSegments(List allSegments) { + public synchronized void setAllSegments(Set allSegments) { this.allSegments = allSegments; } diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java index de7001ca8..a4dd37f2b 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java @@ -22,13 +22,15 @@ import java.util.Map; public class ODPEvent { + public static final String EVENT_TYPE_FULLSTACK = "fullstack"; + private String type; private String action; private Map identifiers; private Map data; - public ODPEvent(@Nonnull String type, @Nonnull String action, @Nullable Map identifiers, @Nullable Map data) { - this.type = type; + public ODPEvent(@Nullable String type, @Nonnull String action, @Nullable Map identifiers, @Nullable Map data) { + this.type = type == null ? EVENT_TYPE_FULLSTACK : type; this.action = action; this.identifiers = identifiers != null ? identifiers : Collections.emptyMap(); this.data = data != null ? data : Collections.emptyMap(); diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java index ab4ce301e..b2a9a658b 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java @@ -52,12 +52,11 @@ public class ODPEventManager { // because `LinkedBlockingQueue` itself is thread safe. private final BlockingQueue eventQueue = new LinkedBlockingQueue<>(); - public ODPEventManager(@Nonnull ODPConfig odpConfig, @Nonnull ODPApiManager apiManager) { - this(odpConfig, apiManager, null, null, null); + public ODPEventManager(@Nonnull ODPApiManager apiManager) { + this(apiManager, null, null, null); } - public ODPEventManager(@Nonnull ODPConfig odpConfig, @Nonnull ODPApiManager apiManager, @Nullable Integer batchSize, @Nullable Integer queueSize, @Nullable Integer flushInterval) { - this.odpConfig = odpConfig; + public ODPEventManager(@Nonnull ODPApiManager apiManager, @Nullable Integer batchSize, @Nullable Integer queueSize, @Nullable Integer flushInterval) { this.apiManager = apiManager; this.batchSize = (batchSize != null && batchSize > 1) ? batchSize : DEFAULT_BATCH_SIZE; this.queueSize = queueSize != null ? queueSize : DEFAULT_QUEUE_SIZE; @@ -65,23 +64,33 @@ public ODPEventManager(@Nonnull ODPConfig odpConfig, @Nonnull ODPApiManager apiM } public void start() { + if (eventDispatcherThread == null) { + eventDispatcherThread = new EventDispatcherThread(); + } + if (!isRunning) { + eventDispatcherThread.start(); + } isRunning = true; - eventDispatcherThread = new EventDispatcherThread(); - eventDispatcherThread.start(); } - public void updateSettings(ODPConfig odpConfig) { - if (!this.odpConfig.equals(odpConfig) && eventQueue.offer(new FlushEvent(this.odpConfig))) { - this.odpConfig = odpConfig; + public void updateSettings(ODPConfig newConfig) { + if (odpConfig == null || (!odpConfig.equals(newConfig) && eventQueue.offer(new FlushEvent(odpConfig)))) { + odpConfig = newConfig; } } - public void identifyUser(@Nullable String vuid, String userId) { + public void identifyUser(String userId) { + identifyUser(null, userId); + } + + public void identifyUser(@Nullable String vuid, @Nullable String userId) { Map identifiers = new HashMap<>(); if (vuid != null) { identifiers.put(ODPUserKey.VUID.getKeyString(), vuid); } - identifiers.put(ODPUserKey.FS_USER_ID.getKeyString(), userId); + if (userId != null) { + identifiers.put(ODPUserKey.FS_USER_ID.getKeyString(), userId); + } ODPEvent event = new ODPEvent("fullstack", "client_initialized", identifiers, null); sendEvent(event); } @@ -111,7 +120,7 @@ private void processEvent(ODPEvent event) { return; } - if (!odpConfig.isReady()) { + if (odpConfig == null || !odpConfig.isReady()) { logger.debug("Unable to Process ODP Event. ODPConfig is not ready."); return; } @@ -184,10 +193,15 @@ public void run() { } } + isRunning = false; logger.debug("Exiting ODP Event Dispatcher Thread."); } private void flush(ODPConfig odpConfig) { + if (currentBatch.size() == 0) { + return; + } + if (odpConfig.isReady()) { String payload = ODPJsonSerializerFactory.getSerializer().serializeEvents(currentBatch); String endpoint = odpConfig.getApiHost() + EVENT_URL_PATH; diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java index cb7e04f99..3e198a209 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java @@ -16,20 +16,26 @@ */ package com.optimizely.ab.odp; +import com.optimizely.ab.internal.Cache; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import javax.annotation.Nonnull; import java.util.List; +import java.util.Set; + +public class ODPManager implements AutoCloseable { + private static final Logger logger = LoggerFactory.getLogger(ODPManager.class); -public class ODPManager { private volatile ODPConfig odpConfig; private final ODPSegmentManager segmentManager; private final ODPEventManager eventManager; - public ODPManager(@Nonnull ODPConfig odpConfig, @Nonnull ODPApiManager apiManager) { - this(odpConfig, new ODPSegmentManager(odpConfig, apiManager), new ODPEventManager(odpConfig, apiManager)); + private ODPManager(@Nonnull ODPApiManager apiManager) { + this(new ODPSegmentManager(apiManager), new ODPEventManager(apiManager)); } - public ODPManager(@Nonnull ODPConfig odpConfig, @Nonnull ODPSegmentManager segmentManager, @Nonnull ODPEventManager eventManager) { - this.odpConfig = odpConfig; + private ODPManager(@Nonnull ODPSegmentManager segmentManager, @Nonnull ODPEventManager eventManager) { this.segmentManager = segmentManager; this.eventManager = eventManager; this.eventManager.start(); @@ -43,9 +49,10 @@ public ODPEventManager getEventManager() { return eventManager; } - public Boolean updateSettings(String apiHost, String apiKey, List allSegments) { + public Boolean updateSettings(String apiHost, String apiKey, Set allSegments) { ODPConfig newConfig = new ODPConfig(apiKey, apiHost, allSegments); - if (!odpConfig.equals(newConfig)) { + if (odpConfig == null || !odpConfig.equals(newConfig)) { + logger.debug("Updating ODP Config"); odpConfig = newConfig; eventManager.updateSettings(odpConfig); segmentManager.resetCache(); @@ -58,4 +65,125 @@ public Boolean updateSettings(String apiHost, String apiKey, List allSeg public void close() { eventManager.stop(); } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private ODPSegmentManager segmentManager; + private ODPEventManager eventManager; + private ODPApiManager apiManager; + private Integer cacheSize; + private Integer cacheTimeoutSeconds; + private Cache> cacheImpl; + + /** + * Provide a custom {@link ODPManager} instance which makes http calls to fetch segments and send events. + * + * A Default ODPApiManager is available in `core-httpclient-impl` package. + * + * @param apiManager The implementation of {@link ODPManager} + * @return ODPManager builder + */ + public Builder withApiManager(ODPApiManager apiManager) { + this.apiManager = apiManager; + return this; + } + + /** + * Provide an optional custom {@link ODPSegmentManager} instance. + * + * A Default {@link ODPSegmentManager} implementation is automatically used if none provided. + * + * @param segmentManager The implementation of {@link ODPSegmentManager} + * @return ODPManager builder + */ + public Builder withSegmentManager(ODPSegmentManager segmentManager) { + this.segmentManager = segmentManager; + return this; + } + + /** + * Provide an optional custom {@link ODPEventManager} instance. + * + * A Default {@link ODPEventManager} implementation is automatically used if none provided. + * + * @param eventManager The implementation of {@link ODPEventManager} + * @return ODPManager builder + */ + public Builder withEventManager(ODPEventManager eventManager) { + this.eventManager = eventManager; + return this; + } + + /** + * Provide an optional custom cache size + * + * A Default cache size is automatically used if none provided. + * + * @param cacheSize Custom cache size to be used. + * @return ODPManager builder + */ + public Builder withSegmentCacheSize(Integer cacheSize) { + this.cacheSize = cacheSize; + return this; + } + + /** + * Provide an optional custom cache timeout. + * + * A Default cache timeout is automatically used if none provided. + * + * @param cacheTimeoutSeconds Custom cache timeout in seconds. + * @return ODPManager builder + */ + public Builder withSegmentCacheTimeout(Integer cacheTimeoutSeconds) { + this.cacheTimeoutSeconds = cacheTimeoutSeconds; + return this; + } + + /** + * Provide an optional custom Segment Cache implementation. + * + * A Default LRU Cache implementation is automatically used if none provided. + * + * @param cacheImpl Customer Cache Implementation. + * @return ODPManager builder + */ + public Builder withSegmentCache(Cache> cacheImpl) { + this.cacheImpl = cacheImpl; + return this; + } + + public ODPManager build() { + if ((segmentManager == null || eventManager == null) && apiManager == null) { + logger.warn("ApiManager instance is needed when using default EventManager or SegmentManager"); + return null; + } + + if (segmentManager == null) { + if (cacheImpl != null) { + segmentManager = new ODPSegmentManager(apiManager, cacheImpl); + } else if (cacheSize != null || cacheTimeoutSeconds != null) { + // Converting null to -1 so that DefaultCache uses the default values; + if (cacheSize == null) { + cacheSize = -1; + } + if (cacheTimeoutSeconds == null) { + cacheTimeoutSeconds = -1; + } + segmentManager = new ODPSegmentManager(apiManager, cacheSize, cacheTimeoutSeconds); + } else { + segmentManager = new ODPSegmentManager(apiManager); + } + } + + if (eventManager == null) { + eventManager = new ODPEventManager(apiManager); + } + + return new ODPManager(segmentManager, eventManager); + } + } } diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentCallback.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentCallback.java new file mode 100644 index 000000000..57bc5097a --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentCallback.java @@ -0,0 +1,22 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +@FunctionalInterface +public interface ODPSegmentCallback { + void onCompleted(Boolean isFetchSuccessful); +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java index 352c4ec8f..90a36fa5d 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java @@ -38,19 +38,17 @@ public class ODPSegmentManager { private final Cache> segmentsCache; - public ODPSegmentManager(ODPConfig odpConfig, ODPApiManager apiManager) { - this(odpConfig, apiManager, Cache.DEFAULT_MAX_SIZE, Cache.DEFAULT_TIMEOUT_SECONDS); + public ODPSegmentManager(ODPApiManager apiManager) { + this(apiManager, Cache.DEFAULT_MAX_SIZE, Cache.DEFAULT_TIMEOUT_SECONDS); } - public ODPSegmentManager(ODPConfig odpConfig, ODPApiManager apiManager, Cache> cache) { + public ODPSegmentManager(ODPApiManager apiManager, Cache> cache) { this.apiManager = apiManager; - this.odpConfig = odpConfig; this.segmentsCache = cache; } - public ODPSegmentManager(ODPConfig odpConfig, ODPApiManager apiManager, Integer cacheSize, Integer cacheTimeoutSeconds) { + public ODPSegmentManager(ODPApiManager apiManager, Integer cacheSize, Integer cacheTimeoutSeconds) { this.apiManager = apiManager; - this.odpConfig = odpConfig; this.segmentsCache = new DefaultLRUCache<>(cacheSize, cacheTimeoutSeconds); } @@ -66,9 +64,9 @@ public List getQualifiedSegments(ODPUserKey userKey, String userValue) { } public List getQualifiedSegments(ODPUserKey userKey, String userValue, List options) { - if (!odpConfig.isReady()) { + if (odpConfig == null || !odpConfig.isReady()) { logger.error("Audience segments fetch failed (ODP is not enabled)"); - return Collections.emptyList(); + return null; } if (!odpConfig.hasSegments()) { @@ -93,7 +91,13 @@ public List getQualifiedSegments(ODPUserKey userKey, String userValue, L ResponseJsonParser parser = ResponseJsonParserFactory.getParser(); String qualifiedSegmentsResponse = apiManager.fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + SEGMENT_URL_PATH, userKey.getKeyString(), userValue, odpConfig.getAllSegments()); - qualifiedSegments = parser.parseQualifiedSegments(qualifiedSegmentsResponse); + try { + qualifiedSegments = parser.parseQualifiedSegments(qualifiedSegmentsResponse); + } catch (Exception e) { + logger.error("Audience segments fetch failed (Error Parsing Response)"); + logger.debug(e.getMessage()); + qualifiedSegments = null; + } if (qualifiedSegments != null && !options.contains(ODPSegmentOption.IGNORE_CACHE)) { segmentsCache.save(cacheKey, qualifiedSegments); @@ -102,6 +106,23 @@ public List getQualifiedSegments(ODPUserKey userKey, String userValue, L return qualifiedSegments; } + public void getQualifiedSegments(ODPUserKey userKey, String userValue, ODPSegmentFetchCallback callback, List options) { + AsyncSegmentFetcher segmentFetcher = new AsyncSegmentFetcher(userKey, userValue, options, callback); + segmentFetcher.start(); + } + + public void getQualifiedSegments(ODPUserKey userKey, String userValue, ODPSegmentFetchCallback callback) { + getQualifiedSegments(userKey, userValue, callback, Collections.emptyList()); + } + + public void getQualifiedSegments(String fsUserId, ODPSegmentFetchCallback callback, List segmentOptions) { + getQualifiedSegments(ODPUserKey.FS_USER_ID, fsUserId, callback, segmentOptions); + } + + public void getQualifiedSegments(String fsUserId, ODPSegmentFetchCallback callback) { + getQualifiedSegments(ODPUserKey.FS_USER_ID, fsUserId, callback, Collections.emptyList()); + } + private String getCacheKey(String userKey, String userValue) { return userKey + "-$-" + userValue; } @@ -113,4 +134,30 @@ public void updateSettings(ODPConfig odpConfig) { public void resetCache() { segmentsCache.reset(); } + + @FunctionalInterface + public interface ODPSegmentFetchCallback { + void onCompleted(List segments); + } + + private class AsyncSegmentFetcher extends Thread { + + private final ODPUserKey userKey; + private final String userValue; + private final List segmentOptions; + private final ODPSegmentFetchCallback callback; + + public AsyncSegmentFetcher(ODPUserKey userKey, String userValue, List segmentOptions, ODPSegmentFetchCallback callback) { + this.userKey = userKey; + this.userValue = userValue; + this.segmentOptions = segmentOptions; + this.callback = callback; + } + + @Override + public void run() { + List segments = getQualifiedSegments(userKey, userValue, segmentOptions); + callback.onCompleted(segments); + } + } } diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java index 91bb19e18..79382a5b7 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java @@ -20,11 +20,12 @@ import com.optimizely.ab.config.*; import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.error.NoOpErrorHandler; -import com.optimizely.ab.event.BatchEventProcessor; import com.optimizely.ab.event.EventHandler; import com.optimizely.ab.event.LogEvent; import com.optimizely.ab.event.internal.BuildVersionInfo; import com.optimizely.ab.event.internal.payload.EventBatch; +import com.optimizely.ab.odp.ODPEventManager; +import com.optimizely.ab.odp.ODPManager; import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Rule; @@ -32,6 +33,7 @@ import org.junit.rules.ExpectedException; import org.mockito.ArgumentCaptor; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; @@ -43,10 +45,7 @@ import static org.hamcrest.CoreMatchers.instanceOf; import static org.hamcrest.CoreMatchers.is; import static org.junit.Assert.*; -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.anyString; import static org.mockito.Mockito.*; -import static org.mockito.Mockito.never; /** * Tests for {@link Optimizely#builder(String, EventHandler)}. @@ -244,4 +243,15 @@ public void withClientInfo() throws Exception { assertEquals(argument.getValue().getEventBatch().getClientVersion(), "1.2.3"); } + @Test + public void withODPManager() { + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely optimizely = Optimizely.builder() + .withODPManager(mockODPManager) + .build(); + assertEquals(mockODPManager, optimizely.getODPManager()); + } } diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java index 2cab4a01e..9fd3dd675 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -34,6 +34,9 @@ import com.optimizely.ab.internal.ControlAttribute; import com.optimizely.ab.internal.LogbackVerifier; import com.optimizely.ab.notification.*; +import com.optimizely.ab.odp.ODPEvent; +import com.optimizely.ab.odp.ODPEventManager; +import com.optimizely.ab.odp.ODPManager; import com.optimizely.ab.optimizelydecision.DecisionResponse; import com.optimizely.ab.optimizelyjson.OptimizelyJSON; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; @@ -44,7 +47,9 @@ import org.junit.rules.RuleChain; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; @@ -179,10 +184,21 @@ public void testClose() throws Exception { withSettings().extraInterfaces(AutoCloseable.class) ); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + Mockito.doNothing().when(mockODPEventManager).sendEvent(any()); + + ODPManager mockODPManager = mock( + ODPManager.class, + withSettings().extraInterfaces(AutoCloseable.class) + ); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely optimizely = Optimizely.builder() .withEventHandler(mockEventHandler) .withEventProcessor(mockEventProcessor) .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) .build(); optimizely.close(); @@ -190,7 +206,7 @@ public void testClose() throws Exception { verify((AutoCloseable) mockEventHandler).close(); verify((AutoCloseable) mockProjectConfigManager).close(); verify((AutoCloseable) mockEventProcessor).close(); - + verify((AutoCloseable) mockODPManager).close(); } //======== activate tests ========// @@ -4666,4 +4682,120 @@ public void getFlagVariationByKey() throws IOException { assertEquals(variationKey, variation.getKey()); } + @Test + public void initODPManagerWithoutProjectConfig() { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + verify(mockODPEventManager).start(); + verify(mockODPManager, never()).updateSettings(any(), any(), any()); + } + + @Test + public void initODPManagerWithProjectConfig() throws IOException { + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely.builder() + .withDatafile(validConfigJsonV4()) + .withODPManager(mockODPManager) + .build(); + + verify(mockODPEventManager).start(); + verify(mockODPManager, times(1)).updateSettings(any(), any(), any()); + } + + @Test + public void updateODPManagerWhenConfigUpdates() throws IOException { + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + NotificationCenter mockNotificationCenter = mock(NotificationCenter.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely.builder() + .withDatafile(validConfigJsonV4()) + .withNotificationCenter(mockNotificationCenter) + .withODPManager(mockODPManager) + .build(); + + verify(mockODPManager, times(1)).updateSettings(any(), any(), any()); + + Mockito.verify(mockNotificationCenter, times(1)).addNotificationHandler(any(), any()); + } + + @Test + public void sendODPEvent() { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + verify(mockODPEventManager).start(); + + Map identifiers = new HashMap<>(); + identifiers.put("id1", "value1"); + identifiers.put("id2", "value2"); + + Map data = new HashMap<>(); + data.put("sdk", "java"); + data.put("revision", 52); + + optimizely.sendODPEvent("fullstack", "identify", identifiers, data); + ArgumentCaptor eventArgument = ArgumentCaptor.forClass(ODPEvent.class); + verify(mockODPEventManager).sendEvent(eventArgument.capture()); + + assertEquals("fullstack", eventArgument.getValue().getType()); + assertEquals("identify", eventArgument.getValue().getAction()); + assertEquals(identifiers, eventArgument.getValue().getIdentifiers()); + assertEquals(data, eventArgument.getValue().getData()); + } + + @Test + public void sendODPEventError() { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .build(); + + Map identifiers = new HashMap<>(); + identifiers.put("id1", "value1"); + identifiers.put("id2", "value2"); + + Map data = new HashMap<>(); + data.put("sdk", "java"); + data.put("revision", 52); + + optimizely.sendODPEvent("fullstack", "identify", identifiers, data); + logbackVerifier.expectMessage(Level.ERROR, "ODP event send failed (ODP is not enabled)"); + } + + @Test + public void identifyUser() { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + optimizely.identifyUser("the-user"); + Mockito.verify(mockODPEventManager, times(1)).identifyUser("the-user"); + } } diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java index c196938c4..5dd47a8cf 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -16,6 +16,7 @@ */ package com.optimizely.ab; +import ch.qos.logback.classic.Level; import com.google.common.base.Charsets; import com.google.common.io.Resources; import com.optimizely.ab.bucketing.FeatureDecision; @@ -24,9 +25,10 @@ import com.optimizely.ab.config.parser.ConfigParseException; import com.optimizely.ab.event.ForwardingEventProcessor; import com.optimizely.ab.event.internal.payload.DecisionMetadata; +import com.optimizely.ab.internal.LogbackVerifier; import com.optimizely.ab.notification.NotificationCenter; +import com.optimizely.ab.odp.*; import com.optimizely.ab.optimizelydecision.DecisionMessage; -import com.optimizely.ab.optimizelydecision.DecisionResponse; import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import com.optimizely.ab.optimizelydecision.OptimizelyDecision; import com.optimizely.ab.optimizelyjson.OptimizelyJSON; @@ -35,8 +37,10 @@ import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.mockito.Mockito; import java.util.*; +import java.util.concurrent.CountDownLatch; import static com.optimizely.ab.config.ValidProjectConfigV4.ATTRIBUTE_HOUSE_KEY; import static com.optimizely.ab.config.ValidProjectConfigV4.AUDIENCE_GRYFFINDOR_VALUE; @@ -51,6 +55,9 @@ public class OptimizelyUserContextTest { @Rule public EventHandlerRule eventHandler = new EventHandlerRule(); + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + String userId = "tester"; boolean isListenerCalled = false; @@ -1610,6 +1617,127 @@ public void setForcedDecisionsAndCallDecideDeliveryRuleToDecision() { )); } /********************************************[END DECIDE TESTS WITH FDs]******************************************/ + + @Test + public void fetchQualifiedSegments() { + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPSegmentManager mockODPSegmentManager = mock(ODPSegmentManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Mockito.when(mockODPManager.getSegmentManager()).thenReturn(mockODPSegmentManager); + + Optimizely optimizely = Optimizely.builder() + .withODPManager(mockODPManager) + .build(); + + OptimizelyUserContext userContext = optimizely.createUserContext("test-user"); + + assertTrue(userContext.fetchQualifiedSegments()); + verify(mockODPSegmentManager).getQualifiedSegments("test-user", Collections.emptyList()); + + assertTrue(userContext.fetchQualifiedSegments(Collections.singletonList(ODPSegmentOption.RESET_CACHE))); + verify(mockODPSegmentManager).getQualifiedSegments("test-user", Collections.singletonList(ODPSegmentOption.RESET_CACHE)); + } + + @Test + public void fetchQualifiedSegmentsError() { + Optimizely optimizely = Optimizely.builder() + .build(); + OptimizelyUserContext userContext = optimizely.createUserContext("test-user"); + + assertFalse(userContext.fetchQualifiedSegments()); + logbackVerifier.expectMessage(Level.ERROR, "Audience segments fetch failed (ODP is not enabled)."); + } + + @Test + public void fetchQualifiedSegmentsAsync() throws InterruptedException { + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPSegmentManager mockODPSegmentManager = mock(ODPSegmentManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + doAnswer( + invocation -> { + ODPSegmentManager.ODPSegmentFetchCallback callback = invocation.getArgumentAt(1, ODPSegmentManager.ODPSegmentFetchCallback.class); + callback.onCompleted(Arrays.asList("segment1", "segment2")); + return null; + } + ).when(mockODPSegmentManager).getQualifiedSegments(any(), (ODPSegmentManager.ODPSegmentFetchCallback) any(), any()); + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Mockito.when(mockODPManager.getSegmentManager()).thenReturn(mockODPSegmentManager); + + Optimizely optimizely = Optimizely.builder() + .withODPManager(mockODPManager) + .build(); + + OptimizelyUserContext userContext = optimizely.createUserContext("test-user"); + + CountDownLatch countDownLatch = new CountDownLatch(1); + userContext.fetchQualifiedSegments((Boolean isFetchSuccessful) -> { + assertTrue(isFetchSuccessful); + countDownLatch.countDown(); + }); + + countDownLatch.await(); + verify(mockODPSegmentManager).getQualifiedSegments(eq("test-user"), any(ODPSegmentManager.ODPSegmentFetchCallback.class), eq(Collections.emptyList())); + assertEquals(Arrays.asList("segment1", "segment2"), userContext.getQualifiedSegments()); + + // reset qualified segments + userContext.setQualifiedSegments(Collections.emptyList()); + CountDownLatch countDownLatch2 = new CountDownLatch(1); + userContext.fetchQualifiedSegments((Boolean isFetchSuccessful) -> { + assertTrue(isFetchSuccessful); + countDownLatch2.countDown(); + }, Collections.singletonList(ODPSegmentOption.RESET_CACHE)); + + countDownLatch2.await(); + verify(mockODPSegmentManager).getQualifiedSegments(eq("test-user"), any(ODPSegmentManager.ODPSegmentFetchCallback.class), eq(Collections.singletonList(ODPSegmentOption.RESET_CACHE))); + assertEquals(Arrays.asList("segment1", "segment2"), userContext.getQualifiedSegments()); + } + + @Test + public void fetchQualifiedSegmentsAsyncError() throws InterruptedException { + Optimizely optimizely = Optimizely.builder() + .build(); + + OptimizelyUserContext userContext = optimizely.createUserContext("test-user"); + + CountDownLatch countDownLatch = new CountDownLatch(1); + userContext.fetchQualifiedSegments((Boolean isFetchSuccessful) -> { + assertFalse(isFetchSuccessful); + countDownLatch.countDown(); + }); + + countDownLatch.await(); + assertEquals(Collections.emptyList(), userContext.getQualifiedSegments()); + logbackVerifier.expectMessage(Level.ERROR, "Audience segments fetch failed (ODP is not enabled)."); + } + + @Test + public void identifyUser() { + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPSegmentManager mockODPSegmentManager = mock(ODPSegmentManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Mockito.when(mockODPManager.getSegmentManager()).thenReturn(mockODPSegmentManager); + + Optimizely optimizely = Optimizely.builder() + .withODPManager(mockODPManager) + .build(); + + OptimizelyUserContext userContext = optimizely.createUserContext("test-user"); + verify(mockODPEventManager).identifyUser("test-user"); + + Mockito.reset(mockODPEventManager); + OptimizelyUserContext userContextClone = userContext.copy(); + + // identifyUser should not be called the new userContext is created through copy + verify(mockODPEventManager, never()).identifyUser("test-user"); + + assertNotSame(userContextClone, userContext); + } + // utils Map createUserProfileMap(String experimentId, String variationId) { diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java index fd4287e0f..d6941671f 100644 --- a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java @@ -59,7 +59,8 @@ public void setup() { @Test public void logAndDiscardEventWhenEventManagerIsNotRunning() { ODPConfig odpConfig = new ODPConfig("key", "host", null); - ODPEventManager eventManager = new ODPEventManager(odpConfig, mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.updateSettings(odpConfig); ODPEvent event = new ODPEvent("test-type", "test-action", Collections.emptyMap(), Collections.emptyMap()); eventManager.sendEvent(event); logbackVerifier.expectMessage(Level.WARN, "Failed to Process ODP Event. ODPEventManager is not running"); @@ -68,7 +69,8 @@ public void logAndDiscardEventWhenEventManagerIsNotRunning() { @Test public void logAndDiscardEventWhenODPConfigNotReady() { ODPConfig odpConfig = new ODPConfig(null, null, null); - ODPEventManager eventManager = new ODPEventManager(odpConfig, mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.updateSettings(odpConfig); eventManager.start(); ODPEvent event = new ODPEvent("test-type", "test-action", Collections.emptyMap(), Collections.emptyMap()); eventManager.sendEvent(event); @@ -80,7 +82,8 @@ public void dispatchEventsInCorrectNumberOfBatches() throws InterruptedException Mockito.reset(mockApiManager); Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); - ODPEventManager eventManager = new ODPEventManager(odpConfig, mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.updateSettings(odpConfig); eventManager.start(); for (int i = 0; i < 25; i++) { eventManager.sendEvent(getEvent(i)); @@ -95,7 +98,8 @@ public void dispatchEventsWithCorrectPayload() throws InterruptedException { Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); int batchSize = 2; ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); - ODPEventManager eventManager = new ODPEventManager(odpConfig, mockApiManager, batchSize, null, null); + ODPEventManager eventManager = new ODPEventManager(mockApiManager, batchSize, null, null); + eventManager.updateSettings(odpConfig); eventManager.start(); for (int i = 0; i < 6; i++) { eventManager.sendEvent(getEvent(i)); @@ -126,7 +130,8 @@ public void dispatchEventsWithCorrectFlushInterval() throws InterruptedException Mockito.reset(mockApiManager); Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); - ODPEventManager eventManager = new ODPEventManager(odpConfig, mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.updateSettings(odpConfig); eventManager.start(); for (int i = 0; i < 25; i++) { eventManager.sendEvent(getEvent(i)); @@ -144,7 +149,8 @@ public void retryFailedEvents() throws InterruptedException { Mockito.reset(mockApiManager); Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(500); ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); - ODPEventManager eventManager = new ODPEventManager(odpConfig, mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.updateSettings(odpConfig); eventManager.start(); for (int i = 0; i < 25; i++) { eventManager.sendEvent(getEvent(i)); @@ -164,7 +170,8 @@ public void shouldFlushAllScheduledEventsBeforeStopping() throws InterruptedExce Mockito.reset(mockApiManager); Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); - ODPEventManager eventManager = new ODPEventManager(odpConfig, mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.updateSettings(odpConfig); eventManager.start(); for (int i = 0; i < 25; i++) { eventManager.sendEvent(getEvent(i)); @@ -181,7 +188,8 @@ public void prepareCorrectPayloadForIdentifyUser() throws InterruptedException { Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); int batchSize = 2; ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); - ODPEventManager eventManager = new ODPEventManager(odpConfig, mockApiManager, batchSize, null, null); + ODPEventManager eventManager = new ODPEventManager(mockApiManager, batchSize, null, null); + eventManager.updateSettings(odpConfig); eventManager.start(); for (int i = 0; i < 2; i++) { eventManager.identifyUser("the-vuid-" + i, "the-fs-user-id-" + i); @@ -208,7 +216,8 @@ public void applyUpdatedODPConfigWhenAvailable() throws InterruptedException { Mockito.reset(mockApiManager); Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); - ODPEventManager eventManager = new ODPEventManager(odpConfig, mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.updateSettings(odpConfig); eventManager.start(); for (int i = 0; i < 25; i++) { eventManager.sendEvent(getEvent(i)); diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerBuilderTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerBuilderTest.java new file mode 100644 index 000000000..0f7f59ae9 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerBuilderTest.java @@ -0,0 +1,76 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import com.optimizely.ab.internal.Cache; +import org.junit.Test; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; + +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; +import static org.junit.Assert.*; + +public class ODPManagerBuilderTest { + + @Test + public void withApiManager() { + ODPApiManager mockApiManager = mock(ODPApiManager.class); + ODPManager odpManager = ODPManager.builder().withApiManager(mockApiManager).build(); + odpManager.updateSettings("test-host", "test-key", new HashSet<>(Arrays.asList("Segment-1", "Segment-2"))); + odpManager.getSegmentManager().getQualifiedSegments("test-user"); + verify(mockApiManager).fetchQualifiedSegments(any(), any(), any(), any(), any()); + } + + @Test + public void withSegmentManager() { + ODPSegmentManager mockSegmentManager = mock(ODPSegmentManager.class); + ODPEventManager mockEventManager = mock(ODPEventManager.class); + ODPManager odpManager = ODPManager.builder() + .withSegmentManager(mockSegmentManager) + .withEventManager(mockEventManager) + .build(); + assertSame(mockSegmentManager, odpManager.getSegmentManager()); + } + + @Test + public void withEventManager() { + ODPSegmentManager mockSegmentManager = mock(ODPSegmentManager.class); + ODPEventManager mockEventManager = mock(ODPEventManager.class); + ODPManager odpManager = ODPManager.builder() + .withSegmentManager(mockSegmentManager) + .withEventManager(mockEventManager) + .build(); + assertSame(mockEventManager, odpManager.getEventManager()); + } + + @Test + public void withSegmentCache() { + Cache> mockCache = mock(Cache.class); + ODPApiManager mockApiManager = mock(ODPApiManager.class); + ODPManager odpManager = ODPManager.builder() + .withApiManager(mockApiManager) + .withSegmentCache(mockCache) + .build(); + + odpManager.updateSettings("test-host", "test-key", new HashSet<>(Arrays.asList("Segment-1", "Segment-2"))); + odpManager.getSegmentManager().getQualifiedSegments("test-user"); + verify(mockCache).lookup("fs_user_id-$-test-user"); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerTest.java index 924c88836..02abe88a1 100644 --- a/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerTest.java @@ -22,6 +22,8 @@ import org.mockito.Mockito; import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; import static org.mockito.Matchers.*; import static org.mockito.Mockito.*; @@ -48,15 +50,15 @@ public void setup() { @Test public void shouldStartEventManagerWhenODPManagerIsInitialized() { - ODPConfig config = new ODPConfig("test-key", "test-host"); - new ODPManager(config, mockSegmentManager, mockEventManager); + ODPManager.builder().withSegmentManager(mockSegmentManager).withEventManager(mockEventManager).build(); + verify(mockEventManager, times(1)).start(); } @Test public void shouldStopEventManagerWhenCloseIsCalled() { - ODPConfig config = new ODPConfig("test-key", "test-host"); - ODPManager odpManager = new ODPManager(config, mockSegmentManager, mockEventManager); + ODPManager odpManager = ODPManager.builder().withSegmentManager(mockSegmentManager).withEventManager(mockEventManager).build(); + odpManager.updateSettings("test-key", "test-host", Collections.emptySet()); // Stop is not called in the default flow. verify(mockEventManager, times(0)).stop(); @@ -69,15 +71,15 @@ public void shouldStopEventManagerWhenCloseIsCalled() { @Test public void shouldUseNewSettingsInEventManagerWhenODPConfigIsUpdated() throws InterruptedException { Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(200); - ODPConfig config = new ODPConfig("test-key", "test-host", Arrays.asList("segment1", "segment2")); - ODPManager odpManager = new ODPManager(config, mockApiManager); + ODPManager odpManager = ODPManager.builder().withApiManager(mockApiManager).build(); + odpManager.updateSettings("test-host", "test-key", new HashSet<>(Arrays.asList("segment1", "segment2"))); odpManager.getEventManager().identifyUser("vuid", "fsuid"); Thread.sleep(2000); verify(mockApiManager, times(1)) .sendEvents(eq("test-key"), eq("test-host/v3/events"), any()); - odpManager.updateSettings("test-host-updated", "test-key-updated", Arrays.asList("segment1")); + odpManager.updateSettings("test-host-updated", "test-key-updated", new HashSet<>(Arrays.asList("segment1"))); odpManager.getEventManager().identifyUser("vuid", "fsuid"); Thread.sleep(1200); verify(mockApiManager, times(1)) @@ -86,16 +88,16 @@ public void shouldUseNewSettingsInEventManagerWhenODPConfigIsUpdated() throws In @Test public void shouldUseNewSettingsInSegmentManagerWhenODPConfigIsUpdated() { - Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anyList())) + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anySet())) .thenReturn(API_RESPONSE); - ODPConfig config = new ODPConfig("test-key", "test-host", Arrays.asList("segment1", "segment2")); - ODPManager odpManager = new ODPManager(config, mockApiManager); + ODPManager odpManager = ODPManager.builder().withApiManager(mockApiManager).build(); + odpManager.updateSettings("test-host", "test-key", new HashSet<>(Arrays.asList("segment1", "segment2"))); odpManager.getSegmentManager().getQualifiedSegments("test-id"); verify(mockApiManager, times(1)) .fetchQualifiedSegments(eq("test-key"), eq("test-host/v3/graphql"), any(), any(), any()); - odpManager.updateSettings("test-host-updated", "test-key-updated", Arrays.asList("segment1")); + odpManager.updateSettings("test-host-updated", "test-key-updated", new HashSet<>(Arrays.asList("segment1"))); odpManager.getSegmentManager().getQualifiedSegments("test-id"); verify(mockApiManager, times(1)) .fetchQualifiedSegments(eq("test-key-updated"), eq("test-host-updated/v3/graphql"), any(), any(), any()); @@ -103,21 +105,19 @@ public void shouldUseNewSettingsInSegmentManagerWhenODPConfigIsUpdated() { @Test public void shouldGetEventManager() { - ODPConfig config = new ODPConfig("test-key", "test-host"); - ODPManager odpManager = new ODPManager(config, mockSegmentManager, mockEventManager); + ODPManager odpManager = ODPManager.builder().withSegmentManager(mockSegmentManager).withEventManager(mockEventManager).build(); assertNotNull(odpManager.getEventManager()); - odpManager = new ODPManager(config, mockApiManager); + odpManager = ODPManager.builder().withApiManager(mockApiManager).build(); assertNotNull(odpManager.getEventManager()); } @Test public void shouldGetSegmentManager() { - ODPConfig config = new ODPConfig("test-key", "test-host"); - ODPManager odpManager = new ODPManager(config, mockSegmentManager, mockEventManager); + ODPManager odpManager = ODPManager.builder().withSegmentManager(mockSegmentManager).withEventManager(mockEventManager).build(); assertNotNull(odpManager.getSegmentManager()); - odpManager = new ODPManager(config, mockApiManager); + odpManager = ODPManager.builder().withApiManager(mockApiManager).build(); assertNotNull(odpManager.getSegmentManager()); } } diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java index f784d53d0..4d34d49b9 100644 --- a/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java @@ -29,9 +29,8 @@ import static org.mockito.Mockito.*; import static org.junit.Assert.*; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; +import java.util.*; +import java.util.concurrent.CountDownLatch; public class ODPSegmentManagerTest { @@ -56,8 +55,9 @@ public void setup() { public void cacheHit() { Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); - ODPConfig odpConfig = new ODPConfig("testKey", "testHost", Arrays.asList("segment1", "segment2")); - ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); List segments = segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId"); // Cache lookup called with correct key @@ -76,11 +76,12 @@ public void cacheHit() { @Test public void cacheMiss() { Mockito.when(mockCache.lookup(any())).thenReturn(null); - Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anyList())) + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anySet())) .thenReturn(API_RESPONSE); - ODPConfig odpConfig = new ODPConfig("testKey", "testHost", Arrays.asList("segment1", "segment2")); - ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); List segments = segmentManager.getQualifiedSegments(ODPUserKey.VUID, "testId"); // Cache lookup called with correct key @@ -88,7 +89,7 @@ public void cacheMiss() { // Cache miss! Make api call and save to cache verify(mockApiManager, times(1)) - .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "vuid", "testId", Arrays.asList("segment1", "segment2")); + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "vuid", "testId", new HashSet<>(Arrays.asList("segment1", "segment2"))); verify(mockCache, times(1)).save("vuid-$-testId", Arrays.asList("segment1", "segment2")); verify(mockCache, times(0)).reset(); @@ -100,11 +101,12 @@ public void cacheMiss() { @Test public void ignoreCache() { Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); - Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anyList())) + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anySet())) .thenReturn(API_RESPONSE); - ODPConfig odpConfig = new ODPConfig("testKey", "testHost", Arrays.asList("segment1", "segment2")); - ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); List segments = segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", Collections.singletonList(ODPSegmentOption.IGNORE_CACHE)); // Cache Ignored! lookup should not be called @@ -112,7 +114,7 @@ public void ignoreCache() { // Cache Ignored! Make API Call but do NOT save because of cacheIgnore verify(mockApiManager, times(1)) - .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", Arrays.asList("segment1", "segment2")); + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", new HashSet<>(Arrays.asList("segment1", "segment2"))); verify(mockCache, times(0)).save(any(), any()); verify(mockCache, times(0)).reset(); @@ -122,22 +124,23 @@ public void ignoreCache() { @Test public void resetCache() { Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); - Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anyList())) + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anySet())) .thenReturn(API_RESPONSE); - ODPConfig odpConfig = new ODPConfig("testKey", "testHost", Arrays.asList("segment1", "segment2")); - ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); List segments = segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", Collections.singletonList(ODPSegmentOption.RESET_CACHE)); // Call reset verify(mockCache, times(1)).reset(); - // Cache Reset! lookup should not be called becaues cache would be empty. + // Cache Reset! lookup should not be called because cache would be empty. verify(mockCache, times(0)).lookup(any()); // Cache reset but not Ignored! Make API Call and save to cache verify(mockApiManager, times(1)) - .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", Arrays.asList("segment1", "segment2")); + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", new HashSet<>(Arrays.asList("segment1", "segment2"))); verify(mockCache, times(1)).save("fs_user_id-$-testId", Arrays.asList("segment1", "segment2")); assertEquals(Arrays.asList("segment1", "segment2"), segments); @@ -146,11 +149,12 @@ public void resetCache() { @Test public void resetAndIgnoreCache() { Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); - Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anyList())) + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anySet())) .thenReturn(API_RESPONSE); - ODPConfig odpConfig = new ODPConfig("testKey", "testHost", Arrays.asList("segment1", "segment2")); - ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); List segments = segmentManager .getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", Arrays.asList(ODPSegmentOption.RESET_CACHE, ODPSegmentOption.IGNORE_CACHE)); @@ -161,7 +165,7 @@ public void resetAndIgnoreCache() { // Cache is also Ignored! Make API Call but do not save verify(mockApiManager, times(1)) - .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", Arrays.asList("segment1", "segment2")); + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", new HashSet<>(Arrays.asList("segment1", "segment2"))); verify(mockCache, times(0)).save(any(), any()); assertEquals(Arrays.asList("segment1", "segment2"), segments); @@ -171,8 +175,9 @@ public void resetAndIgnoreCache() { public void odpConfigNotReady() { Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); - ODPConfig odpConfig = new ODPConfig(null, null, Arrays.asList("segment1", "segment2")); - ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + ODPConfig odpConfig = new ODPConfig(null, null, new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); List segments = segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId"); // No further methods should be called. @@ -183,7 +188,7 @@ public void odpConfigNotReady() { logbackVerifier.expectMessage(Level.ERROR, "Audience segments fetch failed (ODP is not enabled)"); - assertEquals(Collections.emptyList(), segments); + assertNull(segments); } @Test @@ -191,7 +196,8 @@ public void noSegmentsInProject() { Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); ODPConfig odpConfig = new ODPConfig("testKey", "testHost", null); - ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); List segments = segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId"); // No further methods should be called. @@ -204,4 +210,192 @@ public void noSegmentsInProject() { assertEquals(Collections.emptyList(), segments); } + + // Tests for Async version + + @Test + public void cacheHitAsync() throws InterruptedException { + CountDownLatch countDownLatch = new CountDownLatch(1); + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", (segments) -> { + assertEquals(Arrays.asList("segment1-cached", "segment2-cached"), segments); + countDownLatch.countDown(); + }); + + countDownLatch.await(); + + // Cache lookup called with correct key + verify(mockCache, times(1)).lookup("fs_user_id-$-testId"); + + // Cache hit! No api call was made to the server. + verify(mockApiManager, times(0)).fetchQualifiedSegments(any(), any(), any(), any(), any()); + verify(mockCache, times(0)).save(any(), any()); + verify(mockCache, times(0)).reset(); + + logbackVerifier.expectMessage(Level.DEBUG, "ODP Cache Hit. Returning segments from Cache."); + } + + @Test + public void cacheMissAsync() throws InterruptedException { + CountDownLatch countDownLatch = new CountDownLatch(1); + Mockito.when(mockCache.lookup(any())).thenReturn(null); + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anySet())) + .thenReturn(API_RESPONSE); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + segmentManager.getQualifiedSegments(ODPUserKey.VUID, "testId", (segments) -> { + assertEquals(Arrays.asList("segment1", "segment2"), segments); + countDownLatch.countDown(); + }); + + countDownLatch.await(); + + // Cache lookup called with correct key + verify(mockCache, times(1)).lookup("vuid-$-testId"); + + // Cache miss! Make api call and save to cache + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "vuid", "testId", new HashSet<>(Arrays.asList("segment1", "segment2"))); + verify(mockCache, times(1)).save("vuid-$-testId", Arrays.asList("segment1", "segment2")); + verify(mockCache, times(0)).reset(); + + logbackVerifier.expectMessage(Level.DEBUG, "ODP Cache Miss. Making a call to ODP Server."); + } + + @Test + public void ignoreCacheAsync() throws InterruptedException { + CountDownLatch countDownLatch = new CountDownLatch(1); + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anySet())) + .thenReturn(API_RESPONSE); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", segments -> { + assertEquals(Arrays.asList("segment1", "segment2"), segments); + countDownLatch.countDown(); + }, Collections.singletonList(ODPSegmentOption.IGNORE_CACHE)); + + countDownLatch.await(); + + // Cache Ignored! lookup should not be called + verify(mockCache, times(0)).lookup(any()); + + // Cache Ignored! Make API Call but do NOT save because of cacheIgnore + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", new HashSet<>(Arrays.asList("segment1", "segment2"))); + verify(mockCache, times(0)).save(any(), any()); + verify(mockCache, times(0)).reset(); + } + + @Test + public void resetCacheAsync() throws InterruptedException { + CountDownLatch countDownLatch = new CountDownLatch(1); + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anySet())) + .thenReturn(API_RESPONSE); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", segments -> { + assertEquals(Arrays.asList("segment1", "segment2"), segments); + countDownLatch.countDown(); + }, Collections.singletonList(ODPSegmentOption.RESET_CACHE)); + + countDownLatch.await(); + + // Call reset + verify(mockCache, times(1)).reset(); + + // Cache Reset! lookup should not be called because cache would be empty. + verify(mockCache, times(0)).lookup(any()); + + // Cache reset but not Ignored! Make API Call and save to cache + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", new HashSet<>(Arrays.asList("segment1", "segment2"))); + verify(mockCache, times(1)).save("fs_user_id-$-testId", Arrays.asList("segment1", "segment2")); + } + + @Test + public void resetAndIgnoreCacheAsync() throws InterruptedException { + CountDownLatch countDownLatch = new CountDownLatch(1); + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anySet())) + .thenReturn(API_RESPONSE); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", segments -> { + assertEquals(Arrays.asList("segment1", "segment2"), segments); + countDownLatch.countDown(); + }, Arrays.asList(ODPSegmentOption.RESET_CACHE, ODPSegmentOption.IGNORE_CACHE)); + + countDownLatch.await(); + + // Call reset + verify(mockCache, times(1)).reset(); + + verify(mockCache, times(0)).lookup(any()); + + // Cache is also Ignored! Make API Call but do not save + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", new HashSet<>(Arrays.asList("segment1", "segment2"))); + verify(mockCache, times(0)).save(any(), any()); + } + + @Test + public void odpConfigNotReadyAsync() throws InterruptedException { + CountDownLatch countDownLatch = new CountDownLatch(1); + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + + ODPConfig odpConfig = new ODPConfig(null, null, new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", segments -> { + assertNull(segments); + countDownLatch.countDown(); + }); + + countDownLatch.await(); + // No further methods should be called. + verify(mockCache, times(0)).lookup("fs_user_id-$-testId"); + verify(mockApiManager, times(0)).fetchQualifiedSegments(any(), any(), any(), any(), any()); + verify(mockCache, times(0)).save(any(), any()); + verify(mockCache, times(0)).reset(); + + logbackVerifier.expectMessage(Level.ERROR, "Audience segments fetch failed (ODP is not enabled)"); + } + + @Test + public void noSegmentsInProjectAsync() throws InterruptedException { + CountDownLatch countDownLatch = new CountDownLatch(1); + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", null); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", segments -> { + assertEquals(Collections.emptyList(), segments); + countDownLatch.countDown(); + }); + + countDownLatch.await(); + + // No further methods should be called. + verify(mockCache, times(0)).lookup("fs_user_id-$-testId"); + verify(mockApiManager, times(0)).fetchQualifiedSegments(any(), any(), any(), any(), any()); + verify(mockCache, times(0)).save(any(), any()); + verify(mockCache, times(0)).reset(); + + logbackVerifier.expectMessage(Level.DEBUG, "No Segments are used in the project, Not Fetching segments. Returning empty list"); + } } diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java index 2e888e9bb..37d56da03 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java @@ -23,6 +23,9 @@ import com.optimizely.ab.event.EventHandler; import com.optimizely.ab.internal.PropertyUtils; import com.optimizely.ab.notification.NotificationCenter; +import com.optimizely.ab.odp.DefaultODPApiManager; +import com.optimizely.ab.odp.ODPApiManager; +import com.optimizely.ab.odp.ODPManager; import org.apache.http.impl.client.CloseableHttpClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -319,10 +322,16 @@ public static Optimizely newDefaultInstance(ProjectConfigManager configManager, .withNotificationCenter(notificationCenter) .build(); + ODPApiManager defaultODPApiManager = new DefaultODPApiManager(); + ODPManager odpManager = ODPManager.builder() + .withApiManager(defaultODPApiManager) + .build(); + return Optimizely.builder() .withEventProcessor(eventProcessor) .withConfigManager(configManager) .withNotificationCenter(notificationCenter) + .withODPManager(odpManager) .build(); } } diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java index 07ee1b3eb..576701066 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java @@ -27,7 +27,8 @@ import java.io.IOException; import java.io.UnsupportedEncodingException; -import java.util.List; +import java.util.Iterator; +import java.util.Set; public class DefaultODPApiManager implements ODPApiManager { private static final Logger logger = LoggerFactory.getLogger(DefaultODPApiManager.class); @@ -44,13 +45,15 @@ public DefaultODPApiManager() { } @VisibleForTesting - String getSegmentsStringForRequest(List segmentsList) { + String getSegmentsStringForRequest(Set segmentsList) { + StringBuilder segmentsString = new StringBuilder(); + Iterator segmentsListIterator = segmentsList.iterator(); for (int i = 0; i < segmentsList.size(); i++) { if (i > 0) { segmentsString.append(", "); } - segmentsString.append("\\\"").append(segmentsList.get(i)).append("\\\""); + segmentsString.append("\"").append(segmentsListIterator.next()).append("\""); } return segmentsString.toString(); } @@ -129,10 +132,14 @@ String getSegmentsStringForRequest(List segmentsList) { } */ @Override - public String fetchQualifiedSegments(String apiKey, String apiEndpoint, String userKey, String userValue, List segmentsToCheck) { + public String fetchQualifiedSegments(String apiKey, String apiEndpoint, String userKey, String userValue, Set segmentsToCheck) { HttpPost request = new HttpPost(apiEndpoint); String segmentsString = getSegmentsStringForRequest(segmentsToCheck); - String requestPayload = String.format("{\"query\": \"query {customer(%s: \\\"%s\\\") {audiences(subset: [%s]) {edges {node {name state}}}}}\"}", userKey, userValue, segmentsString); + + String query = String.format("query($userId: String, $audiences: [String]) {customer(%s: $userId) {audiences(subset: $audiences) {edges {node {name state}}}}}", userKey); + String variables = String.format("{\"userId\": \"%s\", \"audiences\": [%s]}", userValue, segmentsString); + String requestPayload = String.format("{\"query\": \"%s\", \"variables\": %s}", query, variables); + try { request.setEntity(new StringEntity(requestPayload)); } catch (UnsupportedEncodingException e) { diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java index 77440f804..638a3e1ee 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java @@ -30,6 +30,7 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import static org.junit.Assert.*; import static org.mockito.Matchers.any; @@ -64,33 +65,33 @@ private void setupHttpClient(int statusCode) throws Exception { @Test public void generateCorrectSegmentsStringWhenListHasOneItem() { DefaultODPApiManager apiManager = new DefaultODPApiManager(); - String expected = "\\\"only_segment\\\""; - String actual = apiManager.getSegmentsStringForRequest(Arrays.asList("only_segment")); + String expected = "\"only_segment\""; + String actual = apiManager.getSegmentsStringForRequest(new HashSet<>(Arrays.asList("only_segment"))); assertEquals(expected, actual); } @Test public void generateCorrectSegmentsStringWhenListHasMultipleItems() { DefaultODPApiManager apiManager = new DefaultODPApiManager(); - String expected = "\\\"segment_1\\\", \\\"segment_2\\\", \\\"segment_3\\\""; - String actual = apiManager.getSegmentsStringForRequest(Arrays.asList("segment_1", "segment_2", "segment_3")); + String expected = "\"segment_1\", \"segment_3\", \"segment_2\""; + String actual = apiManager.getSegmentsStringForRequest(new HashSet<>(Arrays.asList("segment_1", "segment_2", "segment_3"))); assertEquals(expected, actual); } @Test public void generateEmptyStringWhenGivenListIsEmpty() { DefaultODPApiManager apiManager = new DefaultODPApiManager(); - String actual = apiManager.getSegmentsStringForRequest(new ArrayList<>()); + String actual = apiManager.getSegmentsStringForRequest(new HashSet<>()); assertEquals("", actual); } @Test public void generateCorrectRequestBody() throws Exception { ODPApiManager apiManager = new DefaultODPApiManager(mockHttpClient); - apiManager.fetchQualifiedSegments("key", "endPoint", "fs_user_id", "test_user", Arrays.asList("segment_1", "segment_2")); + apiManager.fetchQualifiedSegments("key", "endPoint", "fs_user_id", "test_user", new HashSet<>(Arrays.asList("segment_1", "segment_2"))); verify(mockHttpClient, times(1)).execute(any(HttpPost.class)); - String expectedResponse = "{\"query\": \"query {customer(fs_user_id: \\\"test_user\\\") {audiences(subset: [\\\"segment_1\\\", \\\"segment_2\\\"]) {edges {node {name state}}}}}\"}"; + String expectedResponse = "{\"query\": \"query($userId: String, $audiences: [String]) {customer(fs_user_id: $userId) {audiences(subset: $audiences) {edges {node {name state}}}}}\", \"variables\": {\"userId\": \"test_user\", \"audiences\": [\"segment_1\", \"segment_2\"]}}"; ArgumentCaptor request = ArgumentCaptor.forClass(HttpPost.class); verify(mockHttpClient).execute(request.capture()); assertEquals(expectedResponse, EntityUtils.toString(request.getValue().getEntity())); @@ -99,7 +100,7 @@ public void generateCorrectRequestBody() throws Exception { @Test public void returnResponseStringWhenStatusIs200() throws Exception { ODPApiManager apiManager = new DefaultODPApiManager(mockHttpClient); - String responseString = apiManager.fetchQualifiedSegments("key", "endPoint", "fs_user_id", "test_user", Arrays.asList("segment_1", "segment_2")); + String responseString = apiManager.fetchQualifiedSegments("key", "endPoint", "fs_user_id", "test_user", new HashSet<>(Arrays.asList("segment_1", "segment_2"))); verify(mockHttpClient, times(1)).execute(any(HttpPost.class)); assertEquals(validResponse, responseString); } @@ -108,7 +109,7 @@ public void returnResponseStringWhenStatusIs200() throws Exception { public void returnNullWhenStatusIsNot200AndLogError() throws Exception { setupHttpClient(500); ODPApiManager apiManager = new DefaultODPApiManager(mockHttpClient); - String responseString = apiManager.fetchQualifiedSegments("key", "endPoint", "fs_user_id", "test_user", Arrays.asList("segment_1", "segment_2")); + String responseString = apiManager.fetchQualifiedSegments("key", "endPoint", "fs_user_id", "test_user", new HashSet<>(Arrays.asList("segment_1", "segment_2"))); verify(mockHttpClient, times(1)).execute(any(HttpPost.class)); logbackVerifier.expectMessage(Level.ERROR, "Unexpected response from ODP server, Response code: 500, null"); assertNull(responseString); From 42039e22f8ce497afb1f483ad90109c976443e9a Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Tue, 29 Nov 2022 16:12:00 -0800 Subject: [PATCH 092/147] skip unit and fsc tests for snapshot and publish (#493) --- .github/workflows/java.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index af0dccf0a..1c6c57a02 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -30,12 +30,14 @@ jobs: run: find . -type f -name '*.md' -exec awesome_bot {} \; integration_tests: + if: ${{ startsWith(github.ref, 'refs/tags/') != true && github.event.inputs.SNAPSHOT != 'true' }} uses: optimizely/java-sdk/.github/workflows/integration_test.yml@mnoman/fsc-gitaction-test secrets: CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }} TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} fullstack_production_suite: + if: ${{ startsWith(github.ref, 'refs/tags/') != true && github.event.inputs.SNAPSHOT != 'true' }} uses: optimizely/java-sdk/.github/workflows/integration_test.yml@master with: FULLSTACK_TEST_REPO: ProdTesting @@ -44,7 +46,7 @@ jobs: TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} test: - if: startsWith(github.ref, 'refs/tags/') != true + if: ${{ startsWith(github.ref, 'refs/tags/') != true && github.event.inputs.SNAPSHOT != 'true' }} runs-on: macos-latest strategy: fail-fast: false From c68a55420331e6e1cc66319631551c03d9df243d Mon Sep 17 00:00:00 2001 From: Zeeshan Ashraf <35262377+zashraf1985@users.noreply.github.com> Date: Wed, 14 Dec 2022 19:59:27 -0800 Subject: [PATCH 093/147] feat: Added configurable timeouts for fetchSegments and dispatch events ODP calls (#494) ## Summary Added configurable timeouts for fetchSegments and dispatch events ODP calls. The default timeout is 10s. ## Test plan - Manually tested thoroughly - All test pass ## Issues [FSSDK-8689](https://jira.sso.episerver.net/browse/FSSDK-8689) --- .../com/optimizely/ab/HttpClientUtils.java | 19 +++++++++++++++--- .../optimizely/ab/OptimizelyHttpClient.java | 16 +++++++++++++-- .../ab/odp/DefaultODPApiManager.java | 20 +++++++++++++++---- .../ab/odp/DefaultODPApiManagerTest.java | 17 +++++++++++++++- 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/HttpClientUtils.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/HttpClientUtils.java index 8a4d104d5..c35bb4b3f 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/HttpClientUtils.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/HttpClientUtils.java @@ -23,9 +23,11 @@ */ public final class HttpClientUtils { - private static final int CONNECTION_TIMEOUT_MS = 10000; - private static final int CONNECTION_REQUEST_TIMEOUT_MS = 5000; - private static final int SOCKET_TIMEOUT_MS = 10000; + public static final int CONNECTION_TIMEOUT_MS = 10000; + public static final int CONNECTION_REQUEST_TIMEOUT_MS = 5000; + public static final int SOCKET_TIMEOUT_MS = 10000; + + private static RequestConfig requestConfigWithTimeout; private HttpClientUtils() { } @@ -36,6 +38,17 @@ private HttpClientUtils() { .setSocketTimeout(SOCKET_TIMEOUT_MS) .build(); + public static RequestConfig getDefaultRequestConfigWithTimeout(int timeoutMillis) { + if (requestConfigWithTimeout == null) { + requestConfigWithTimeout = RequestConfig.custom() + .setConnectTimeout(timeoutMillis) + .setConnectionRequestTimeout(CONNECTION_REQUEST_TIMEOUT_MS) + .setSocketTimeout(SOCKET_TIMEOUT_MS) + .build(); + } + return requestConfigWithTimeout; + } + public static OptimizelyHttpClient getDefaultHttpClient() { return OptimizelyHttpClient.builder().build(); } diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyHttpClient.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyHttpClient.java index 37c2163ac..f4040276f 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyHttpClient.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyHttpClient.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019, Optimizely + * Copyright 2019, 2022 Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,6 +25,8 @@ import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.impl.client.HttpClients; import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.Closeable; import java.io.IOException; @@ -38,6 +40,8 @@ */ public class OptimizelyHttpClient implements Closeable { + private static final Logger logger = LoggerFactory.getLogger(OptimizelyHttpClient.class); + private final CloseableHttpClient httpClient; OptimizelyHttpClient(CloseableHttpClient httpClient) { @@ -78,6 +82,7 @@ public static class Builder { // force-close the connection after this idle time (with 0, eviction is disabled by default) long evictConnectionIdleTimePeriod = 0; TimeUnit evictConnectionIdleTimeUnit = TimeUnit.MILLISECONDS; + private int timeoutMillis = HttpClientUtils.CONNECTION_TIMEOUT_MS; private Builder() { @@ -103,6 +108,11 @@ public Builder withEvictIdleConnections(long maxIdleTime, TimeUnit maxIdleTimeUn this.evictConnectionIdleTimeUnit = maxIdleTimeUnit; return this; } + + public Builder setTimeoutMillis(int timeoutMillis) { + this.timeoutMillis = timeoutMillis; + return this; + } public OptimizelyHttpClient build() { PoolingHttpClientConnectionManager poolingHttpClientConnectionManager = new PoolingHttpClientConnectionManager(); @@ -111,11 +121,13 @@ public OptimizelyHttpClient build() { poolingHttpClientConnectionManager.setValidateAfterInactivity(validateAfterInactivity); HttpClientBuilder builder = HttpClients.custom() - .setDefaultRequestConfig(HttpClientUtils.DEFAULT_REQUEST_CONFIG) + .setDefaultRequestConfig(HttpClientUtils.getDefaultRequestConfigWithTimeout(timeoutMillis)) .setConnectionManager(poolingHttpClientConnectionManager) .disableCookieManagement() .useSystemProperties(); + logger.debug("Creating HttpClient with timeout: " + timeoutMillis); + if (evictConnectionIdleTimePeriod > 0) { builder.evictIdleConnections(evictConnectionIdleTimePeriod, evictConnectionIdleTimeUnit); } diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java index 576701066..636ed8eec 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java @@ -33,15 +33,27 @@ public class DefaultODPApiManager implements ODPApiManager { private static final Logger logger = LoggerFactory.getLogger(DefaultODPApiManager.class); - private final OptimizelyHttpClient httpClient; + private final OptimizelyHttpClient httpClientSegments; + private final OptimizelyHttpClient httpClientEvents; public DefaultODPApiManager() { this(OptimizelyHttpClient.builder().build()); } + public DefaultODPApiManager(int segmentFetchTimeoutMillis, int eventDispatchTimeoutMillis) { + httpClientSegments = OptimizelyHttpClient.builder().setTimeoutMillis(segmentFetchTimeoutMillis).build(); + if (segmentFetchTimeoutMillis == eventDispatchTimeoutMillis) { + // If the timeouts are same, single httpClient can be used for both. + httpClientEvents = httpClientSegments; + } else { + httpClientEvents = OptimizelyHttpClient.builder().setTimeoutMillis(eventDispatchTimeoutMillis).build(); + } + } + @VisibleForTesting DefaultODPApiManager(OptimizelyHttpClient httpClient) { - this.httpClient = httpClient; + this.httpClientSegments = httpClient; + this.httpClientEvents = httpClient; } @VisibleForTesting @@ -150,7 +162,7 @@ public String fetchQualifiedSegments(String apiKey, String apiEndpoint, String u CloseableHttpResponse response = null; try { - response = httpClient.execute(request); + response = httpClientSegments.execute(request); } catch (IOException e) { logger.error("Error retrieving response from ODP service", e); return null; @@ -211,7 +223,7 @@ public Integer sendEvents(String apiKey, String apiEndpoint, String eventPayload CloseableHttpResponse response = null; try { - response = httpClient.execute(request); + response = httpClientEvents.execute(request); } catch (IOException e) { logger.error("Error retrieving response from event request", e); return 0; diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java index 638a3e1ee..93b728fba 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java @@ -28,7 +28,6 @@ import org.junit.Test; import org.mockito.ArgumentCaptor; -import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; @@ -129,4 +128,20 @@ public void eventDispatchFailStatus() throws Exception { apiManager.sendEvents("testKey", "testEndpoint", "[]]"); logbackVerifier.expectMessage(Level.ERROR, "ODP event send failed (Response code: 400, null)"); } + + @Test + public void apiTimeouts() { + // Default timeout is 10 seconds + new DefaultODPApiManager(); + logbackVerifier.expectMessage(Level.DEBUG, "Creating HttpClient with timeout: 10000", 1); + + // Same timeouts result in single httpclient + new DefaultODPApiManager(2222, 2222); + logbackVerifier.expectMessage(Level.DEBUG, "Creating HttpClient with timeout: 2222", 1); + + // Different timeouts result in different HttpClients + new DefaultODPApiManager(3333, 4444); + logbackVerifier.expectMessage(Level.DEBUG, "Creating HttpClient with timeout: 3333", 1); + logbackVerifier.expectMessage(Level.DEBUG, "Creating HttpClient with timeout: 4444", 1); + } } From 3c14dde2d728a0a6b7f5bb89b429305d7a5e75e4 Mon Sep 17 00:00:00 2001 From: Zeeshan Ashraf <35262377+zashraf1985@users.noreply.github.com> Date: Tue, 20 Dec 2022 18:54:50 -0800 Subject: [PATCH 094/147] fix: Fixed ODP Event type and Json Parser log levels (#495) ## Summary 1. Changed default event type from `client_initialized` to `identified`. 2. Changed Parser log levels from `info` to `debug`. ## Test plan - Manually tested thoroughly. - All existing unit tests and FSC test pass. ## Issues https://jira.sso.episerver.net/browse/FSSDK-8725 https://jira.sso.episerver.net/browse/FSSDK-8726 --- .../java/com/optimizely/ab/internal/JsonParserProvider.java | 2 +- .../src/main/java/com/optimizely/ab/odp/ODPEventManager.java | 2 +- .../com/optimizely/ab/odp/parser/ResponseJsonParserFactory.java | 2 +- .../test/java/com/optimizely/ab/odp/ODPEventManagerTest.java | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/internal/JsonParserProvider.java b/core-api/src/main/java/com/optimizely/ab/internal/JsonParserProvider.java index 6f6de6516..8bde2ac66 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/JsonParserProvider.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/JsonParserProvider.java @@ -64,7 +64,7 @@ public static JsonParserProvider getDefaultParser() { continue; } - logger.info("using json parser: {}", parser.className); + logger.debug("using json parser: {}", parser.className); return parser; } diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java index b2a9a658b..0ae441c44 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java @@ -91,7 +91,7 @@ public void identifyUser(@Nullable String vuid, @Nullable String userId) { if (userId != null) { identifiers.put(ODPUserKey.FS_USER_ID.getKeyString(), userId); } - ODPEvent event = new ODPEvent("fullstack", "client_initialized", identifiers, null); + ODPEvent event = new ODPEvent("fullstack", "identified", identifiers, null); sendEvent(event); } diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactory.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactory.java index 7762cef0e..111c7ae85 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactory.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactory.java @@ -43,7 +43,7 @@ public static ResponseJsonParser getParser() { jsonParser = new JsonSimpleParser(); break; } - logger.info("Using " + parserProvider.toString() + " parser"); + logger.debug("Using " + parserProvider.toString() + " parser"); return jsonParser; } } diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java index d6941671f..17c489dd4 100644 --- a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java @@ -204,7 +204,7 @@ public void prepareCorrectPayloadForIdentifyUser() throws InterruptedException { for (int i = 0; i < events.length(); i++) { JSONObject event = events.getJSONObject(i); assertEquals("fullstack", event.getString("type")); - assertEquals("client_initialized", event.getString("action")); + assertEquals("identified", event.getString("action")); assertEquals("the-vuid-" + i, event.getJSONObject("identifiers").getString("vuid")); assertEquals("the-fs-user-id-" + i, event.getJSONObject("identifiers").getString("fs_user_id")); assertEquals("sdk", event.getJSONObject("data").getString("data_source_type")); From f63cff509fe2049b8256d71935d5fc43e2aa69d3 Mon Sep 17 00:00:00 2001 From: Zeeshan Ashraf <35262377+zashraf1985@users.noreply.github.com> Date: Fri, 6 Jan 2023 11:34:12 -0800 Subject: [PATCH 095/147] fix: ODP - getQualifiedSegments should return null when fetch fails (#496) --- .../java/com/optimizely/ab/Optimizely.java | 18 ++++++++---- .../optimizely/ab/OptimizelyUserContext.java | 28 ++++++++++++------- .../ab/OptimizelyUserContextTest.java | 4 +-- 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 0fbacaa3b..12b9f33d1 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2016-2022, Optimizely, Inc. and contributors * + * Copyright 2016-2023, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -436,7 +436,7 @@ private Boolean isFeatureEnabled(@Nonnull ProjectConfig projectConfig, Map copiedAttributes = copyAttributes(attributes); FeatureDecision.DecisionSource decisionSource = FeatureDecision.DecisionSource.ROLLOUT; - FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, createUserContext(userId, copiedAttributes), projectConfig).getResult(); + FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, createUserContextCopy(userId, copiedAttributes), projectConfig).getResult(); Boolean featureEnabled = false; SourceInfo sourceInfo = new RolloutSourceInfo(); if (featureDecision.decisionSource != null) { @@ -745,7 +745,7 @@ T getFeatureVariableValueForType(@Nonnull String featureKey, String variableValue = variable.getDefaultValue(); Map copiedAttributes = copyAttributes(attributes); - FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, createUserContext(userId, copiedAttributes), projectConfig).getResult(); + FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, createUserContextCopy(userId, copiedAttributes), projectConfig).getResult(); Boolean featureEnabled = false; if (featureDecision.variation != null) { if (featureDecision.variation.getFeatureEnabled()) { @@ -880,7 +880,7 @@ public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, } Map copiedAttributes = copyAttributes(attributes); - FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, createUserContext(userId, copiedAttributes), projectConfig, Collections.emptyList()).getResult(); + FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, createUserContextCopy(userId, copiedAttributes), projectConfig, Collections.emptyList()).getResult(); Boolean featureEnabled = false; Variation variation = featureDecision.variation; @@ -982,7 +982,7 @@ private Variation getVariation(@Nonnull ProjectConfig projectConfig, @Nonnull String userId, @Nonnull Map attributes) throws UnknownExperimentException { Map copiedAttributes = copyAttributes(attributes); - Variation variation = decisionService.getVariation(experiment, createUserContext(userId, copiedAttributes), projectConfig).getResult(); + Variation variation = decisionService.getVariation(experiment, createUserContextCopy(userId, copiedAttributes), projectConfig).getResult(); String notificationType = NotificationCenter.DecisionNotificationType.AB_TEST.toString(); if (projectConfig.getExperimentFeatureKeyMapping().get(experiment.getId()) != null) { @@ -1172,6 +1172,14 @@ public OptimizelyUserContext createUserContext(@Nonnull String userId) { return new OptimizelyUserContext(this, userId); } + private OptimizelyUserContext createUserContextCopy(@Nonnull String userId, @Nonnull Map attributes) { + if (userId == null) { + logger.warn("The userId parameter must be nonnull."); + return null; + } + return new OptimizelyUserContext(this, userId, attributes, Collections.EMPTY_MAP, null, false); + } + OptimizelyDecision decide(@Nonnull OptimizelyUserContext user, @Nonnull String key, @Nonnull List options) { diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java index 5ba15b5b4..7cb8753f4 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -1,6 +1,6 @@ /** * - * Copyright 2020-2022, Optimizely and contributors + * Copyright 2020-2023, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -76,7 +76,9 @@ public OptimizelyUserContext(@Nonnull Optimizely optimizely, this.forcedDecisionsMap = new ConcurrentHashMap<>(forcedDecisionsMap); } - this.qualifiedSegments = Collections.synchronizedList( qualifiedSegments == null ? new LinkedList<>(): qualifiedSegments); + if (qualifiedSegments != null) { + this.qualifiedSegments = Collections.synchronizedList(new LinkedList<>(qualifiedSegments)); + } if (shouldIdentifyUser == null || shouldIdentifyUser) { optimizely.identifyUser(userId); @@ -109,6 +111,10 @@ public OptimizelyUserContext copy() { * @return boolean Is user qualified for a segment. */ public boolean isQualifiedFor(@Nonnull String segment) { + if (qualifiedSegments == null) { + return false; + } + return qualifiedSegments.contains(segment); } @@ -293,8 +299,14 @@ public List getQualifiedSegments() { } public void setQualifiedSegments(List qualifiedSegments) { - this.qualifiedSegments.clear(); - this.qualifiedSegments.addAll(qualifiedSegments); + if (qualifiedSegments == null) { + this.qualifiedSegments = null; + } else if (this.qualifiedSegments == null) { + this.qualifiedSegments = Collections.synchronizedList(new LinkedList<>(qualifiedSegments)); + } else { + this.qualifiedSegments.clear(); + this.qualifiedSegments.addAll(qualifiedSegments); + } } /** @@ -315,9 +327,7 @@ public Boolean fetchQualifiedSegments() { */ public Boolean fetchQualifiedSegments(@Nonnull List segmentOptions) { List segments = optimizely.fetchQualifiedSegments(userId, segmentOptions); - if (segments != null) { - setQualifiedSegments(segments); - } + setQualifiedSegments(segments); return segments != null; } @@ -332,9 +342,7 @@ public Boolean fetchQualifiedSegments(@Nonnull List segmentOpt */ public void fetchQualifiedSegments(ODPSegmentCallback callback, List segmentOptions) { optimizely.fetchQualifiedSegments(userId, segments -> { - if (segments != null) { - setQualifiedSegments(segments); - } + setQualifiedSegments(segments); callback.onCompleted(segments != null); }, segmentOptions); } diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java index 5dd47a8cf..8f8bae834 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2021-2022, Optimizely and contributors + * Copyright 2021-2023, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -1709,7 +1709,7 @@ public void fetchQualifiedSegmentsAsyncError() throws InterruptedException { }); countDownLatch.await(); - assertEquals(Collections.emptyList(), userContext.getQualifiedSegments()); + assertEquals(null, userContext.getQualifiedSegments()); logbackVerifier.expectMessage(Level.ERROR, "Audience segments fetch failed (ODP is not enabled)."); } From 395e8c9a71dfdfea3f77e6ca13c9737e1ed2910b Mon Sep 17 00:00:00 2001 From: Zeeshan Ashraf <35262377+zashraf1985@users.noreply.github.com> Date: Fri, 6 Jan 2023 11:58:17 -0800 Subject: [PATCH 096/147] Made ODP Event Dispatcher a daemon thread (#497) --- .../java/com/optimizely/ab/odp/ODPEventManager.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java index 0ae441c44..ae034fd68 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java @@ -68,7 +68,13 @@ public void start() { eventDispatcherThread = new EventDispatcherThread(); } if (!isRunning) { - eventDispatcherThread.start(); + final ThreadFactory threadFactory = Executors.defaultThreadFactory(); + ExecutorService executor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = threadFactory.newThread(runnable); + thread.setDaemon(true); + return thread; + }); + executor.submit(eventDispatcherThread); } isRunning = true; } @@ -159,7 +165,7 @@ public void run() { if (currentBatch.size() > 0) { nextEvent = eventQueue.poll(nextFlushTime - new Date().getTime(), TimeUnit.MILLISECONDS); } else { - nextEvent = eventQueue.poll(); + nextEvent = eventQueue.take(); } if (nextEvent == null) { From 3f7c53c223a3cd38e4db7242a2b3ab02c90cd046 Mon Sep 17 00:00:00 2001 From: Muhammad Noman Date: Sat, 7 Jan 2023 02:18:20 +0500 Subject: [PATCH 097/147] Fix: Removed null check from getDefaultRequestConfigWithTimeout to create separate request configs (#498) --- .../java/com/optimizely/ab/HttpClientUtils.java | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/HttpClientUtils.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/HttpClientUtils.java index c35bb4b3f..dc786c4f6 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/HttpClientUtils.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/HttpClientUtils.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, 2019, Optimizely and contributors + * Copyright 2016-2017, 2019, 2022-2023, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -39,13 +39,11 @@ private HttpClientUtils() { .build(); public static RequestConfig getDefaultRequestConfigWithTimeout(int timeoutMillis) { - if (requestConfigWithTimeout == null) { - requestConfigWithTimeout = RequestConfig.custom() - .setConnectTimeout(timeoutMillis) - .setConnectionRequestTimeout(CONNECTION_REQUEST_TIMEOUT_MS) - .setSocketTimeout(SOCKET_TIMEOUT_MS) - .build(); - } + requestConfigWithTimeout = RequestConfig.custom() + .setConnectTimeout(timeoutMillis) + .setConnectionRequestTimeout(CONNECTION_REQUEST_TIMEOUT_MS) + .setSocketTimeout(timeoutMillis) + .build(); return requestConfigWithTimeout; } From ac27ae795ca4f6fc028270270af9d593b10c9c79 Mon Sep 17 00:00:00 2001 From: Muhammad Noman Date: Tue, 17 Jan 2023 22:15:35 +0500 Subject: [PATCH 098/147] removed setting up batchSize and it will set it to 1 when you set flushInterval to 0 (#500) --- .../optimizely/ab/odp/ODPEventManager.java | 8 +++---- .../ab/odp/ODPEventManagerTest.java | 22 +++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java index ae034fd68..a0129f925 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java @@ -1,6 +1,6 @@ /** * - * Copyright 2022, Optimizely + * Copyright 2022-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -53,14 +53,14 @@ public class ODPEventManager { private final BlockingQueue eventQueue = new LinkedBlockingQueue<>(); public ODPEventManager(@Nonnull ODPApiManager apiManager) { - this(apiManager, null, null, null); + this(apiManager, null, null); } - public ODPEventManager(@Nonnull ODPApiManager apiManager, @Nullable Integer batchSize, @Nullable Integer queueSize, @Nullable Integer flushInterval) { + public ODPEventManager(@Nonnull ODPApiManager apiManager, @Nullable Integer queueSize, @Nullable Integer flushInterval) { this.apiManager = apiManager; - this.batchSize = (batchSize != null && batchSize > 1) ? batchSize : DEFAULT_BATCH_SIZE; this.queueSize = queueSize != null ? queueSize : DEFAULT_QUEUE_SIZE; this.flushInterval = (flushInterval != null && flushInterval > 0) ? flushInterval : DEFAULT_FLUSH_INTERVAL; + this.batchSize = (flushInterval != null && flushInterval == 0) ? 1 : DEFAULT_BATCH_SIZE; } public void start() { diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java index 17c489dd4..ff970c846 100644 --- a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java @@ -96,23 +96,23 @@ public void dispatchEventsInCorrectNumberOfBatches() throws InterruptedException public void dispatchEventsWithCorrectPayload() throws InterruptedException { Mockito.reset(mockApiManager); Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); - int batchSize = 2; + int flushInterval = 0; ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); - ODPEventManager eventManager = new ODPEventManager(mockApiManager, batchSize, null, null); + ODPEventManager eventManager = new ODPEventManager(mockApiManager, null, flushInterval); eventManager.updateSettings(odpConfig); eventManager.start(); for (int i = 0; i < 6; i++) { eventManager.sendEvent(getEvent(i)); } Thread.sleep(500); - Mockito.verify(mockApiManager, times(3)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), payloadCaptor.capture()); + Mockito.verify(mockApiManager, times(6)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), payloadCaptor.capture()); List payloads = payloadCaptor.getAllValues(); for (int i = 0; i < payloads.size(); i++) { JSONArray events = new JSONArray(payloads.get(i)); - assertEquals(batchSize, events.length()); + assertEquals(1, events.length()); for (int j = 0; j < events.length(); j++) { - int id = (batchSize * i) + j; + int id = (1 * i) + j; JSONObject event = events.getJSONObject(j); assertEquals("test-type-" + id , event.getString("type")); assertEquals("test-action-" + id , event.getString("action")); @@ -186,9 +186,9 @@ public void shouldFlushAllScheduledEventsBeforeStopping() throws InterruptedExce public void prepareCorrectPayloadForIdentifyUser() throws InterruptedException { Mockito.reset(mockApiManager); Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); - int batchSize = 2; + int flushInterval = 0; ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); - ODPEventManager eventManager = new ODPEventManager(mockApiManager, batchSize, null, null); + ODPEventManager eventManager = new ODPEventManager(mockApiManager, null, flushInterval); eventManager.updateSettings(odpConfig); eventManager.start(); for (int i = 0; i < 2; i++) { @@ -196,17 +196,17 @@ public void prepareCorrectPayloadForIdentifyUser() throws InterruptedException { } Thread.sleep(1500); - Mockito.verify(mockApiManager, times(1)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), payloadCaptor.capture()); + Mockito.verify(mockApiManager, times(2)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), payloadCaptor.capture()); String payload = payloadCaptor.getValue(); JSONArray events = new JSONArray(payload); - assertEquals(batchSize, events.length()); + assertEquals(1, events.length()); for (int i = 0; i < events.length(); i++) { JSONObject event = events.getJSONObject(i); assertEquals("fullstack", event.getString("type")); assertEquals("identified", event.getString("action")); - assertEquals("the-vuid-" + i, event.getJSONObject("identifiers").getString("vuid")); - assertEquals("the-fs-user-id-" + i, event.getJSONObject("identifiers").getString("fs_user_id")); + assertEquals("the-vuid-" + (i + 1), event.getJSONObject("identifiers").getString("vuid")); + assertEquals("the-fs-user-id-" + (i + 1), event.getJSONObject("identifiers").getString("fs_user_id")); assertEquals("sdk", event.getJSONObject("data").getString("data_source_type")); } } From cbe85de051193c0d02539fad08962778233b9592 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Tue, 17 Jan 2023 10:50:28 -0800 Subject: [PATCH 099/147] feat(ats): add support for android ODP events (#502) Add support for Android-SDK ODP integration, which may not be implemented in the Android-SDK. 1. ODPManager.Builder.withExtraCommonData(commonData) - clients (android-sdk) will pass common data (client os, device type,...) which should be added into all ODPEvents. 2. ODPManager.Builder.withExtraCommonIdentifiers(commonIdentifiers) - clients (android-sdk) will pass common identifiers (vuid,...) which should be added into all ODPEvents. --- .../optimizely/ab/OptimizelyUserContext.java | 4 + .../java/com/optimizely/ab/odp/ODPEvent.java | 4 +- .../optimizely/ab/odp/ODPEventManager.java | 31 ++- .../com/optimizely/ab/odp/ODPManager.java | 32 +++ .../optimizelyconfig/OptimizelyFeature.java | 4 +- .../optimizely/ab/OptimizelyBuilderTest.java | 6 + .../ab/odp/ODPEventManagerTest.java | 195 +++++++++++++++++- .../ab/odp/ODPManagerBuilderTest.java | 27 ++- 8 files changed, 290 insertions(+), 13 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java index 7cb8753f4..e2c03b147 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -16,6 +16,7 @@ */ package com.optimizely.ab; +import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.odp.ODPManager; import com.optimizely.ab.odp.ODPSegmentCallback; import com.optimizely.ab.odp.ODPSegmentOption; @@ -313,6 +314,8 @@ public void setQualifiedSegments(List qualifiedSegments) { * Fetch all qualified segments for the user context. *

      * The segments fetched will be saved and can be accessed at any time by calling {@link #getQualifiedSegments()}. + * + * @return a boolean value for fetch success or failure. */ public Boolean fetchQualifiedSegments() { return fetchQualifiedSegments(Collections.emptyList()); @@ -324,6 +327,7 @@ public Boolean fetchQualifiedSegments() { * The segments fetched will be saved and can be accessed at any time by calling {@link #getQualifiedSegments()}. * * @param segmentOptions A set of options for fetching qualified segments. + * @return a boolean value for fetch success or failure. */ public Boolean fetchQualifiedSegments(@Nonnull List segmentOptions) { List segments = optimizely.fetchQualifiedSegments(userId, segmentOptions); diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java index a4dd37f2b..2ed2c1e76 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely Inc. and contributors + * Copyright 2022-2023, Optimizely Inc. and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,7 +26,7 @@ public class ODPEvent { private String type; private String action; - private Map identifiers; + private Map identifiers; private Map data; public ODPEvent(@Nullable String type, @Nonnull String action, @Nullable Map identifiers, @Nullable Map data) { diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java index a0129f925..fbd39ffaf 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java @@ -16,6 +16,7 @@ */ package com.optimizely.ab.odp; +import com.optimizely.ab.annotations.VisibleForTesting; import com.optimizely.ab.event.internal.BuildVersionInfo; import com.optimizely.ab.event.internal.ClientEngineInfo; import com.optimizely.ab.odp.serializer.ODPJsonSerializerFactory; @@ -38,6 +39,8 @@ public class ODPEventManager { private final int queueSize; private final int batchSize; private final int flushInterval; + @Nonnull private Map userCommonData = Collections.emptyMap(); + @Nonnull private Map userCommonIdentifiers = Collections.emptyMap(); private Boolean isRunning = false; @@ -63,6 +66,16 @@ public ODPEventManager(@Nonnull ODPApiManager apiManager, @Nullable Integer queu this.batchSize = (flushInterval != null && flushInterval == 0) ? 1 : DEFAULT_BATCH_SIZE; } + // these user-provided common data are included in all ODP events in addition to the SDK-generated common data. + public void setUserCommonData(@Nullable Map commonData) { + if (commonData != null) this.userCommonData = commonData; + } + + // these user-provided common identifiers are included in all ODP events in addition to the SDK-generated identifiers. + public void setUserCommonIdentifiers(@Nullable Map commonIdentifiers) { + if (commonIdentifiers != null) this.userCommonIdentifiers = commonIdentifiers; + } + public void start() { if (eventDispatcherThread == null) { eventDispatcherThread = new EventDispatcherThread(); @@ -107,19 +120,35 @@ public void sendEvent(ODPEvent event) { return; } event.setData(augmentCommonData(event.getData())); + event.setIdentifiers(augmentCommonIdentifiers(event.getIdentifiers())); processEvent(event); } - private Map augmentCommonData(Map sourceData) { + @VisibleForTesting + Map augmentCommonData(Map sourceData) { + // priority: sourceData > userCommonData > sdkCommonData + Map data = new HashMap<>(); data.put("idempotence_id", UUID.randomUUID().toString()); data.put("data_source_type", "sdk"); data.put("data_source", ClientEngineInfo.getClientEngine().getClientEngineValue()); data.put("data_source_version", BuildVersionInfo.getClientVersion()); + + data.putAll(userCommonData); data.putAll(sourceData); return data; } + @VisibleForTesting + Map augmentCommonIdentifiers(Map sourceIdentifiers) { + // priority: sourceIdentifiers > userCommonIdentifiers + + Map identifiers = new HashMap<>(); + identifiers.putAll(userCommonIdentifiers); + identifiers.putAll(sourceIdentifiers); + return identifiers; + } + private void processEvent(ODPEvent event) { if (!isRunning) { logger.warn("Failed to Process ODP Event. ODPEventManager is not running"); diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java index 3e198a209..cacbcad0d 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java @@ -21,7 +21,9 @@ import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; +import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Set; public class ODPManager implements AutoCloseable { @@ -77,6 +79,8 @@ public static class Builder { private Integer cacheSize; private Integer cacheTimeoutSeconds; private Cache> cacheImpl; + private Map userCommonData; + private Map userCommonIdentifiers; /** * Provide a custom {@link ODPManager} instance which makes http calls to fetch segments and send events. @@ -156,6 +160,32 @@ public Builder withSegmentCache(Cache> cacheImpl) { return this; } + /** + * Provide an optional group of user data that should be included in all ODP events. + * + * Note that this is in addition to the default data that is automatically included in all ODP events by this SDK (sdk-name, sdk-version, etc). + * + * @param commonData A key-value map of common user data. + * @return ODPManager builder + */ + public Builder withUserCommonData(@Nonnull Map commonData) { + this.userCommonData = commonData; + return this; + } + + /** + * Provide an optional group of identifiers that should be included in all ODP events. + * + * Note that this is in addition to the identifiers that is automatically included in all ODP events by this SDK. + * + * @param commonIdentifiers A key-value map of common identifiers. + * @return ODPManager builder + */ + public Builder withUserCommonIdentifiers(@Nonnull Map commonIdentifiers) { + this.userCommonIdentifiers = commonIdentifiers; + return this; + } + public ODPManager build() { if ((segmentManager == null || eventManager == null) && apiManager == null) { logger.warn("ApiManager instance is needed when using default EventManager or SegmentManager"); @@ -182,6 +212,8 @@ public ODPManager build() { if (eventManager == null) { eventManager = new ODPEventManager(apiManager); } + eventManager.setUserCommonData(userCommonData); + eventManager.setUserCommonIdentifiers(userCommonIdentifiers); return new ODPManager(segmentManager, eventManager); } diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeature.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeature.java index 77a1f67fd..7dec828a6 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeature.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeature.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2020-2021, Optimizely, Inc. and contributors * + * Copyright 2020-2021, 2023, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -63,6 +63,8 @@ public String getKey() { /** * @deprecated use {@link #getExperimentRules()} and {@link #getDeliveryRules()} instead + * + * @return a map of ExperimentKey to OptimizelyExperiment */ @Deprecated public Map getExperimentsMap() { diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java index 79382a5b7..566eec635 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java @@ -23,6 +23,8 @@ import com.optimizely.ab.event.EventHandler; import com.optimizely.ab.event.LogEvent; import com.optimizely.ab.event.internal.BuildVersionInfo; +import com.optimizely.ab.event.internal.ClientEngineInfo; +import com.optimizely.ab.event.internal.payload.Event; import com.optimizely.ab.event.internal.payload.EventBatch; import com.optimizely.ab.odp.ODPEventManager; import com.optimizely.ab.odp.ODPManager; @@ -241,6 +243,10 @@ public void withClientInfo() throws Exception { verify(eventHandler, timeout(5000)).dispatchEvent(argument.capture()); assertEquals(argument.getValue().getEventBatch().getClientName(), "android-sdk"); assertEquals(argument.getValue().getEventBatch().getClientVersion(), "1.2.3"); + + // restore the default values for other tests + ClientEngineInfo.setClientEngine(ClientEngineInfo.DEFAULT); + BuildVersionInfo.setClientVersion(BuildVersionInfo.VERSION); } @Test diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java index ff970c846..27de838f3 100644 --- a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2022, Optimizely + * Copyright 2022-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,9 @@ package com.optimizely.ab.odp; import ch.qos.logback.classic.Level; +import com.optimizely.ab.event.internal.BuildVersionInfo; +import com.optimizely.ab.event.internal.ClientEngineInfo; +import com.optimizely.ab.event.internal.payload.EventBatch; import com.optimizely.ab.internal.LogbackVerifier; import org.json.JSONArray; import org.json.JSONObject; @@ -30,10 +33,7 @@ import org.mockito.Mockito; import org.mockito.runners.MockitoJUnitRunner; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import static org.mockito.Matchers.*; import static org.mockito.Mockito.*; @@ -255,6 +255,191 @@ public void validateEventData() { assertFalse(event.isDataValid()); } + @Test + public void validateEventCommonData() { + Map sourceData = new HashMap<>(); + sourceData.put("k1", "v1"); + + Mockito.reset(mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + Map merged = eventManager.augmentCommonData(sourceData); + + assertEquals(merged.get("k1"), "v1"); + assertTrue(merged.get("idempotence_id").toString().length() > 16); + assertEquals(merged.get("data_source_type"), "sdk"); + assertEquals(merged.get("data_source"), "java-sdk"); + assertTrue(merged.get("data_source_version").toString().length() > 0); + assertEquals(merged.size(), 5); + + // when clientInfo is overridden (android-sdk): + + ClientEngineInfo.setClientEngine(EventBatch.ClientEngine.ANDROID_SDK); + BuildVersionInfo.setClientVersion("1.2.3"); + merged = eventManager.augmentCommonData(sourceData); + + assertEquals(merged.get("k1"), "v1"); + assertTrue(merged.get("idempotence_id").toString().length() > 16); + assertEquals(merged.get("data_source_type"), "sdk"); + assertEquals(merged.get("data_source"), "android-sdk"); + assertEquals(merged.get("data_source_version"), "1.2.3"); + assertEquals(merged.size(), 5); + + // restore the default values for other tests + ClientEngineInfo.setClientEngine(ClientEngineInfo.DEFAULT); + BuildVersionInfo.setClientVersion(BuildVersionInfo.VERSION); + } + + @Test + public void validateAugmentCommonData() { + Map sourceData = new HashMap<>(); + sourceData.put("k1", "source-1"); + sourceData.put("k2", "source-2"); + Map userCommonData = new HashMap<>(); + userCommonData.put("k3", "common-1"); + userCommonData.put("k4", "common-2"); + + Mockito.reset(mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.setUserCommonData(userCommonData); + + Map merged = eventManager.augmentCommonData(sourceData); + + // event-sourceData + assertEquals(merged.get("k1"), "source-1"); + assertEquals(merged.get("k2"), "source-2"); + // userCommonData + assertEquals(merged.get("k3"), "common-1"); + assertEquals(merged.get("k4"), "common-2"); + // sdk-generated common data + assertNotNull(merged.get("idempotence_id")); + assertEquals(merged.get("data_source_type"), "sdk"); + assertNotNull(merged.get("data_source")); + assertNotNull(merged.get("data_source_version")); + + assertEquals(merged.size(), 8); + } + + @Test + public void validateAugmentCommonData_keyConflicts1() { + Map sourceData = new HashMap<>(); + sourceData.put("k1", "source-1"); + sourceData.put("k2", "source-2"); + Map userCommonData = new HashMap<>(); + userCommonData.put("k1", "common-1"); + userCommonData.put("k2", "common-2"); + + Mockito.reset(mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.setUserCommonData(userCommonData); + + Map merged = eventManager.augmentCommonData(sourceData); + + // event-sourceData overrides userCommonData + assertEquals(merged.get("k1"), "source-1"); + assertEquals(merged.get("k2"), "source-2"); + // sdk-generated common data + assertNotNull(merged.get("idempotence_id")); + assertEquals(merged.get("data_source_type"), "sdk"); + assertNotNull(merged.get("data_source")); + assertNotNull(merged.get("data_source_version")); + + assertEquals(merged.size(), 6); + } + + @Test + public void validateAugmentCommonData_keyConflicts2() { + Map sourceData = new HashMap<>(); + sourceData.put("data_source_type", "source-1"); + Map userCommonData = new HashMap<>(); + userCommonData.put("data_source_type", "common-1"); + + Mockito.reset(mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.setUserCommonData(userCommonData); + + Map merged = eventManager.augmentCommonData(sourceData); + + // event-sourceData overrides userCommonData and sdk-generated common data + assertEquals(merged.get("data_source_type"), "source-1"); + // sdk-generated common data + assertNotNull(merged.get("idempotence_id")); + assertNotNull(merged.get("data_source")); + assertNotNull(merged.get("data_source_version")); + + assertEquals(merged.size(), 4); + } + + @Test + public void validateAugmentCommonData_keyConflicts3() { + Map sourceData = new HashMap<>(); + sourceData.put("k1", "source-1"); + Map userCommonData = new HashMap<>(); + userCommonData.put("data_source_type", "common-1"); + + Mockito.reset(mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.setUserCommonData(userCommonData); + + Map merged = eventManager.augmentCommonData(sourceData); + + // userCommonData overrides sdk-generated common data + assertEquals(merged.get("data_source_type"), "common-1"); + assertEquals(merged.get("k1"), "source-1"); + // sdk-generated common data + assertNotNull(merged.get("idempotence_id")); + assertNotNull(merged.get("data_source")); + assertNotNull(merged.get("data_source_version")); + + assertEquals(merged.size(), 5); + } + + @Test + public void validateAugmentCommonIdentifiers() { + Map sourceIdentifiers = new HashMap<>(); + sourceIdentifiers.put("k1", "source-1"); + sourceIdentifiers.put("k2", "source-2"); + Map userCommonIdentifiers = new HashMap<>(); + userCommonIdentifiers.put("k3", "common-1"); + userCommonIdentifiers.put("k4", "common-2"); + + Mockito.reset(mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.setUserCommonIdentifiers(userCommonIdentifiers); + + Map merged = eventManager.augmentCommonIdentifiers(sourceIdentifiers); + + // event-sourceIdentifiers + assertEquals(merged.get("k1"), "source-1"); + assertEquals(merged.get("k2"), "source-2"); + // userCommonIdentifiers + assertEquals(merged.get("k3"), "common-1"); + assertEquals(merged.get("k4"), "common-2"); + + assertEquals(merged.size(), 4); + } + + @Test + public void validateAugmentCommonIdentifiers_keyConflicts() { + Map sourceIdentifiers = new HashMap<>(); + sourceIdentifiers.put("k1", "source-1"); + sourceIdentifiers.put("k2", "source-2"); + Map userCommonIdentifiers = new HashMap<>(); + userCommonIdentifiers.put("k1", "common-1"); + userCommonIdentifiers.put("k2", "common-2"); + + Mockito.reset(mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.setUserCommonIdentifiers(userCommonIdentifiers); + + Map merged = eventManager.augmentCommonIdentifiers(sourceIdentifiers); + + // event-sourceIdentifiers overrides userCommonIdentifiers + assertEquals(merged.get("k1"), "source-1"); + assertEquals(merged.get("k2"), "source-2"); + + assertEquals(merged.size(), 2); + } + private ODPEvent getEvent(int id) { Map identifiers = new HashMap<>(); identifiers.put("identifier1", "value1-" + id); diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerBuilderTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerBuilderTest.java index 0f7f59ae9..0dcc9104a 100644 --- a/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerBuilderTest.java +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerBuilderTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2022, Optimizely + * Copyright 2022-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,9 +19,7 @@ import com.optimizely.ab.internal.Cache; import org.junit.Test; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; +import java.util.*; import static org.mockito.Matchers.*; import static org.mockito.Mockito.*; @@ -73,4 +71,25 @@ public void withSegmentCache() { odpManager.getSegmentManager().getQualifiedSegments("test-user"); verify(mockCache).lookup("fs_user_id-$-test-user"); } + + @Test + public void withUserCommonDataAndCommonIdentifiers() { + Map data = new HashMap<>(); + data.put("k1", "v1"); + Map identifiers = new HashMap<>(); + identifiers.put("k2", "v2"); + + ODPEventManager mockEventManager = mock(ODPEventManager.class); + ODPSegmentManager mockSegmentManager = mock(ODPSegmentManager.class); + ODPManager.builder() + .withUserCommonData(data) + .withUserCommonIdentifiers(identifiers) + .withEventManager(mockEventManager) + .withSegmentManager(mockSegmentManager) + .build(); + + verify(mockEventManager).setUserCommonData(eq(data)); + verify(mockEventManager).setUserCommonIdentifiers(eq(identifiers)); + } + } From cbaeb85aabe1f07add9cdf5d806d2706556fcc88 Mon Sep 17 00:00:00 2001 From: Muhammad Noman Date: Fri, 27 Jan 2023 03:17:06 +0500 Subject: [PATCH 100/147] Fix: Added notificationRegistry to make sure that odpSettings updates (#501) * Added notificationRegistry to make sure that odpSettings update will call upon UpdateConfig * Updated headers and added additional checks in unittests * Added suppress warning * renamed NoficationRegistry function. Added default implementation of getSDKKey in ProjectConfigManager * reverting header change * reverting extra changes * reverting extra changes * nit fix * nit * Resolved comments * Added getCachedconfig Function to return ProjectConfig without wait, but the default implementation of it will return null. * ProjectConfig getCachedConfig(); without default implementation * Removed default implementation of getSDKkey to enforce user to make their own choice. * Resolved comments * setting Sdk key inside pollingConfigManager to make sure that the sdkKey for a particular config is always same. * setting sdkKey in parent class * fix * making sure that if the SDKKey is set by the user then it should be picked from projectConfig * Refactored and moved getSdkKey to pollingConfigManager to make sure that to trigger the notification always user provided sdkKey will be prioritized Co-authored-by: mnoman09 --- .../java/com/optimizely/ab/Optimizely.java | 16 +++- .../ab/config/AtomicProjectConfigManager.java | 17 +++- .../config/PollingProjectConfigManager.java | 30 ++++++- .../ab/config/ProjectConfigManager.java | 23 ++++- .../ab/internal/NotificationRegistry.java | 52 ++++++++++++ .../com/optimizely/ab/OptimizelyTest.java | 47 +++++------ .../PollingProjectConfigManagerTest.java | 24 +++++- .../ab/internal/NotificationRegistryTest.java | 84 +++++++++++++++++++ .../com/optimizely/ab/OptimizelyFactory.java | 20 ++++- .../ab/config/HttpProjectConfigManager.java | 9 +- .../optimizely/ab/OptimizelyFactoryTest.java | 26 +++++- .../config/HttpProjectConfigManagerTest.java | 4 +- 12 files changed, 306 insertions(+), 46 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/internal/NotificationRegistry.java create mode 100644 core-api/src/test/java/com/optimizely/ab/internal/NotificationRegistryTest.java diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 12b9f33d1..5f41bcd6e 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -27,6 +27,7 @@ import com.optimizely.ab.event.*; import com.optimizely.ab.event.internal.*; import com.optimizely.ab.event.internal.payload.EventBatch; +import com.optimizely.ab.internal.NotificationRegistry; import com.optimizely.ab.notification.*; import com.optimizely.ab.odp.*; import com.optimizely.ab.optimizelyconfig.OptimizelyConfig; @@ -124,10 +125,15 @@ private Optimizely(@Nonnull EventHandler eventHandler, if (odpManager != null) { odpManager.getEventManager().start(); - if (getProjectConfig() != null) { + if (projectConfigManager.getCachedConfig() != null) { updateODPSettings(); } - addUpdateConfigNotificationHandler(configNotification -> { updateODPSettings(); }); + if (projectConfigManager.getSDKKey() != null) { + NotificationRegistry.getInternalNotificationCenter(projectConfigManager.getSDKKey()). + addNotificationHandler(UpdateConfigNotification.class, + configNotification -> { updateODPSettings(); }); + } + } } @@ -153,6 +159,8 @@ public void close() { tryClose(eventProcessor); tryClose(eventHandler); tryClose(projectConfigManager); + notificationCenter.clearAllNotificationListeners(); + NotificationRegistry.clearNotificationCenterRegistry(projectConfigManager.getSDKKey()); if (odpManager != null) { tryClose(odpManager); } @@ -1477,8 +1485,8 @@ public void identifyUser(@Nonnull String userId) { } private void updateODPSettings() { - if (odpManager != null && getProjectConfig() != null) { - ProjectConfig projectConfig = getProjectConfig(); + ProjectConfig projectConfig = projectConfigManager.getCachedConfig(); + if (odpManager != null && projectConfig != null) { odpManager.updateSettings(projectConfig.getHostForODP(), projectConfig.getPublicKeyForODP(), projectConfig.getAllSegments()); } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/AtomicProjectConfigManager.java b/core-api/src/main/java/com/optimizely/ab/config/AtomicProjectConfigManager.java index fa1f4bd62..336d33c0e 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/AtomicProjectConfigManager.java +++ b/core-api/src/main/java/com/optimizely/ab/config/AtomicProjectConfigManager.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019, Optimizely and contributors + * Copyright 2019, 2023, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,6 +27,21 @@ public ProjectConfig getConfig() { return projectConfigReference.get(); } + /** + * Access to current cached project configuration. + * + * @return {@link ProjectConfig} + */ + @Override + public ProjectConfig getCachedConfig() { + return projectConfigReference.get(); + } + + @Override + public String getSDKKey() { + return null; + } + public void setConfig(ProjectConfig projectConfig) { projectConfigReference.set(projectConfig); } diff --git a/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java b/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java index b03aeabc0..4ad34e7a8 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java +++ b/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019-2020, Optimizely and contributors + * Copyright 2019-2020, 2023, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ */ package com.optimizely.ab.config; +import com.optimizely.ab.internal.NotificationRegistry; import com.optimizely.ab.notification.NotificationCenter; import com.optimizely.ab.notification.UpdateConfigNotification; import com.optimizely.ab.optimizelyconfig.OptimizelyConfig; @@ -56,6 +57,7 @@ public abstract class PollingProjectConfigManager implements ProjectConfigManage private final CountDownLatch countDownLatch = new CountDownLatch(1); + private volatile String sdkKey; private volatile boolean started; private ScheduledFuture scheduledFuture; @@ -84,6 +86,16 @@ public PollingProjectConfigManager(long period, TimeUnit timeUnit, long blocking protected abstract ProjectConfig poll(); + /** + * Access to current cached project configuration, This is to make sure that config returns without any wait, even if it is null. + * + * @return {@link ProjectConfig} + */ + @Override + public ProjectConfig getCachedConfig() { + return currentProjectConfig.get(); + } + /** * Only allow the ProjectConfig to be set to a non-null value, if and only if the value has not already been set. * @param projectConfig @@ -109,6 +121,13 @@ void setConfig(ProjectConfig projectConfig) { currentProjectConfig.set(projectConfig); currentOptimizelyConfig.set(new OptimizelyConfigService(projectConfig).getConfig()); countDownLatch.countDown(); + + if (sdkKey == null) { + sdkKey = projectConfig.getSdkKey(); + } + if (sdkKey != null) { + NotificationRegistry.getInternalNotificationCenter(sdkKey).send(SIGNAL); + } notificationCenter.send(SIGNAL); } @@ -150,6 +169,11 @@ public OptimizelyConfig getOptimizelyConfig() { return currentOptimizelyConfig.get(); } + @Override + public String getSDKKey() { + return this.sdkKey; + } + public synchronized void start() { if (started) { logger.warn("Manager already started."); @@ -189,6 +213,10 @@ public synchronized void close() { started = false; } + protected void setSdkKey(String sdkKey) { + this.sdkKey = sdkKey; + } + public boolean isRunning() { return started; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfigManager.java b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfigManager.java index 1a1b2f4bc..002acae55 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfigManager.java +++ b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfigManager.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019, Optimizely and contributors + * Copyright 2019, 2023, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,8 @@ */ package com.optimizely.ab.config; +import javax.annotation.Nullable; + public interface ProjectConfigManager { /** * Implementations of this method should block until a datafile is available. @@ -23,5 +25,24 @@ public interface ProjectConfigManager { * @return ProjectConfig */ ProjectConfig getConfig(); + + /** + * Implementations of this method should not block until a datafile is available, instead return current cached project configuration. + * return null if ProjectConfig is not ready at the moment. + * + * NOTE: To use ODP segments, implementation of this function is required to return current project configuration. + * @return ProjectConfig + */ + @Nullable + ProjectConfig getCachedConfig(); + + /** + * Implementations of this method should return SDK key. If there is no SDKKey then it should return null. + * + * NOTE: To update ODP segments configuration via polling, it is required to return sdkKey. + * @return String + */ + @Nullable + String getSDKKey(); } diff --git a/core-api/src/main/java/com/optimizely/ab/internal/NotificationRegistry.java b/core-api/src/main/java/com/optimizely/ab/internal/NotificationRegistry.java new file mode 100644 index 000000000..92d0c6d38 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/internal/NotificationRegistry.java @@ -0,0 +1,52 @@ +/** + * + * Copyright 2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import com.optimizely.ab.notification.NotificationCenter; + +import javax.annotation.Nonnull; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class NotificationRegistry { + private final static Map _notificationCenters = new ConcurrentHashMap<>(); + + private NotificationRegistry() + { + } + + public static NotificationCenter getInternalNotificationCenter(@Nonnull String sdkKey) + { + NotificationCenter notificationCenter = null; + if (sdkKey != null) { + if (_notificationCenters.containsKey(sdkKey)) { + notificationCenter = _notificationCenters.get(sdkKey); + } else { + notificationCenter = new NotificationCenter(); + _notificationCenters.put(sdkKey, notificationCenter); + } + } + return notificationCenter; + } + + public static void clearNotificationCenterRegistry(@Nonnull String sdkKey) { + if (sdkKey != null) { + _notificationCenters.remove(sdkKey); + } + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java index 9fd3dd675..705ce1cb6 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2016-2022, Optimizely, Inc. and contributors * + * Copyright 2016-2023, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -119,6 +119,23 @@ public static Collection data() throws IOException { public OptimizelyRule optimizelyBuilder = new OptimizelyRule(); public EventHandlerRule eventHandler = new EventHandlerRule(); + public ProjectConfigManager projectConfigManagerReturningNull = new ProjectConfigManager() { + @Override + public ProjectConfig getConfig() { + return null; + } + + @Override + public ProjectConfig getCachedConfig() { + return null; + } + + @Override + public String getSDKKey() { + return null; + } + }; + @Rule @SuppressFBWarnings("URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD") public RuleChain ruleChain = RuleChain.outerRule(thrown) @@ -4505,13 +4522,13 @@ public void isValidReturnsTrueWhenClientIsValid() throws Exception { @Test public void testGetNotificationCenter() { - Optimizely optimizely = optimizelyBuilder.withConfigManager(() -> null).build(); + Optimizely optimizely = optimizelyBuilder.withConfigManager(projectConfigManagerReturningNull).build(); assertEquals(optimizely.notificationCenter, optimizely.getNotificationCenter()); } @Test public void testAddTrackNotificationHandler() { - Optimizely optimizely = optimizelyBuilder.withConfigManager(() -> null).build(); + Optimizely optimizely = optimizelyBuilder.withConfigManager(projectConfigManagerReturningNull).build(); NotificationManager manager = optimizely.getNotificationCenter() .getNotificationManager(TrackNotification.class); @@ -4521,7 +4538,7 @@ public void testAddTrackNotificationHandler() { @Test public void testAddDecisionNotificationHandler() { - Optimizely optimizely = optimizelyBuilder.withConfigManager(() -> null).build(); + Optimizely optimizely = optimizelyBuilder.withConfigManager(projectConfigManagerReturningNull).build(); NotificationManager manager = optimizely.getNotificationCenter() .getNotificationManager(DecisionNotification.class); @@ -4531,7 +4548,7 @@ public void testAddDecisionNotificationHandler() { @Test public void testAddUpdateConfigNotificationHandler() { - Optimizely optimizely = optimizelyBuilder.withConfigManager(() -> null).build(); + Optimizely optimizely = optimizelyBuilder.withConfigManager(projectConfigManagerReturningNull).build(); NotificationManager manager = optimizely.getNotificationCenter() .getNotificationManager(UpdateConfigNotification.class); @@ -4541,7 +4558,7 @@ public void testAddUpdateConfigNotificationHandler() { @Test public void testAddLogEventNotificationHandler() { - Optimizely optimizely = optimizelyBuilder.withConfigManager(() -> null).build(); + Optimizely optimizely = optimizelyBuilder.withConfigManager(projectConfigManagerReturningNull).build(); NotificationManager manager = optimizely.getNotificationCenter() .getNotificationManager(LogEvent.class); @@ -4713,24 +4730,6 @@ public void initODPManagerWithProjectConfig() throws IOException { verify(mockODPManager, times(1)).updateSettings(any(), any(), any()); } - @Test - public void updateODPManagerWhenConfigUpdates() throws IOException { - ODPEventManager mockODPEventManager = mock(ODPEventManager.class); - ODPManager mockODPManager = mock(ODPManager.class); - NotificationCenter mockNotificationCenter = mock(NotificationCenter.class); - - Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); - Optimizely.builder() - .withDatafile(validConfigJsonV4()) - .withNotificationCenter(mockNotificationCenter) - .withODPManager(mockODPManager) - .build(); - - verify(mockODPManager, times(1)).updateSettings(any(), any(), any()); - - Mockito.verify(mockNotificationCenter, times(1)).addNotificationHandler(any(), any()); - } - @Test public void sendODPEvent() { ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); diff --git a/core-api/src/test/java/com/optimizely/ab/config/PollingProjectConfigManagerTest.java b/core-api/src/test/java/com/optimizely/ab/config/PollingProjectConfigManagerTest.java index 390c9b874..130f8844a 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/PollingProjectConfigManagerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/PollingProjectConfigManagerTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019-2021, Optimizely and contributors + * Copyright 2019-2021, 2023, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ */ package com.optimizely.ab.config; +import com.optimizely.ab.internal.NotificationRegistry; import com.optimizely.ab.notification.NotificationCenter; import com.optimizely.ab.notification.UpdateConfigNotification; import org.junit.After; @@ -95,12 +96,14 @@ public void testBlockingGetConfig() throws Exception { testProjectConfigManager.release(); TimeUnit.MILLISECONDS.sleep(PROJECT_CONFIG_DELAY); assertEquals(projectConfig, testProjectConfigManager.getConfig()); + assertEquals(projectConfig.getSdkKey(), testProjectConfigManager.getSDKKey()); } @Test public void testBlockingGetConfigWithDefault() throws Exception { testProjectConfigManager.setConfig(projectConfig); assertEquals(projectConfig, testProjectConfigManager.getConfig()); + assertEquals(projectConfig.getSdkKey(), testProjectConfigManager.getSDKKey()); } @Test @@ -124,6 +127,7 @@ public void testGetConfigNotStartedDefault() throws Exception { testProjectConfigManager.close(); assertFalse(testProjectConfigManager.isRunning()); assertEquals(projectConfig, testProjectConfigManager.getConfig()); + assertEquals(projectConfig.getSdkKey(), testProjectConfigManager.getSDKKey()); } @Test @@ -210,11 +214,17 @@ public ProjectConfig poll() { @Test public void testUpdateConfigNotificationGetsTriggered() throws InterruptedException { - CountDownLatch countDownLatch = new CountDownLatch(1); + CountDownLatch countDownLatch = new CountDownLatch(2); + NotificationCenter registryDefaultNotificationCenter = NotificationRegistry.getInternalNotificationCenter("ValidProjectConfigV4"); + NotificationCenter userNotificationCenter = testProjectConfigManager.getNotificationCenter(); + assertNotEquals(registryDefaultNotificationCenter, userNotificationCenter); + testProjectConfigManager.getNotificationCenter() .getNotificationManager(UpdateConfigNotification.class) .addHandler(message -> {countDownLatch.countDown();}); - + NotificationRegistry.getInternalNotificationCenter("ValidProjectConfigV4") + .getNotificationManager(UpdateConfigNotification.class) + .addHandler(message -> {countDownLatch.countDown();}); assertTrue(countDownLatch.await(500, TimeUnit.MILLISECONDS)); } @@ -271,5 +281,13 @@ public int getCount() { public void release() { countDownLatch.countDown(); } + + @Override + public String getSDKKey() { + if (projectConfig == null) { + return null; + } + return projectConfig.getSdkKey(); + } } } diff --git a/core-api/src/test/java/com/optimizely/ab/internal/NotificationRegistryTest.java b/core-api/src/test/java/com/optimizely/ab/internal/NotificationRegistryTest.java new file mode 100644 index 000000000..4f130a848 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/internal/NotificationRegistryTest.java @@ -0,0 +1,84 @@ +/** + * + * Copyright 2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import com.optimizely.ab.notification.NotificationCenter; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.junit.Assert; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + + +public class NotificationRegistryTest { + + @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") + @Test + public void getNullNotificationCenterWhenSDKeyIsNull() { + String sdkKey = null; + NotificationCenter notificationCenter = NotificationRegistry.getInternalNotificationCenter(sdkKey); + assertNull(notificationCenter); + } + + @Test + public void getSameNotificationCenterWhenSDKKeyIsSameButNotNull() { + String sdkKey = "testSDkKey"; + NotificationCenter notificationCenter1 = NotificationRegistry.getInternalNotificationCenter(sdkKey); + NotificationCenter notificationCenter2 = NotificationRegistry.getInternalNotificationCenter(sdkKey); + assertEquals(notificationCenter1, notificationCenter2); + } + + @Test + public void getSameNotificationCenterWhenSDKKeyIsEmpty() { + String sdkKey1 = ""; + String sdkKey2 = ""; + NotificationCenter notificationCenter1 = NotificationRegistry.getInternalNotificationCenter(sdkKey1); + NotificationCenter notificationCenter2 = NotificationRegistry.getInternalNotificationCenter(sdkKey2); + assertEquals(notificationCenter1, notificationCenter2); + } + + @Test + public void getDifferentNotificationCenterWhenSDKKeyIsNotSame() { + String sdkKey1 = "testSDkKey1"; + String sdkKey2 = "testSDkKey2"; + NotificationCenter notificationCenter1 = NotificationRegistry.getInternalNotificationCenter(sdkKey1); + NotificationCenter notificationCenter2 = NotificationRegistry.getInternalNotificationCenter(sdkKey2); + Assert.assertNotEquals(notificationCenter1, notificationCenter2); + } + + @Test + public void clearRegistryNotificationCenterClearsOldNotificationCenter() { + String sdkKey1 = "testSDkKey1"; + NotificationCenter notificationCenter1 = NotificationRegistry.getInternalNotificationCenter(sdkKey1); + NotificationRegistry.clearNotificationCenterRegistry(sdkKey1); + NotificationCenter notificationCenter2 = NotificationRegistry.getInternalNotificationCenter(sdkKey1); + + Assert.assertNotEquals(notificationCenter1, notificationCenter2); + } + + @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") + @Test + public void clearRegistryNotificationCenterWillNotCauseExceptionIfPassedNullSDkKey() { + String sdkKey1 = "testSDkKey1"; + NotificationCenter notificationCenter1 = NotificationRegistry.getInternalNotificationCenter(sdkKey1); + NotificationRegistry.clearNotificationCenterRegistry(null); + NotificationCenter notificationCenter2 = NotificationRegistry.getInternalNotificationCenter(sdkKey1); + + Assert.assertEquals(notificationCenter1, notificationCenter2); + } +} diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java index 37d56da03..1c6ee2820 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019-2021, Optimizely + * Copyright 2019-2021, 2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package com.optimizely.ab; import com.optimizely.ab.config.HttpProjectConfigManager; +import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.ProjectConfigManager; import com.optimizely.ab.event.AsyncEventHandler; import com.optimizely.ab.event.BatchEventProcessor; @@ -222,7 +223,22 @@ public static Optimizely newDefaultInstance() { public static Optimizely newDefaultInstance(String sdkKey) { if (sdkKey == null) { logger.error("Must provide an sdkKey, returning non-op Optimizely client"); - return newDefaultInstance(() -> null); + return newDefaultInstance(new ProjectConfigManager() { + @Override + public ProjectConfig getConfig() { + return null; + } + + @Override + public ProjectConfig getCachedConfig() { + return null; + } + + @Override + public String getSDKKey() { + return null; + } + }); } return newDefaultInstance(sdkKey, null); diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java index cef13fdcd..4be1715d2 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java @@ -332,11 +332,10 @@ public HttpProjectConfigManager build(boolean defer) { .withEvictIdleConnections(evictConnectionIdleTimePeriod, evictConnectionIdleTimeUnit) .build(); } - + if (sdkKey == null) { + throw new NullPointerException("sdkKey cannot be null"); + } if (url == null) { - if (sdkKey == null) { - throw new NullPointerException("sdkKey cannot be null"); - } if (datafileAccessToken == null) { url = String.format(format, sdkKey); @@ -358,7 +357,7 @@ public HttpProjectConfigManager build(boolean defer) { blockingTimeoutPeriod, blockingTimeoutUnit, notificationCenter); - + httpProjectManager.setSdkKey(sdkKey); if (datafile != null) { try { ProjectConfig projectConfig = HttpProjectConfigManager.parseProjectConfig(datafile); diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java index 07c2c0634..aaa3a67fa 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019-2020, Optimizely + * Copyright 2019-2020, 2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,8 @@ import com.google.common.base.Charsets; import com.google.common.io.Resources; import com.optimizely.ab.config.HttpProjectConfigManager; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.ProjectConfigManager; import com.optimizely.ab.event.AsyncEventHandler; import com.optimizely.ab.event.BatchEventProcessor; import com.optimizely.ab.internal.PropertyUtils; @@ -260,17 +262,33 @@ public void newDefaultInstanceWithDatafileAccessTokenAndCustomHttpClient() throw optimizely = OptimizelyFactory.newDefaultInstance("sdk-key", datafileString, "auth-token", httpClient); assertTrue(optimizely.isValid()); } + public ProjectConfigManager projectConfigManagerReturningNull = new ProjectConfigManager() { + @Override + public ProjectConfig getConfig() { + return null; + } + + @Override + public ProjectConfig getCachedConfig() { + return null; + } + + @Override + public String getSDKKey() { + return null; + } + }; @Test public void newDefaultInstanceWithProjectConfig() throws Exception { - optimizely = OptimizelyFactory.newDefaultInstance(() -> null); + optimizely = OptimizelyFactory.newDefaultInstance(projectConfigManagerReturningNull); assertFalse(optimizely.isValid()); } @Test public void newDefaultInstanceWithProjectConfigAndNotificationCenter() throws Exception { NotificationCenter notificationCenter = new NotificationCenter(); - optimizely = OptimizelyFactory.newDefaultInstance(() -> null, notificationCenter); + optimizely = OptimizelyFactory.newDefaultInstance(projectConfigManagerReturningNull, notificationCenter); assertFalse(optimizely.isValid()); assertEquals(notificationCenter, optimizely.getNotificationCenter()); } @@ -278,7 +296,7 @@ public void newDefaultInstanceWithProjectConfigAndNotificationCenter() throws Ex @Test public void newDefaultInstanceWithProjectConfigAndNotificationCenterAndEventHandler() { NotificationCenter notificationCenter = new NotificationCenter(); - optimizely = OptimizelyFactory.newDefaultInstance(() -> null, notificationCenter, logEvent -> {}); + optimizely = OptimizelyFactory.newDefaultInstance(projectConfigManagerReturningNull, notificationCenter, logEvent -> {}); assertFalse(optimizely.isValid()); assertEquals(notificationCenter, optimizely.getNotificationCenter()); } diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java index c61a1f01a..9cbc0bb01 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019, Optimizely + * Copyright 2019, 2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -129,6 +129,7 @@ public void testHttpGetByCustomUrl() throws Exception { projectConfigManager = builder() .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("custom-sdkKey") .withUrl(expected) .build(); @@ -207,6 +208,7 @@ public void testBuildDefer() throws Exception { .withOptimizelyHttpClient(mockHttpClient) .withSdkKey("sdk-key") .build(true); + assertEquals("sdk-key", projectConfigManager.getSDKKey()); } @Test From 42f88992952eaeea6da52080cd1d748fcac3bc35 Mon Sep 17 00:00:00 2001 From: Muhammad Noman Date: Wed, 8 Feb 2023 21:59:01 +0500 Subject: [PATCH 101/147] Refact: ODPAPIManager now returns fetchQualifiedSegments list instead of string (#503) --- .../com/optimizely/ab/odp/ODPApiManager.java | 5 +++-- .../com/optimizely/ab/odp/ODPEventManager.java | 14 +++++++++++--- .../com/optimizely/ab/odp/ODPSegmentManager.java | 13 ++----------- .../com/optimizely/ab/odp/ODPManagerTest.java | 5 +++-- .../optimizely/ab/odp/ODPSegmentManagerTest.java | 4 ++-- .../optimizely/ab/odp/DefaultODPApiManager.java | 15 ++++++++++----- .../ab/odp/DefaultODPApiManagerTest.java | 16 +++++++++------- 7 files changed, 40 insertions(+), 32 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java index 6385d2b7b..b45bd937f 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely Inc. and contributors + * Copyright 2022-2023, Optimizely Inc. and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,10 +15,11 @@ */ package com.optimizely.ab.odp; +import java.util.List; import java.util.Set; public interface ODPApiManager { - String fetchQualifiedSegments(String apiKey, String apiEndpoint, String userKey, String userValue, Set segmentsToCheck); + List fetchQualifiedSegments(String apiKey, String apiEndpoint, String userKey, String userValue, Set segmentsToCheck); Integer sendEvents(String apiKey, String apiEndpoint, String eventPayload); } diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java index fbd39ffaf..ce218ed9d 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java @@ -108,7 +108,11 @@ public void identifyUser(@Nullable String vuid, @Nullable String userId) { identifiers.put(ODPUserKey.VUID.getKeyString(), vuid); } if (userId != null) { - identifiers.put(ODPUserKey.FS_USER_ID.getKeyString(), userId); + if (isVuid(userId)) { + identifiers.put(ODPUserKey.VUID.getKeyString(), userId); + } else { + identifiers.put(ODPUserKey.FS_USER_ID.getKeyString(), userId); + } } ODPEvent event = new ODPEvent("fullstack", "identified", identifiers, null); sendEvent(event); @@ -125,7 +129,7 @@ public void sendEvent(ODPEvent event) { } @VisibleForTesting - Map augmentCommonData(Map sourceData) { + protected Map augmentCommonData(Map sourceData) { // priority: sourceData > userCommonData > sdkCommonData Map data = new HashMap<>(); @@ -140,7 +144,7 @@ Map augmentCommonData(Map sourceData) { } @VisibleForTesting - Map augmentCommonIdentifiers(Map sourceIdentifiers) { + protected Map augmentCommonIdentifiers(Map sourceIdentifiers) { // priority: sourceIdentifiers > userCommonIdentifiers Map identifiers = new HashMap<>(); @@ -149,6 +153,10 @@ Map augmentCommonIdentifiers(Map sourceIdentifie return identifiers; } + private boolean isVuid(String userId) { + return userId.startsWith("vuid_"); + } + private void processEvent(ODPEvent event) { if (!isRunning) { logger.warn("Failed to Process ODP Event. ODPEventManager is not running"); diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java index 90a36fa5d..275c45ee1 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java @@ -1,6 +1,6 @@ /** * - * Copyright 2022, Optimizely + * Copyright 2022-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -89,16 +89,7 @@ public List getQualifiedSegments(ODPUserKey userKey, String userValue, L logger.debug("ODP Cache Miss. Making a call to ODP Server."); - ResponseJsonParser parser = ResponseJsonParserFactory.getParser(); - String qualifiedSegmentsResponse = apiManager.fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + SEGMENT_URL_PATH, userKey.getKeyString(), userValue, odpConfig.getAllSegments()); - try { - qualifiedSegments = parser.parseQualifiedSegments(qualifiedSegmentsResponse); - } catch (Exception e) { - logger.error("Audience segments fetch failed (Error Parsing Response)"); - logger.debug(e.getMessage()); - qualifiedSegments = null; - } - + qualifiedSegments = apiManager.fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + SEGMENT_URL_PATH, userKey.getKeyString(), userValue, odpConfig.getAllSegments()); if (qualifiedSegments != null && !options.contains(ODPSegmentOption.IGNORE_CACHE)) { segmentsCache.save(cacheKey, qualifiedSegments); } diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerTest.java index 02abe88a1..9673bfa69 100644 --- a/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2022, Optimizely + * Copyright 2022-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,13 +24,14 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashSet; +import java.util.List; import static org.mockito.Matchers.*; import static org.mockito.Mockito.*; import static org.junit.Assert.*; public class ODPManagerTest { - private static final String API_RESPONSE = "{\"data\":{\"customer\":{\"audiences\":{\"edges\":[{\"node\":{\"name\":\"segment1\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"segment2\",\"state\":\"qualified\"}}]}}}}"; + private static final List API_RESPONSE = Arrays.asList(new String[]{"segment1", "segment2"}); @Mock ODPApiManager mockApiManager; diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java index 4d34d49b9..2e6aa611e 100644 --- a/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2022, Optimizely + * Copyright 2022-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -43,7 +43,7 @@ public class ODPSegmentManagerTest { @Mock ODPApiManager mockApiManager; - private static final String API_RESPONSE = "{\"data\":{\"customer\":{\"audiences\":{\"edges\":[{\"node\":{\"name\":\"segment1\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"segment2\",\"state\":\"qualified\"}}]}}}}"; + private static final List API_RESPONSE = Arrays.asList(new String[]{"segment1", "segment2"}); @Before public void setup() { diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java index 636ed8eec..040709d39 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely Inc. and contributors + * Copyright 2022-2023, Optimizely Inc. and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,8 @@ import com.optimizely.ab.OptimizelyHttpClient; import com.optimizely.ab.annotations.VisibleForTesting; +import com.optimizely.ab.odp.parser.ResponseJsonParser; +import com.optimizely.ab.odp.parser.ResponseJsonParserFactory; import org.apache.http.StatusLine; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpPost; @@ -28,6 +30,7 @@ import java.io.IOException; import java.io.UnsupportedEncodingException; import java.util.Iterator; +import java.util.List; import java.util.Set; public class DefaultODPApiManager implements ODPApiManager { @@ -144,7 +147,7 @@ String getSegmentsStringForRequest(Set segmentsList) { } */ @Override - public String fetchQualifiedSegments(String apiKey, String apiEndpoint, String userKey, String userValue, Set segmentsToCheck) { + public List fetchQualifiedSegments(String apiKey, String apiEndpoint, String userKey, String userValue, Set segmentsToCheck) { HttpPost request = new HttpPost(apiEndpoint); String segmentsString = getSegmentsStringForRequest(segmentsToCheck); @@ -174,11 +177,14 @@ public String fetchQualifiedSegments(String apiKey, String apiEndpoint, String u closeHttpResponse(response); return null; } - + ResponseJsonParser parser = ResponseJsonParserFactory.getParser(); try { - return EntityUtils.toString(response.getEntity()); + return parser.parseQualifiedSegments(EntityUtils.toString(response.getEntity())); } catch (IOException e) { logger.error("Error converting ODP segments response to string", e); + } catch (Exception e) { + logger.error("Audience segments fetch failed (Error Parsing Response)"); + logger.debug(e.getMessage()); } finally { closeHttpResponse(response); } @@ -201,7 +207,6 @@ public String fetchQualifiedSegments(String apiKey, String apiEndpoint, String u "type": "fullstack" } ] - Returns: 1. null, When there was a non-recoverable error and no retry is needed. 2. 0 If an unexpected error occurred and retrying can be useful. diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java index 93b728fba..a268cacc7 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely Inc. and contributors + * Copyright 2022-2023, Optimizely Inc. and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,13 +30,15 @@ import java.util.Arrays; import java.util.HashSet; +import java.util.List; import static org.junit.Assert.*; import static org.mockito.Matchers.any; import static org.mockito.Mockito.*; public class DefaultODPApiManagerTest { - private static final String validResponse = "{\"data\":{\"customer\":{\"audiences\":{\"edges\":[{\"node\":{\"name\":\"has_email\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"has_email_opted_in\",\"state\":\"qualified\"}}]}}}}"; + private static final List validResponse = Arrays.asList(new String[] {"has_email", "has_email_opted_in"}); + private static final String validRequestResponse = "{\"data\":{\"customer\":{\"audiences\":{\"edges\":[{\"node\":{\"name\":\"has_email\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"has_email_opted_in\",\"state\":\"qualified\"}}]}}}}"; @Rule public LogbackVerifier logbackVerifier = new LogbackVerifier(); @@ -55,7 +57,7 @@ private void setupHttpClient(int statusCode) throws Exception { when(statusLine.getStatusCode()).thenReturn(statusCode); when(httpResponse.getStatusLine()).thenReturn(statusLine); - when(httpResponse.getEntity()).thenReturn(new StringEntity(validResponse)); + when(httpResponse.getEntity()).thenReturn(new StringEntity(validRequestResponse)); when(mockHttpClient.execute(any(HttpPost.class))) .thenReturn(httpResponse); @@ -99,19 +101,19 @@ public void generateCorrectRequestBody() throws Exception { @Test public void returnResponseStringWhenStatusIs200() throws Exception { ODPApiManager apiManager = new DefaultODPApiManager(mockHttpClient); - String responseString = apiManager.fetchQualifiedSegments("key", "endPoint", "fs_user_id", "test_user", new HashSet<>(Arrays.asList("segment_1", "segment_2"))); + List response = apiManager.fetchQualifiedSegments("key", "endPoint", "fs_user_id", "test_user", new HashSet<>(Arrays.asList("segment_1", "segment_2"))); verify(mockHttpClient, times(1)).execute(any(HttpPost.class)); - assertEquals(validResponse, responseString); + assertEquals(validResponse, response); } @Test public void returnNullWhenStatusIsNot200AndLogError() throws Exception { setupHttpClient(500); ODPApiManager apiManager = new DefaultODPApiManager(mockHttpClient); - String responseString = apiManager.fetchQualifiedSegments("key", "endPoint", "fs_user_id", "test_user", new HashSet<>(Arrays.asList("segment_1", "segment_2"))); + List response = apiManager.fetchQualifiedSegments("key", "endPoint", "fs_user_id", "test_user", new HashSet<>(Arrays.asList("segment_1", "segment_2"))); verify(mockHttpClient, times(1)).execute(any(HttpPost.class)); logbackVerifier.expectMessage(Level.ERROR, "Unexpected response from ODP server, Response code: 500, null"); - assertNull(responseString); + assertNull(response); } @Test From c7f9099108b285f3f10d380d7e693e476d3f166e Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Fri, 10 Feb 2023 08:40:27 -0800 Subject: [PATCH 102/147] fix vuid support for fetchQualifiedSegments (#504) --- .../optimizely/ab/odp/ODPEventManager.java | 10 +--- .../com/optimizely/ab/odp/ODPManager.java | 4 ++ .../optimizely/ab/odp/ODPSegmentManager.java | 14 +++-- .../ab/odp/ODPEventManagerTest.java | 58 +++++++++++++++++++ .../com/optimizely/ab/odp/ODPManagerTest.java | 8 +++ .../ab/odp/ODPSegmentManagerTest.java | 16 +++++ 6 files changed, 98 insertions(+), 12 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java index ce218ed9d..8edb135aa 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java @@ -108,10 +108,10 @@ public void identifyUser(@Nullable String vuid, @Nullable String userId) { identifiers.put(ODPUserKey.VUID.getKeyString(), vuid); } if (userId != null) { - if (isVuid(userId)) { - identifiers.put(ODPUserKey.VUID.getKeyString(), userId); + if (ODPManager.isVuid(userId)) { + identifiers.put(ODPUserKey.VUID.getKeyString(), userId); } else { - identifiers.put(ODPUserKey.FS_USER_ID.getKeyString(), userId); + identifiers.put(ODPUserKey.FS_USER_ID.getKeyString(), userId); } } ODPEvent event = new ODPEvent("fullstack", "identified", identifiers, null); @@ -153,10 +153,6 @@ protected Map augmentCommonIdentifiers(Map sourc return identifiers; } - private boolean isVuid(String userId) { - return userId.startsWith("vuid_"); - } - private void processEvent(ODPEvent event) { if (!isRunning) { logger.warn("Failed to Process ODP Event. ODPEventManager is not running"); diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java index cacbcad0d..4f1ddc52d 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java @@ -68,6 +68,10 @@ public void close() { eventManager.stop(); } + public static boolean isVuid(String userId) { + return userId.startsWith("vuid_"); + } + public static Builder builder() { return new Builder(); } diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java index 275c45ee1..ce1aaffd3 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java @@ -52,11 +52,15 @@ public ODPSegmentManager(ODPApiManager apiManager, Integer cacheSize, Integer ca this.segmentsCache = new DefaultLRUCache<>(cacheSize, cacheTimeoutSeconds); } - public List getQualifiedSegments(String fsUserId) { - return getQualifiedSegments(ODPUserKey.FS_USER_ID, fsUserId, Collections.emptyList()); - } - public List getQualifiedSegments(String fsUserId, List options) { - return getQualifiedSegments(ODPUserKey.FS_USER_ID, fsUserId, options); + public List getQualifiedSegments(String userId) { + return getQualifiedSegments(userId, Collections.emptyList()); + } + public List getQualifiedSegments(String userId, List options) { + if (ODPManager.isVuid(userId)) { + return getQualifiedSegments(ODPUserKey.VUID, userId, options); + } else { + return getQualifiedSegments(ODPUserKey.FS_USER_ID, userId, options); + } } public List getQualifiedSegments(ODPUserKey userKey, String userValue) { diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java index 27de838f3..0c1c01a2c 100644 --- a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java @@ -211,6 +211,64 @@ public void prepareCorrectPayloadForIdentifyUser() throws InterruptedException { } } + @Test + public void identifyUserWithVuidAndUserId() throws InterruptedException { + ODPEventManager eventManager = spy(new ODPEventManager(mockApiManager)); + ArgumentCaptor captor = ArgumentCaptor.forClass(ODPEvent.class); + + eventManager.identifyUser("vuid_123", "test-user"); + verify(eventManager, times(1)).sendEvent(captor.capture()); + + ODPEvent event = captor.getValue(); + Map identifiers = event.getIdentifiers(); + assertEquals(identifiers.size(), 2); + assertEquals(identifiers.get("vuid"), "vuid_123"); + assertEquals(identifiers.get("fs_user_id"), "test-user"); + } + + @Test + public void identifyUserWithVuidOnly() throws InterruptedException { + ODPEventManager eventManager = spy(new ODPEventManager(mockApiManager)); + ArgumentCaptor captor = ArgumentCaptor.forClass(ODPEvent.class); + + eventManager.identifyUser("vuid_123", null); + verify(eventManager, times(1)).sendEvent(captor.capture()); + + ODPEvent event = captor.getValue(); + Map identifiers = event.getIdentifiers(); + assertEquals(identifiers.size(), 1); + assertEquals(identifiers.get("vuid"), "vuid_123"); + } + + @Test + public void identifyUserWithUserIdOnly() throws InterruptedException { + ODPEventManager eventManager = spy(new ODPEventManager(mockApiManager)); + ArgumentCaptor captor = ArgumentCaptor.forClass(ODPEvent.class); + + eventManager.identifyUser(null, "test-user"); + verify(eventManager, times(1)).sendEvent(captor.capture()); + + ODPEvent event = captor.getValue(); + Map identifiers = event.getIdentifiers(); + assertEquals(identifiers.size(), 1); + assertEquals(identifiers.get("fs_user_id"), "test-user"); + } + + @Test + public void identifyUserWithVuidAsUserId() throws InterruptedException { + ODPEventManager eventManager = spy(new ODPEventManager(mockApiManager)); + ArgumentCaptor captor = ArgumentCaptor.forClass(ODPEvent.class); + + eventManager.identifyUser(null, "vuid_123"); + verify(eventManager, times(1)).sendEvent(captor.capture()); + + ODPEvent event = captor.getValue(); + Map identifiers = event.getIdentifiers(); + assertEquals(identifiers.size(), 1); + // SDK will convert userId to vuid when userId has a valid vuid format. + assertEquals(identifiers.get("vuid"), "vuid_123"); + } + @Test public void applyUpdatedODPConfigWhenAvailable() throws InterruptedException { Mockito.reset(mockApiManager); diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerTest.java index 9673bfa69..1e1f59f29 100644 --- a/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerTest.java @@ -121,4 +121,12 @@ public void shouldGetSegmentManager() { odpManager = ODPManager.builder().withApiManager(mockApiManager).build(); assertNotNull(odpManager.getSegmentManager()); } + + @Test + public void isVuid() { + assertTrue(ODPManager.isVuid("vuid_123")); + assertFalse(ODPManager.isVuid("vuid123")); + assertFalse(ODPManager.isVuid("any_123")); + assertFalse(ODPManager.isVuid("")); + } } diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java index 2e6aa611e..3d71f0d2c 100644 --- a/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java @@ -398,4 +398,20 @@ public void noSegmentsInProjectAsync() throws InterruptedException { logbackVerifier.expectMessage(Level.DEBUG, "No Segments are used in the project, Not Fetching segments. Returning empty list"); } + + @Test + public void getQualifiedSegmentsWithUserId() { + ODPSegmentManager segmentManager = spy(new ODPSegmentManager(mockApiManager, mockCache)); + segmentManager.getQualifiedSegments("test-user"); + verify(segmentManager).getQualifiedSegments(ODPUserKey.FS_USER_ID, "test-user", Collections.emptyList()); + } + + @Test + public void getQualifiedSegmentsWithVuid() { + ODPSegmentManager segmentManager = spy(new ODPSegmentManager(mockApiManager, mockCache)); + segmentManager.getQualifiedSegments("vuid_123"); + // SDK will convert userId to vuid when userId has a valid vuid format. + verify(segmentManager).getQualifiedSegments(ODPUserKey.VUID, "vuid_123", Collections.emptyList()); + } + } From 989cf3fbc3bf14367e51ba86ec5c4c031f03412f Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Fri, 10 Mar 2023 13:40:39 -0800 Subject: [PATCH 103/147] [FSSDK-8953] update non-code-path resources for FX (#506) --- README.md | 129 ++++++++++++++++++++++++++++----------------- build.gradle | 2 +- core-api/README.md | 6 +-- 3 files changed, 85 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 376a8ec59..33e55928d 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,40 @@ -Optimizely Java SDK -=================== +# Optimizely Java SDK + [![Build Status](https://travis-ci.org/optimizely/java-sdk.svg?branch=master)](https://travis-ci.org/optimizely/java-sdk) [![Apache 2.0](https://img.shields.io/badge/license-APACHE%202.0-blue.svg)](http://www.apache.org/licenses/LICENSE-2.0) -This repository houses the Java SDK for use with Optimizely Full Stack and Optimizely Rollouts. +This repository houses the Java SDK for use with Optimizely Feature Experimentation and Optimizely Full Stack (legacy). + +Optimizely Feature Experimentation is an A/B testing and feature management tool for product development teams that enables you to experiment at every step. Using Optimizely Feature Experimentation allows for every feature on your roadmap to be an opportunity to discover hidden insights. Learn more at [Optimizely.com](https://www.optimizely.com/products/experiment/feature-experimentation/), or see the [developer documentation](https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/welcome). + +Optimizely Rollouts is [free feature flags](https://www.optimizely.com/free-feature-flagging/) for development teams. You can easily roll out and roll back features in any application without code deploys, mitigating risk for every feature on your roadmap. + +## Get started -Optimizely Full Stack is A/B testing and feature flag management for product development teams. Experiment in any application. Make every feature on your roadmap an opportunity to learn. Learn more at https://www.optimizely.com/platform/full-stack/, or see the [documentation](https://docs.developers.optimizely.com/experimentation/v4.0-full-stack/docs). +Refer to the [Java SDK's developer documentation](https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/java-sdk) for detailed instructions on getting started with using the SDK. -Optimizely Rollouts is free feature flags for development teams. Easily roll out and roll back features in any application without code deploys. Mitigate risk for every feature on your roadmap. Learn more at https://www.optimizely.com/rollouts/, or see the [documentation](https://docs.developers.optimizely.com/experimentation/v3.1.0-full-stack/docs/introduction-to-rollouts). +### Requirements +Java 8 or higher versions. -## Getting Started +### Install the SDK -### Installing the SDK +The Java SDK is distributed through Maven Central and is created with source and target compatibility of Java 1.8. The `core-api` and `httpclient` packages are [optimizely-sdk-core-api](https://mvnrepository.com/artifact/com.optimizely.ab/core-api) and [optimizely-sdk-httpclient](https://mvnrepository.com/artifact/com.optimizely.ab/core-httpclient-impl), respectively. -#### Gradle -The Java SDK is distributed through Maven Central and is created with source and target compatibility of Java 1.8. The `core-api` and `httpclient` packages are [optimizely-sdk-core-api](https://mvnrepository.com/artifact/com.optimizely.ab/core-api) and [optimizely-sdk-httpclient](https://mvnrepository.com/artifact/com.optimizely.ab/core-httpclient-impl), respectively. +`core-api` requires [org.slf4j:slf4j-api:1.7.16](https://mvnrepository.com/artifact/org.slf4j/slf4j-api/1.7.16) and a supported JSON parser. +We currently integrate with [Jackson](https://github.com/FasterXML/jackson), [GSON](https://github.com/google/gson), [json.org](http://www.json.org), and [json-simple](https://code.google.com/archive/p/json-simple); if any of those packages are available at runtime, they will be used by `core-api`. If none of those packages are already provided in your project's classpath, one will need to be added. + +`core-httpclient-impl` is an optional dependency that implements the event dispatcher and requires [org.apache.httpcomponents:httpclient:4.5.2](https://mvnrepository.com/artifact/org.apache.httpcomponents/httpclient/4.5.2). --- + **NOTE** Optimizely previously distributed the Java SDK through Bintray/JCenter. But, as of April 27, 2021, [Bintray/JCenter will become a read-only repository indefinitely](https://jfrog.com/blog/into-the-sunset-bintray-jcenter-gocenter-and-chartcenter/). The publish repository has been migrated to [MavenCentral](https://mvnrepository.com/artifact/com.optimizely.ab) for the SDK version 3.8.1 or later. --- - ``` repositories { mavenCentral() @@ -35,49 +44,29 @@ repositories { dependencies { compile 'com.optimizely.ab:core-api:{VERSION}' compile 'com.optimizely.ab:core-httpclient-impl:{VERSION}' - // The SDK integrates with multiple JSON parsers, here we use - // Jackson. + // The SDK integrates with multiple JSON parsers, here we use Jackson. compile 'com.fasterxml.jackson.core:jackson-core:2.7.1' compile 'com.fasterxml.jackson.core:jackson-annotations:2.7.1' compile 'com.fasterxml.jackson.core:jackson-databind:2.7.1' } -``` - -#### Dependencies - -`core-api` requires [org.slf4j:slf4j-api:1.7.16](https://mvnrepository.com/artifact/org.slf4j/slf4j-api/1.7.16) and a supported JSON parser. -We currently integrate with [Jackson](https://github.com/FasterXML/jackson), [GSON](https://github.com/google/gson), [json.org](http://www.json.org), -and [json-simple](https://code.google.com/archive/p/json-simple); if any of those packages are available at runtime, they will be used by `core-api`. -If none of those packages are already provided in your project's classpath, one will need to be added. `core-httpclient-impl` is an optional -dependency that implements the event dispatcher and requires [org.apache.httpcomponents:httpclient:4.5.2](https://mvnrepository.com/artifact/org.apache.httpcomponents/httpclient/4.5.2). -The supplied `pom` files on Bintray define module dependencies. - -### Feature Management Access -To access the Feature Management configuration in the Optimizely dashboard, please contact your Optimizely account executive. +``` -### Using the SDK -See the Optimizely Full Stack [developer documentation](https://docs.developers.optimizely.com/experimentation/v4.0-full-stack/docs/java-sdk) to learn how to set -up your first Java project and use the SDK. +## Use the Java SDK -## Development +See the Optimizely Feature Experimentation [developer documentation](https://docs.developers.optimizely.com/experimentation/v4.0-full-stack/docs/java-sdk) to learn how to set up your first Java project and use the SDK. -### Building the SDK -To build local jars which are outputted into the respective modules' `build/lib` directories: - -``` -./gradlew build -``` +## SDK Development ### Unit tests -#### Running all tests - You can run all unit tests with: ``` + ./gradlew test + ``` ### Checking for bugs @@ -85,7 +74,9 @@ You can run all unit tests with: We utilize [FindBugs](http://findbugs.sourceforge.net/) to identify possible bugs in the SDK. To run the check: ``` + ./gradlew check + ``` ### Benchmarking @@ -93,7 +84,9 @@ We utilize [FindBugs](http://findbugs.sourceforge.net/) to identify possible bug [JMH](http://openjdk.java.net/projects/code-tools/jmh/) benchmarks can be run through gradle: ``` + ./gradlew core-api:jmh + ``` Results are generated in `$buildDir/reports/jmh`. @@ -112,34 +105,74 @@ This software incorporates code from the following open source projects: #### core-api module -**SLF4J** [https://www.slf4j.org ](https://www.slf4j.org) -Copyright © 2004-2017 QOS.ch +**SLF4J** [https://www.slf4j.org ](https://www.slf4j.org) + +Copyright © 2004-2017 QOS.ch + License (MIT): [https://www.slf4j.org/license.html](https://www.slf4j.org/license.html) -**Jackson Annotations** [https://github.com/FasterXML/jackson-annotations](https://github.com/FasterXML/jackson-annotations) +**Jackson Annotations** [https://github.com/FasterXML/jackson-annotations](https://github.com/FasterXML/jackson-annotations) + License (Apache 2.0): [https://github.com/FasterXML/jackson-annotations/blob/master/src/main/resources/META-INF/LICENSE](https://github.com/FasterXML/jackson-annotations/blob/master/src/main/resources/META-INF/LICENSE) -**Gson** [https://github.com/google/gson ](https://github.com/google/gson) +**Gson** [https://github.com/google/gson ](https://github.com/google/gson) + Copyright © 2008 Google Inc. + License (Apache 2.0): [https://github.com/google/gson/blob/master/LICENSE](https://github.com/google/gson/blob/master/LICENSE) -**JSON-java** [https://github.com/stleary/JSON-java](https://github.com/stleary/JSON-java) -Copyright © 2002 JSON.org +**JSON-java** [https://github.com/stleary/JSON-java](https://github.com/stleary/JSON-java) + +Copyright © 2002 JSON.org + License (The JSON License): [https://github.com/stleary/JSON-java/blob/master/LICENSE](https://github.com/stleary/JSON-java/blob/master/LICENSE) -**JSON.simple** [https://code.google.com/archive/p/json-simple/](https://code.google.com/archive/p/json-simple/) -Copyright © January 2004 +**JSON.simple** [https://code.google.com/archive/p/json-simple/](https://code.google.com/archive/p/json-simple/) + +Copyright © January 2004 + License (Apache 2.0): [https://github.com/fangyidong/json-simple/blob/master/LICENSE.txt](https://github.com/fangyidong/json-simple/blob/master/LICENSE.txt) -**Jackson Databind** [https://github.com/FasterXML/jackson-databind](https://github.com/FasterXML/jackson-databind) +**Jackson Databind** [https://github.com/FasterXML/jackson-databind](https://github.com/FasterXML/jackson-databind) + License (Apache 2.0): [https://github.com/FasterXML/jackson-databind/blob/master/src/main/resources/META-INF/LICENSE](https://github.com/FasterXML/jackson-databind/blob/master/src/main/resources/META-INF/LICENSE) #### core-httpclient-impl module -**Gson** [https://github.com/google/gson ](https://github.com/google/gson) +**Gson** [https://github.com/google/gson ](https://github.com/google/gson) + Copyright © 2008 Google Inc. + License (Apache 2.0): [https://github.com/google/gson/blob/master/LICENSE](https://github.com/google/gson/blob/master/LICENSE) -**Apache HttpClient** [https://hc.apache.org/httpcomponents-client-ga/index.html ](https://hc.apache.org/httpcomponents-client-ga/index.html) +**Apache HttpClient** [https://hc.apache.org/httpcomponents-client-ga/index.html ](https://hc.apache.org/httpcomponents-client-ga/index.html) + Copyright © January 2004 + License (Apache 2.0): [https://github.com/apache/httpcomponents-client/blob/master/LICENSE.txt](https://github.com/apache/httpcomponents-client/blob/master/LICENSE.txt) + +### Other Optimzely SDKs + +- Agent - https://github.com/optimizely/agent + +- Android - https://github.com/optimizely/android-sdk + +- C# - https://github.com/optimizely/csharp-sdk + +- Flutter - https://github.com/optimizely/optimizely-flutter-sdk + +- Go - https://github.com/optimizely/go-sdk + +- Java - https://github.com/optimizely/java-sdk + +- JavaScript - https://github.com/optimizely/javascript-sdk + +- PHP - https://github.com/optimizely/php-sdk + +- Python - https://github.com/optimizely/python-sdk + +- React - https://github.com/optimizely/react-sdk + +- Ruby - https://github.com/optimizely/ruby-sdk + +- Swift - https://github.com/optimizely/swift-sdk diff --git a/build.gradle b/build.gradle index 4c167014f..b8405e39b 100644 --- a/build.gradle +++ b/build.gradle @@ -223,7 +223,7 @@ def customizePom(pom, title) { name title url 'https://github.com/optimizely/java-sdk' - description 'The Java SDK for Optimizely Full Stack (feature flag management for product development teams)' + description 'The Java SDK for Optimizely Feature Experimentation, Optimizely Full Stack (legacy), and Optimizely Rollouts' licenses { license { name 'The Apache Software License, Version 2.0' diff --git a/core-api/README.md b/core-api/README.md index 13504566f..91d439ec7 100644 --- a/core-api/README.md +++ b/core-api/README.md @@ -1,7 +1,7 @@ # Java SDK Core API -This package contains the core APIs and interfaces for the Optimizely Full Stack API in Java. +This package contains the core APIs and interfaces for the Optimizely Feature Experimentation API in Java. -Full product documentation is in the [Optimizely developers documentation](https://docs.developers.optimizely.com/full-stack/docs/welcome). +Full product documentation is in the [Optimizely developers documentation](https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/welcome). ## Installation @@ -22,7 +22,7 @@ compile 'com.optimizely.ab:core-api:{VERSION}' ## Optimizely [`Optimizely`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/Optimizely.java) -provides top level API access to the Full Stack project. +provides top level API access to the Feature Experimentation project. ### Usage ```Java From 2de31040b71d59605848600a38588542a481153b Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Fri, 10 Mar 2023 14:52:38 -0800 Subject: [PATCH 104/147] chore: prepare for release 3.10.3 (#507) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50b9c6cf0..2fab5c6e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Optimizely Java X SDK Changelog +## [3.10.3] +March 10th, 2023 + +### Fixes +We updated our README.md and other non-functional code to reflect that this SDK supports both Optimizely Feature Experimentation and Optimizely Full Stack ([#506](https://github.com/optimizely/java-sdk/pull/506)). + ## [3.10.2] March 17th, 2022 From d9f19b35edba56824ebef40f4f1ff227dd2d4809 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Mon, 13 Mar 2023 10:30:51 -0700 Subject: [PATCH 105/147] fix release date (#508) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fab5c6e7..ac028c780 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Optimizely Java X SDK Changelog ## [3.10.3] -March 10th, 2023 +March 13th, 2023 ### Fixes We updated our README.md and other non-functional code to reflect that this SDK supports both Optimizely Feature Experimentation and Optimizely Full Stack ([#506](https://github.com/optimizely/java-sdk/pull/506)). From 9e02c7e24d09fedf058203c56617bcf6546f2f37 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Tue, 21 Mar 2023 08:45:05 -0700 Subject: [PATCH 106/147] fix(odp): check odp identifiers not empty before sending (#509) --- .../java/com/optimizely/ab/Optimizely.java | 9 +++++++++ .../java/com/optimizely/ab/odp/ODPEvent.java | 14 ++++++++++---- .../optimizely/ab/odp/ODPEventManager.java | 13 ++++++++++--- .../ab/odp/ODPEventManagerTest.java | 19 +++++++++++++++++-- 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 5f41bcd6e..fe28a8bb4 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1468,6 +1468,15 @@ public ODPManager getODPManager() { return odpManager; } + + /** + * Send an event to the ODP server. + * + * @param type the event type (default = "fullstack"). + * @param action the event action name. + * @param identifiers a dictionary for identifiers. The caller must provide at least one key-value pair unless non-empty common identifiers have been set already with {@link ODPManager.Builder#withUserCommonIdentifiers(Map) }. + * @param data a dictionary for associated data. The default event data will be added to this data before sending to the ODP server. + */ public void sendODPEvent(@Nullable String type, @Nonnull String action, @Nullable Map identifiers, @Nullable Map data) { if (odpManager != null) { ODPEvent event = new ODPEvent(type, action, identifiers, data); diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java index 2ed2c1e76..71e054df0 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java @@ -24,10 +24,10 @@ public class ODPEvent { public static final String EVENT_TYPE_FULLSTACK = "fullstack"; - private String type; - private String action; - private Map identifiers; - private Map data; + @Nonnull private String type; + @Nonnull private String action; + @Nonnull private Map identifiers; + @Nonnull private Map data; public ODPEvent(@Nullable String type, @Nonnull String action, @Nullable Map identifiers, @Nullable Map data) { this.type = type == null ? EVENT_TYPE_FULLSTACK : type; @@ -84,4 +84,10 @@ public Boolean isDataValid() { } return true; } + + @Transient + public Boolean isIdentifiersValid() { + return !identifiers.isEmpty(); + } + } diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java index 8edb135aa..352c47eac 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java @@ -119,12 +119,19 @@ public void identifyUser(@Nullable String vuid, @Nullable String userId) { } public void sendEvent(ODPEvent event) { + event.setData(augmentCommonData(event.getData())); + event.setIdentifiers(augmentCommonIdentifiers(event.getIdentifiers())); + + if (!event.isIdentifiersValid()) { + logger.error("ODP event send failed (event identifiers must have at least one key-value pair)"); + return; + } + if (!event.isDataValid()) { - logger.error("ODP event send failed (ODP data is not valid)"); + logger.error("ODP event send failed (event data is not valid)"); return; } - event.setData(augmentCommonData(event.getData())); - event.setIdentifiers(augmentCommonIdentifiers(event.getIdentifiers())); + processEvent(event); } diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java index 0c1c01a2c..d2163a6b6 100644 --- a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java @@ -61,7 +61,7 @@ public void logAndDiscardEventWhenEventManagerIsNotRunning() { ODPConfig odpConfig = new ODPConfig("key", "host", null); ODPEventManager eventManager = new ODPEventManager(mockApiManager); eventManager.updateSettings(odpConfig); - ODPEvent event = new ODPEvent("test-type", "test-action", Collections.emptyMap(), Collections.emptyMap()); + ODPEvent event = new ODPEvent("test-type", "test-action", Collections.singletonMap("any-key", "any-value"), Collections.emptyMap()); eventManager.sendEvent(event); logbackVerifier.expectMessage(Level.WARN, "Failed to Process ODP Event. ODPEventManager is not running"); } @@ -72,7 +72,7 @@ public void logAndDiscardEventWhenODPConfigNotReady() { ODPEventManager eventManager = new ODPEventManager(mockApiManager); eventManager.updateSettings(odpConfig); eventManager.start(); - ODPEvent event = new ODPEvent("test-type", "test-action", Collections.emptyMap(), Collections.emptyMap()); + ODPEvent event = new ODPEvent("test-type", "test-action", Collections.singletonMap("any-key", "any-value"), Collections.emptyMap()); eventManager.sendEvent(event); logbackVerifier.expectMessage(Level.DEBUG, "Unable to Process ODP Event. ODPConfig is not ready."); } @@ -92,6 +92,21 @@ public void dispatchEventsInCorrectNumberOfBatches() throws InterruptedException Mockito.verify(mockApiManager, times(3)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); } + @Test + public void logAndDiscardEventWhenIdentifiersEmpty() throws InterruptedException { + int flushInterval = 0; + ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); + ODPEventManager eventManager = new ODPEventManager(mockApiManager, null, flushInterval); + eventManager.updateSettings(odpConfig); + eventManager.start(); + + ODPEvent event = new ODPEvent("test-type", "test-action", Collections.emptyMap(), Collections.emptyMap()); + eventManager.sendEvent(event); + Thread.sleep(500); + Mockito.verify(mockApiManager, never()).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); + logbackVerifier.expectMessage(Level.ERROR, "ODP event send failed (event identifiers must have at least one key-value pair)"); + } + @Test public void dispatchEventsWithCorrectPayload() throws InterruptedException { Mockito.reset(mockApiManager); From 2022b49a0a2b4d5046a32aa6e62f53f3b9b158a1 Mon Sep 17 00:00:00 2001 From: Muhammad Noman Date: Tue, 25 Apr 2023 22:54:06 +0500 Subject: [PATCH 107/147] [FSSDK-9054] fix(odp): flush odp events instantly upon calling close (#511) * corrected flush all test and added shutdown event instead of shutdown boolean. * Added check --------- Co-authored-by: NomanShoaib --- .../com/optimizely/ab/odp/ODPEventManager.java | 18 ++++++++++-------- .../optimizely/ab/odp/ODPEventManagerTest.java | 7 ++++--- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java index 352c47eac..0a93627b7 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java @@ -35,6 +35,7 @@ public class ODPEventManager { private static final int DEFAULT_FLUSH_INTERVAL = 1000; private static final int MAX_RETRIES = 3; private static final String EVENT_URL_PATH = "/v3/events"; + private static final Object SHUTDOWN_SIGNAL = new Object(); private final int queueSize; private final int batchSize; @@ -188,8 +189,6 @@ public void stop() { private class EventDispatcherThread extends Thread { - private volatile boolean shouldStop = false; - private final List currentBatch = new ArrayList<>(); private long nextFlushTime = new Date().getTime(); @@ -198,7 +197,7 @@ private class EventDispatcherThread extends Thread { public void run() { while (true) { try { - Object nextEvent; + Object nextEvent = null; // If batch has events, set the timeout to remaining time for flush interval, // otherwise wait for the new event indefinitely @@ -213,9 +212,6 @@ public void run() { if (!currentBatch.isEmpty()) { flush(); } - if (shouldStop) { - break; - } continue; } @@ -228,7 +224,11 @@ public void run() { // Batch starting, create a new flush time nextFlushTime = new Date().getTime() + flushInterval; } - + if (nextEvent == SHUTDOWN_SIGNAL) { + flush(); + logger.info("Received shutdown signal."); + break; + } currentBatch.add((ODPEvent) nextEvent); if (currentBatch.size() >= batchSize) { @@ -268,7 +268,9 @@ private void flush() { } public void signalStop() { - shouldStop = true; + if (!eventQueue.offer(SHUTDOWN_SIGNAL)) { + logger.error("Failed to Process Shutdown odp Event. Event Queue is not accepting any more events"); + } } } diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java index d2163a6b6..eb005f832 100644 --- a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java @@ -182,18 +182,19 @@ public void retryFailedEvents() throws InterruptedException { @Test public void shouldFlushAllScheduledEventsBeforeStopping() throws InterruptedException { + int flushInterval = 20000; Mockito.reset(mockApiManager); Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); - ODPEventManager eventManager = new ODPEventManager(mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager, null, flushInterval); eventManager.updateSettings(odpConfig); eventManager.start(); - for (int i = 0; i < 25; i++) { + for (int i = 0; i < 8; i++) { eventManager.sendEvent(getEvent(i)); } eventManager.stop(); Thread.sleep(1500); - Mockito.verify(mockApiManager, times(3)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); + Mockito.verify(mockApiManager, times(1)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); logbackVerifier.expectMessage(Level.DEBUG, "Exiting ODP Event Dispatcher Thread."); } From 8dc9a74bcc662c9f8a7e0220d084b527fb36dc98 Mon Sep 17 00:00:00 2001 From: Muhammad Noman Date: Tue, 25 Apr 2023 23:45:33 +0500 Subject: [PATCH 108/147] [FSSDK-9076] fix: send odp event empty values (#513) * [FSSDK-9076] fix: send odp event empty values * nit fix --------- Co-authored-by: NomanShoaib --- .../java/com/optimizely/ab/Optimizely.java | 5 + .../java/com/optimizely/ab/odp/ODPEvent.java | 2 +- .../optimizely/ab/odp/ODPEventManager.java | 28 +++- .../com/optimizely/ab/odp/ODPUserKey.java | 6 +- .../com/optimizely/ab/OptimizelyTest.java | 122 ++++++++++++++++++ .../ab/odp/ODPEventManagerTest.java | 67 ++++++++++ 6 files changed, 226 insertions(+), 4 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index fe28a8bb4..c8ca3d55b 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1479,6 +1479,11 @@ public ODPManager getODPManager() { */ public void sendODPEvent(@Nullable String type, @Nonnull String action, @Nullable Map identifiers, @Nullable Map data) { if (odpManager != null) { + if (action == null || action.trim().isEmpty()) { + logger.error("ODP action is not valid (cannot be empty)."); + return; + } + ODPEvent event = new ODPEvent(type, action, identifiers, data); odpManager.getEventManager().sendEvent(event); } else { diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java index 71e054df0..a505bf6d1 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java @@ -30,7 +30,7 @@ public class ODPEvent { @Nonnull private Map data; public ODPEvent(@Nullable String type, @Nonnull String action, @Nullable Map identifiers, @Nullable Map data) { - this.type = type == null ? EVENT_TYPE_FULLSTACK : type; + this.type = type == null || type.trim().isEmpty() ? EVENT_TYPE_FULLSTACK : type; this.action = action; this.identifiers = identifiers != null ? identifiers : Collections.emptyMap(); this.data = data != null ? data : Collections.emptyMap(); diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java index 0a93627b7..ba668a138 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java @@ -35,6 +35,10 @@ public class ODPEventManager { private static final int DEFAULT_FLUSH_INTERVAL = 1000; private static final int MAX_RETRIES = 3; private static final String EVENT_URL_PATH = "/v3/events"; + private static final List FS_USER_ID_MATCHES = new ArrayList<>(Arrays.asList( + ODPUserKey.FS_USER_ID.getKeyString(), + ODPUserKey.FS_USER_ID_ALIAS.getKeyString() + )); private static final Object SHUTDOWN_SIGNAL = new Object(); private final int queueSize; @@ -121,7 +125,7 @@ public void identifyUser(@Nullable String vuid, @Nullable String userId) { public void sendEvent(ODPEvent event) { event.setData(augmentCommonData(event.getData())); - event.setIdentifiers(augmentCommonIdentifiers(event.getIdentifiers())); + event.setIdentifiers(convertCriticalIdentifiers(augmentCommonIdentifiers(event.getIdentifiers()))); if (!event.isIdentifiersValid()) { logger.error("ODP event send failed (event identifiers must have at least one key-value pair)"); @@ -133,6 +137,7 @@ public void sendEvent(ODPEvent event) { return; } + processEvent(event); } @@ -158,6 +163,27 @@ protected Map augmentCommonIdentifiers(Map sourc Map identifiers = new HashMap<>(); identifiers.putAll(userCommonIdentifiers); identifiers.putAll(sourceIdentifiers); + + return identifiers; + } + + private static Map convertCriticalIdentifiers(Map identifiers) { + + if (identifiers.containsKey(ODPUserKey.FS_USER_ID.getKeyString())) { + return identifiers; + } + + List> identifiersList = new ArrayList<>(identifiers.entrySet()); + + for (Map.Entry kvp : identifiersList) { + + if (FS_USER_ID_MATCHES.contains(kvp.getKey().toLowerCase())) { + identifiers.remove(kvp.getKey()); + identifiers.put(ODPUserKey.FS_USER_ID.getKeyString(), kvp.getValue()); + break; + } + } + return identifiers; } diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPUserKey.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPUserKey.java index d7cdbb641..ef0bce3ff 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPUserKey.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPUserKey.java @@ -1,6 +1,6 @@ /** * - * Copyright 2022, Optimizely + * Copyright 2022-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,7 +20,9 @@ public enum ODPUserKey { VUID("vuid"), - FS_USER_ID("fs_user_id"); + FS_USER_ID("fs_user_id"), + + FS_USER_ID_ALIAS("fs-user-id"); private final String keyString; diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java index 705ce1cb6..700780f75 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -4762,6 +4762,128 @@ public void sendODPEvent() { assertEquals(data, eventArgument.getValue().getData()); } + @Test + @SuppressFBWarnings(value = "NP_NONNULL_PARAM_VIOLATION", justification = "Testing nullness contract violation") + public void sendODPEventErrorNullAction() { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(validProjectConfig); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + verify(mockODPEventManager).start(); + + Map identifiers = new HashMap<>(); + identifiers.put("id1", "value1"); + identifiers.put("id2", "value2"); + + Map data = new HashMap<>(); + data.put("sdk", "java"); + data.put("revision", 52); + + optimizely.sendODPEvent("fullstack", null, identifiers, data); + logbackVerifier.expectMessage(Level.ERROR, "ODP action is not valid (cannot be empty)."); + } + + @Test + public void sendODPEventErrorEmptyAction() { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(validProjectConfig); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + verify(mockODPEventManager).start(); + + Map identifiers = new HashMap<>(); + identifiers.put("id1", "value1"); + identifiers.put("id2", "value2"); + + Map data = new HashMap<>(); + data.put("sdk", "java"); + data.put("revision", 52); + + optimizely.sendODPEvent("fullstack", "", identifiers, data); + logbackVerifier.expectMessage(Level.ERROR, "ODP action is not valid (cannot be empty)."); + } + + @Test + @SuppressFBWarnings(value = "NP_NONNULL_PARAM_VIOLATION", justification = "Testing nullness contract violation") + public void sendODPEventNullType() { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(validProjectConfig); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + verify(mockODPEventManager).start(); + + Map identifiers = new HashMap<>(); + identifiers.put("id1", "value1"); + identifiers.put("id2", "value2"); + + Map data = new HashMap<>(); + data.put("sdk", "java"); + data.put("revision", 52); + + optimizely.sendODPEvent(null, "identify", identifiers, data); + ArgumentCaptor eventArgument = ArgumentCaptor.forClass(ODPEvent.class); + verify(mockODPEventManager).sendEvent(eventArgument.capture()); + + assertEquals("fullstack", eventArgument.getValue().getType()); + assertEquals("identify", eventArgument.getValue().getAction()); + assertEquals(identifiers, eventArgument.getValue().getIdentifiers()); + assertEquals(data, eventArgument.getValue().getData()); + } + + @Test + public void sendODPEventEmptyType() { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(validProjectConfig); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + verify(mockODPEventManager).start(); + + Map identifiers = new HashMap<>(); + identifiers.put("id1", "value1"); + identifiers.put("id2", "value2"); + + Map data = new HashMap<>(); + data.put("sdk", "java"); + data.put("revision", 52); + + optimizely.sendODPEvent("", "identify", identifiers, data); + ArgumentCaptor eventArgument = ArgumentCaptor.forClass(ODPEvent.class); + verify(mockODPEventManager).sendEvent(eventArgument.capture()); + + assertEquals("fullstack", eventArgument.getValue().getType()); + assertEquals("identify", eventArgument.getValue().getAction()); + assertEquals(identifiers, eventArgument.getValue().getIdentifiers()); + assertEquals(data, eventArgument.getValue().getData()); + } + @Test public void sendODPEventError() { ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java index eb005f832..8a7546300 100644 --- a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java @@ -227,6 +227,73 @@ public void prepareCorrectPayloadForIdentifyUser() throws InterruptedException { } } + @Test + public void preparePayloadForIdentifyUserWithVariationsOfFsUserId() throws InterruptedException { + Mockito.reset(mockApiManager); + Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); + int flushInterval = 1; + ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); + ODPEventManager eventManager = new ODPEventManager(mockApiManager, null, flushInterval); + eventManager.updateSettings(odpConfig); + eventManager.start(); + ODPEvent event1 = new ODPEvent("fullstack", + "identified", + new HashMap() {{ + put("fs-user-id", "123"); + }}, null); + + ODPEvent event2 = new ODPEvent("fullstack", + "identified", + new HashMap() {{ + put("FS-user-ID", "123"); + }}, null); + + ODPEvent event3 = new ODPEvent("fullstack", + "identified", + new HashMap() {{ + put("FS_USER_ID", "123"); + put("fs.user.id", "456"); + }}, null); + + ODPEvent event4 = new ODPEvent("fullstack", + "identified", + new HashMap() {{ + put("fs_user_id", "123"); + put("fsuserid", "456"); + }}, null); + List> expectedIdentifiers = new ArrayList>() {{ + add(new HashMap() {{ + put("fs_user_id", "123"); + }}); + add(new HashMap() {{ + put("fs_user_id", "123"); + }}); + add(new HashMap() {{ + put("fs_user_id", "123"); + put("fs.user.id", "456"); + }}); + add(new HashMap() {{ + put("fs_user_id", "123"); + put("fsuserid", "456"); + }}); + }}; + eventManager.sendEvent(event1); + eventManager.sendEvent(event2); + eventManager.sendEvent(event3); + eventManager.sendEvent(event4); + + Thread.sleep(1500); + Mockito.verify(mockApiManager, times(1)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), payloadCaptor.capture()); + + String payload = payloadCaptor.getValue(); + JSONArray events = new JSONArray(payload); + assertEquals(4, events.length()); + for (int i = 0; i < events.length(); i++) { + JSONObject event = events.getJSONObject(i); + assertEquals(event.getJSONObject("identifiers").toMap(), expectedIdentifiers.get(i)); + } + } + @Test public void identifyUserWithVuidAndUserId() throws InterruptedException { ODPEventManager eventManager = spy(new ODPEventManager(mockApiManager)); From 791ae41785e403f840301ec4f5d79a6b0de34772 Mon Sep 17 00:00:00 2001 From: Muhammad Noman Date: Wed, 26 Apr 2023 00:35:44 +0500 Subject: [PATCH 109/147] [FSSDK-9056] fix: add config check in odp methods (#512) * [FSSDK-9056] fix: add config check in odp methods * Fixed tests by passing valid config * spotbug fix --------- Co-authored-by: NomanShoaib --- .../java/com/optimizely/ab/Optimizely.java | 21 +++++ .../com/optimizely/ab/OptimizelyTest.java | 31 ++++++- .../ab/OptimizelyUserContextTest.java | 83 +++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index c8ca3d55b..acd9d05fd 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1445,6 +1445,11 @@ public int addNotificationHandler(Class clazz, NotificationHandler han } public List fetchQualifiedSegments(String userId, @Nonnull List segmentOptions) { + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing fetchQualifiedSegments call."); + return null; + } if (odpManager != null) { synchronized (odpManager) { return odpManager.getSegmentManager().getQualifiedSegments(userId, segmentOptions); @@ -1455,6 +1460,12 @@ public List fetchQualifiedSegments(String userId, @Nonnull List segmentOptions) { + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing fetchQualifiedSegments call."); + callback.onCompleted(null); + return; + } if (odpManager == null) { logger.error("Audience segments fetch failed (ODP is not enabled)."); callback.onCompleted(null); @@ -1478,6 +1489,11 @@ public ODPManager getODPManager() { * @param data a dictionary for associated data. The default event data will be added to this data before sending to the ODP server. */ public void sendODPEvent(@Nullable String type, @Nonnull String action, @Nullable Map identifiers, @Nullable Map data) { + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing sendODPEvent call."); + return; + } if (odpManager != null) { if (action == null || action.trim().isEmpty()) { logger.error("ODP action is not valid (cannot be empty)."); @@ -1492,6 +1508,11 @@ public void sendODPEvent(@Nullable String type, @Nonnull String action, @Nullabl } public void identifyUser(@Nonnull String userId) { + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing identifyUser call."); + return; + } ODPManager odpManager = getODPManager(); if (odpManager != null) { odpManager.getEventManager().identifyUser(userId); diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java index 700780f75..260de9945 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -4733,6 +4733,7 @@ public void initODPManagerWithProjectConfig() throws IOException { @Test public void sendODPEvent() { ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(validProjectConfig); ODPEventManager mockODPEventManager = mock(ODPEventManager.class); ODPManager mockODPManager = mock(ODPManager.class); @@ -4762,6 +4763,33 @@ public void sendODPEvent() { assertEquals(data, eventArgument.getValue().getData()); } + @Test + public void sendODPEventInvalidConfig() { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(null); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + verify(mockODPEventManager).start(); + + Map identifiers = new HashMap<>(); + identifiers.put("id1", "value1"); + identifiers.put("id2", "value2"); + + Map data = new HashMap<>(); + data.put("sdk", "java"); + data.put("revision", 52); + + optimizely.sendODPEvent("fullstack", "identify", identifiers, data); + logbackVerifier.expectMessage(Level.ERROR, "Optimizely instance is not valid, failing sendODPEvent call."); + } + @Test @SuppressFBWarnings(value = "NP_NONNULL_PARAM_VIOLATION", justification = "Testing nullness contract violation") public void sendODPEventErrorNullAction() { @@ -4887,7 +4915,7 @@ public void sendODPEventEmptyType() { @Test public void sendODPEventError() { ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); - + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(validProjectConfig); Optimizely optimizely = Optimizely.builder() .withConfigManager(mockProjectConfigManager) .build(); @@ -4907,6 +4935,7 @@ public void sendODPEventError() { @Test public void identifyUser() { ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(validProjectConfig); ODPEventManager mockODPEventManager = mock(ODPEventManager.class); ODPManager mockODPManager = mock(ODPManager.class); diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java index 8f8bae834..7c479f147 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -1628,6 +1628,8 @@ public void fetchQualifiedSegments() { Mockito.when(mockODPManager.getSegmentManager()).thenReturn(mockODPSegmentManager); Optimizely optimizely = Optimizely.builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) .withODPManager(mockODPManager) .build(); @@ -1640,9 +1642,33 @@ public void fetchQualifiedSegments() { verify(mockODPSegmentManager).getQualifiedSegments("test-user", Collections.singletonList(ODPSegmentOption.RESET_CACHE)); } + @Test + public void fetchQualifiedSegmentsErrorWhenConfigIsInvalid() { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(null); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPSegmentManager mockODPSegmentManager = mock(ODPSegmentManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Mockito.when(mockODPManager.getSegmentManager()).thenReturn(mockODPSegmentManager); + + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + OptimizelyUserContext userContext = optimizely.createUserContext("test-user"); + + assertFalse(userContext.fetchQualifiedSegments()); + logbackVerifier.expectMessage(Level.ERROR, "Optimizely instance is not valid, failing fetchQualifiedSegments call."); + } + @Test public void fetchQualifiedSegmentsError() { Optimizely optimizely = Optimizely.builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) .build(); OptimizelyUserContext userContext = optimizely.createUserContext("test-user"); @@ -1667,6 +1693,8 @@ public void fetchQualifiedSegmentsAsync() throws InterruptedException { Mockito.when(mockODPManager.getSegmentManager()).thenReturn(mockODPSegmentManager); Optimizely optimizely = Optimizely.builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) .withODPManager(mockODPManager) .build(); @@ -1698,6 +1726,8 @@ public void fetchQualifiedSegmentsAsync() throws InterruptedException { @Test public void fetchQualifiedSegmentsAsyncError() throws InterruptedException { Optimizely optimizely = Optimizely.builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) .build(); OptimizelyUserContext userContext = optimizely.createUserContext("test-user"); @@ -1713,6 +1743,57 @@ public void fetchQualifiedSegmentsAsyncError() throws InterruptedException { logbackVerifier.expectMessage(Level.ERROR, "Audience segments fetch failed (ODP is not enabled)."); } + @Test + public void fetchQualifiedSegmentsAsyncErrorWhenConfigIsInvalid() throws InterruptedException { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(null); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPSegmentManager mockODPSegmentManager = mock(ODPSegmentManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Mockito.when(mockODPManager.getSegmentManager()).thenReturn(mockODPSegmentManager); + + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + OptimizelyUserContext userContext = optimizely.createUserContext("test-user"); + + CountDownLatch countDownLatch = new CountDownLatch(1); + userContext.fetchQualifiedSegments((Boolean isFetchSuccessful) -> { + assertFalse(isFetchSuccessful); + countDownLatch.countDown(); + }); + + countDownLatch.await(); + assertEquals(null, userContext.getQualifiedSegments()); + logbackVerifier.expectMessage(Level.ERROR, "Optimizely instance is not valid, failing fetchQualifiedSegments call."); + } + + @Test + public void identifyUserErrorWhenConfigIsInvalid() { + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPSegmentManager mockODPSegmentManager = mock(ODPSegmentManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(null); + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Mockito.when(mockODPManager.getSegmentManager()).thenReturn(mockODPSegmentManager); + + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + optimizely.createUserContext("test-user"); + verify(mockODPEventManager, never()).identifyUser("test-user"); + Mockito.reset(mockODPEventManager); + + logbackVerifier.expectMessage(Level.ERROR, "Optimizely instance is not valid, failing identifyUser call."); + } + @Test public void identifyUser() { ODPEventManager mockODPEventManager = mock(ODPEventManager.class); @@ -1723,6 +1804,8 @@ public void identifyUser() { Mockito.when(mockODPManager.getSegmentManager()).thenReturn(mockODPSegmentManager); Optimizely optimizely = Optimizely.builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) .withODPManager(mockODPManager) .build(); From 562310f29a7ac2b569f11337c9e9a687ebefa062 Mon Sep 17 00:00:00 2001 From: Muhammad Noman Date: Thu, 4 May 2023 04:49:47 +0500 Subject: [PATCH 110/147] [FSSDK-9127] fix: Handle ODP INVALID_IDENTIFIER_EXCEPTION code (#514) * [FSSDK-9127] Fix: Verifiying that ODP INVALID_IDENTIFIER_EXCEPTION code is handled gracefully. * Parsed errors as per new odp update * support case when code key is missing and classification key exist. --------- Co-authored-by: NomanShoaib --- .../ab/odp/parser/impl/GsonParser.java | 17 +++++----- .../ab/odp/parser/impl/JacksonParser.java | 15 ++++----- .../ab/odp/parser/impl/JsonParser.java | 18 ++++++----- .../ab/odp/parser/impl/JsonSimpleParser.java | 16 +++++----- .../ab/odp/parser/ResponseJsonParserTest.java | 31 +++++++++++++++++-- .../ab/odp/DefaultODPApiManager.java | 3 +- 6 files changed, 65 insertions(+), 35 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/GsonParser.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/GsonParser.java index b27d65078..70136536f 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/GsonParser.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/GsonParser.java @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely Inc. and contributors + * Copyright 2022-2023, Optimizely Inc. and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -34,15 +34,16 @@ public List parseQualifiedSegments(String responseJson) { JsonObject root = JsonParser.parseString(responseJson).getAsJsonObject(); if (root.has("errors")) { - JsonArray errors = root.getAsJsonArray("errors"); - StringBuilder logMessage = new StringBuilder(); - for (int i = 0; i < errors.size(); i++) { - if (i > 0) { - logMessage.append(", "); + JsonArray errors = root.getAsJsonArray("errors"); + JsonObject extensions = errors.get(0).getAsJsonObject().get("extensions").getAsJsonObject(); + if (extensions != null) { + if (extensions.has("code") && extensions.get("code").getAsString().equals("INVALID_IDENTIFIER_EXCEPTION")) { + logger.warn("Audience segments fetch failed (invalid identifier)"); + } else { + String errorMessage = extensions.get("classification") == null ? "decode error" : extensions.get("classification").getAsString(); + logger.error("Audience segments fetch failed (" + errorMessage + ")"); } - logMessage.append(errors.get(i).getAsJsonObject().get("message").getAsString()); } - logger.error(logMessage.toString()); return null; } diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JacksonParser.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JacksonParser.java index f1a38eca7..b9a2b668f 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JacksonParser.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JacksonParser.java @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely Inc. and contributors + * Copyright 2022-2023, Optimizely Inc. and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -39,14 +39,15 @@ public List parseQualifiedSegments(String responseJson) { if (root.has("errors")) { JsonNode errors = root.path("errors"); - StringBuilder logMessage = new StringBuilder(); - for (int i = 0; i < errors.size(); i++) { - if (i > 0) { - logMessage.append(", "); + JsonNode extensions = errors.get(0).path("extensions"); + if (extensions != null) { + if (extensions.has("code") && extensions.path("code").asText().equals("INVALID_IDENTIFIER_EXCEPTION")) { + logger.warn("Audience segments fetch failed (invalid identifier)"); + } else { + String errorMessage = extensions.has("classification") ? extensions.path("classification").asText() : "decode error"; + logger.error("Audience segments fetch failed (" + errorMessage + ")"); } - logMessage.append(errors.get(i).path("message")); } - logger.error(logMessage.toString()); return null; } diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonParser.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonParser.java index fcae748a4..e0e23c366 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonParser.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonParser.java @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely Inc. and contributors + * Copyright 2022-2023, Optimizely Inc. and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -35,15 +35,17 @@ public List parseQualifiedSegments(String responseJson) { JSONObject root = new JSONObject(responseJson); if (root.has("errors")) { - JSONArray errors = root.getJSONArray("errors"); - StringBuilder logMessage = new StringBuilder(); - for (int i = 0; i < errors.length(); i++) { - if (i > 0) { - logMessage.append(", "); + JSONArray errors = root.getJSONArray("errors"); + JSONObject extensions = errors.getJSONObject(0).getJSONObject("extensions"); + if (extensions != null) { + if (extensions.has("code") && extensions.getString("code").equals("INVALID_IDENTIFIER_EXCEPTION")) { + logger.warn("Audience segments fetch failed (invalid identifier)"); + } else { + String errorMessage = extensions.has("classification") ? + extensions.getString("classification") : "decode error"; + logger.error("Audience segments fetch failed (" + errorMessage + ")"); } - logMessage.append(errors.getJSONObject(i).getString("message")); } - logger.error(logMessage.toString()); return null; } diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonSimpleParser.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonSimpleParser.java index 1bee81b0a..de444e3c2 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonSimpleParser.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonSimpleParser.java @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely Inc. and contributors + * Copyright 2022-2023, Optimizely Inc. and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -36,17 +36,17 @@ public List parseQualifiedSegments(String responseJson) { JSONObject root = null; try { root = (JSONObject) parser.parse(responseJson); - if (root.containsKey("errors")) { JSONArray errors = (JSONArray) root.get("errors"); - StringBuilder logMessage = new StringBuilder(); - for (int i = 0; i < errors.size(); i++) { - if (i > 0) { - logMessage.append(", "); + JSONObject extensions = (JSONObject) ((JSONObject) errors.get(0)).get("extensions"); + if (extensions != null) { + if (extensions.containsKey("code") && extensions.get("code").equals("INVALID_IDENTIFIER_EXCEPTION")) { + logger.warn("Audience segments fetch failed (invalid identifier)"); + } else { + String errorMessage = extensions.get("classification") == null ? "decode error" : (String) extensions.get("classification"); + logger.error("Audience segments fetch failed (" + errorMessage + ")"); } - logMessage.append((String)((JSONObject) errors.get(i)).get("message")); } - logger.error(logMessage.toString()); return null; } diff --git a/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserTest.java b/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserTest.java index 1acd5cf15..454ab1718 100644 --- a/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserTest.java @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely Inc. and contributors + * Copyright 2022-2023, Optimizely Inc. and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -84,9 +84,34 @@ public void returnNullWhenJsonIsMalformed() { @Test public void returnNullAndLogCorrectErrorWhenErrorResponseIsReturned() { - String responseToParse = "{\"errors\":[{\"message\":\"Exception while fetching data (/customer) : java.lang.RuntimeException: could not resolve _fs_user_id = wrong_id\",\"locations\":[{\"line\":2,\"column\":3}],\"path\":[\"customer\"],\"extensions\":{\"classification\":\"InvalidIdentifierException\"}}],\"data\":{\"customer\":null}}"; + String responseToParse = "{\"errors\":[{\"message\":\"Exception while fetching data (/customer) : java.lang.RuntimeException: could not resolve _fs_user_id = wrong_id\",\"locations\":[{\"line\":2,\"column\":3}],\"path\":[\"customer\"],\"extensions\":{\"code\":\"INVALID_IDENTIFIER_EXCEPTION\", \"classification\":\"DataFetchingException\"}}],\"data\":{\"customer\":null}}"; List parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); - logbackVerifier.expectMessage(Level.ERROR, "Exception while fetching data (/customer) : java.lang.RuntimeException: could not resolve _fs_user_id = wrong_id"); + logbackVerifier.expectMessage(Level.WARN, "Audience segments fetch failed (invalid identifier)"); assertEquals(null, parsedSegments); } + + @Test + public void returnNullAndLogNoErrorWhenErrorResponseIsReturnedButCodeKeyIsNotPresent() { + String responseToParse = "{\"errors\":[{\"message\":\"Exception while fetching data (/customer) : java.lang.RuntimeException: could not resolve _fs_user_id = wrong_id\",\"locations\":[{\"line\":2,\"column\":3}],\"path\":[\"customer\"],\"extensions\":{\"classification\":\"DataFetchingException\"}}],\"data\":{\"customer\":null}}"; + List parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + logbackVerifier.expectMessage(Level.ERROR, "Audience segments fetch failed (DataFetchingException)"); + assertEquals(null, parsedSegments); + } + + @Test + public void returnNullAndLogCorrectErrorWhenErrorResponseIsReturnedButCodeValueIsNotInvalidIdentifierException() { + String responseToParse = "{\"errors\":[{\"message\":\"Exception while fetching data (/customer) : java.lang.RuntimeException: could not resolve _fs_user_id = wrong_id\",\"locations\":[{\"line\":2,\"column\":3}],\"path\":[\"customer\"],\"extensions\":{\"code\":\"OTHER_EXCEPTIONS\", \"classification\":\"DataFetchingException\"}}],\"data\":{\"customer\":null}}"; + List parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + logbackVerifier.expectMessage(Level.ERROR, "Audience segments fetch failed (DataFetchingException)"); + assertEquals(null, parsedSegments); + } + + @Test + public void returnNullAndLogCorrectErrorWhenErrorResponseIsReturnedButCodeValueIsNotInvalidIdentifierExceptionNullClassification() { + String responseToParse = "{\"errors\":[{\"message\":\"Exception while fetching data (/customer) : java.lang.RuntimeException: could not resolve _fs_user_id = wrong_id\",\"locations\":[{\"line\":2,\"column\":3}],\"path\":[\"customer\"],\"extensions\":{\"code\":\"OTHER_EXCEPTIONS\"}}],\"data\":{\"customer\":null}}"; + List parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + logbackVerifier.expectMessage(Level.ERROR, "Audience segments fetch failed (decode error)"); + assertEquals(null, parsedSegments); + } + } diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java index 040709d39..3a7ae3291 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java @@ -137,7 +137,8 @@ String getSegmentsStringForRequest(Set segmentsList) { "customer" ], "extensions": { - "classification": "InvalidIdentifierException" + "code": "INVALID_IDENTIFIER_EXCEPTION", + "classification": "DataFetchingException" } } ], From b7c8f3cc148e5a704fbae4b45d8ac057e40c9240 Mon Sep 17 00:00:00 2001 From: Muhammad Noman Date: Fri, 5 May 2023 04:32:03 +0500 Subject: [PATCH 111/147] [FSSDK-8795]chore: prepare for pre release 4.0.0-beta (#515) --- CHANGELOG.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac028c780..9512cb740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ # Optimizely Java X SDK Changelog +## [4.0.0-beta] +May 5th, 2023 + +### New Features +The 4.0.0-beta release introduces a new primary feature, [Advanced Audience Targeting]( https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting) +enabled through integration with [Optimizely Data Platform (ODP)](https://docs.developers.optimizely.com/optimizely-data-platform/docs) ( +[#474](https://github.com/optimizely/java-sdk/pull/474), +[#481](https://github.com/optimizely/java-sdk/pull/481), +[#482](https://github.com/optimizely/java-sdk/pull/482), +[#483](https://github.com/optimizely/java-sdk/pull/483), +[#484](https://github.com/optimizely/java-sdk/pull/484), +[#485](https://github.com/optimizely/java-sdk/pull/485), +[#487](https://github.com/optimizely/java-sdk/pull/487), +[#489](https://github.com/optimizely/java-sdk/pull/489), +[#490](https://github.com/optimizely/java-sdk/pull/490), +[#494](https://github.com/optimizely/java-sdk/pull/494) +). + +You can use ODP, a high-performance [Customer Data Platform (CDP)]( https://www.optimizely.com/optimization-glossary/customer-data-platform/), to easily create complex +real-time segments (RTS) using first-party and 50+ third-party data sources out of the box. You can create custom schemas that support the user attributes important +for your business, and stitch together user behavior done on different devices to better understand and target your customers for personalized user experiences. ODP can +be used as a single source of truth for these segments in any Optimizely or 3rd party tool. + +With ODP accounts integrated into Optimizely projects, you can build audiences using segments pre-defined in ODP. The SDK will fetch the segments for given users and +make decisions using the segments. For access to ODP audience targeting in your Feature Experimentation account, please contact your Optimizely Customer Success Manager. + +This version includes the following changes: +- New API added to `OptimizelyUserContext`: + - `fetchQualifiedSegments()`: this API will retrieve user segments from the ODP server. The fetched segments will be used for audience evaluation. The fetched data will be stored in the local cache to avoid repeated network delays. + - When an `OptimizelyUserContext` is created, the SDK will automatically send an identify request to the ODP server to facilitate observing user activities. +- New APIs added to `OptimizelyClient`: + - `sendOdpEvent()`: customers can build/send arbitrary ODP events that will bind user identifiers and data to user profiles in ODP. + +For details, refer to our documentation pages: +- [Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting) +- [Server SDK Support](https://docs.developers.optimizely.com/feature-experimentation/v1.0/docs/advanced-audience-targeting-for-server-side-sdks) +- [Initialize Java SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/initialize-sdk-java) +- [OptimizelyUserContext Java SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizelyusercontext-java) +- [Advanced Audience Targeting segment qualification methods](https://docs.developers.optimizely.com/feature-experimentation/docs/advanced-audience-targeting-segment-qualification-methods-java) +- [Send Optimizely Data Platform data using Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/send-odp-data-using-advanced-audience-targeting-java) + +### Breaking Changes +- `OdpManager` in the SDK is enabled by default, if initialized using OptimizelyFactory. Unless an ODP account is integrated into the Optimizely projects, most `OdpManager` functions will be ignored. If needed, `OdpManager` to be disabled initialize `OptimizelyClient` without passing `OdpManager`. +- `ProjectConfigManager` interface has been changed to add 2 more methods `getCachedConfig()` and `getSDKKey()`. Custom ProjectConfigManager should implement these new methods. See `PollingProjectConfigManager` for reference. This change is required to support ODPManager updated on datafile download ([#501](https://github.com/optimizely/java-sdk/pull/501)). + ## [3.10.3] March 13th, 2023 From e67974b27e3157dc43862f81bf2b96da2a637134 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Thu, 4 May 2023 17:16:21 -0700 Subject: [PATCH 112/147] fix ubuntu for action (#516) --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0cd965aad..e7ba7782e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,7 +20,7 @@ on: required: true jobs: run_build: - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: set up JDK 8 From c60c9e63b453ebf535e855d71bebde486936e618 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Mon, 5 Jun 2023 15:34:50 -0700 Subject: [PATCH 113/147] upgrade dependency versions for source clear (#517) --- core-httpclient-impl/gradle.properties | 1 - gradle.properties | 5 +++-- java-quickstart/build.gradle | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) delete mode 100644 core-httpclient-impl/gradle.properties diff --git a/core-httpclient-impl/gradle.properties b/core-httpclient-impl/gradle.properties deleted file mode 100644 index 72bc00d4c..000000000 --- a/core-httpclient-impl/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -httpClientVersion = 4.5.13 diff --git a/gradle.properties b/gradle.properties index c67b677d9..71d3f3310 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,14 +10,15 @@ org.gradle.daemon = true org.gradle.parallel = true # Application Packages -gsonVersion = 2.8.6 +gsonVersion = 2.10.1 guavaVersion = 22.0 hamcrestVersion = 1.3 -jacksonVersion = 2.11.2 +jacksonVersion = 2.15.2 jsonVersion = 20190722 jsonSimpleVersion = 1.1.1 logbackVersion = 1.2.3 slf4jVersion = 1.7.30 +httpClientVersion = 4.5.14 # Style Packages findbugsAnnotationVersion = 3.0.1 diff --git a/java-quickstart/build.gradle b/java-quickstart/build.gradle index db68cef27..3e6a978f8 100644 --- a/java-quickstart/build.gradle +++ b/java-quickstart/build.gradle @@ -4,9 +4,9 @@ dependencies { compile project(':core-api') compile project(':core-httpclient-impl') - compile group: 'com.google.code.gson', name: 'gson', version: '2.8.6' - compile group: 'org.apache.httpcomponents', name: 'httpclient', version: '4.5.12' - compile group: 'org.slf4j', name: 'slf4j-simple', version: '1.7.30' + compile group: 'com.google.code.gson', name: 'gson', version: gsonVersion + compile group: 'org.apache.httpcomponents', name: 'httpclient', version: httpClientVersion + compile group: 'org.slf4j', name: 'slf4j-simple', version: slf4jVersion testCompile group: 'junit', name: 'junit', version: '4.12' } From 7ca4df3f01d8c871183d77647844b7dcfa76a7c4 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Tue, 6 Jun 2023 13:30:59 -0700 Subject: [PATCH 114/147] add evict timeout to logx connections (#518) --- .../main/java/com/optimizely/ab/event/AsyncEventHandler.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java index 3d32f3971..391f89b57 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java @@ -119,6 +119,8 @@ public AsyncEventHandler(int queueCapacity, .withMaxTotalConnections(maxConnections) .withMaxPerRoute(connectionsPerRoute) .withValidateAfterInactivity(validateAfter) + // infrequent event discards observed. staled connections force-closed after a long idle time. + .withEvictIdleConnections(1L, TimeUnit.MINUTES) .build(); this.workerExecutor = new ThreadPoolExecutor(numWorkers, numWorkers, From 3ca11a9d7a92aac43d5495ae0017444675846bc1 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Thu, 8 Jun 2023 13:40:39 -0700 Subject: [PATCH 115/147] fix changelog (#521) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9512cb740..386c8eeb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Optimizely Java X SDK Changelog +## [3.10.4] +June 8th, 2023 + +### Fixes +- Fix intermittent logx event dispatch failures possibly caused by reusing stale connections. Add `evictIdleConnections` (1min) to `OptimizelyHttpClient` in `AsyncEventHandler` to force close persistent connections after 1min idle time ([#518](https://github.com/optimizely/java-sdk/pull/518)). + + ## [4.0.0-beta] May 5th, 2023 From c507649832f605fa6dd3419caf2284da0c1436c8 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Mon, 26 Jun 2023 15:37:46 -0700 Subject: [PATCH 116/147] [FSSDK-9432] fix: fix to support arbitrary client names to be included in logx and odp events. (#524) Fix to support arbitrary client names to be included in logx and odp events. - The previous implementation allows only a fixed set of values (java-sdk, android-sdk, and android-tv-sdk) with enum. - We need to make it more flexible to support other sdks too (flutter, react-native, etc) wrapping the android-sdk and java-sdk cores. - Old methods for enum access are all deprecated. Downgrade jackson library version down to 2.13.5 (2.14+ uses Java8 langs not supported in Android) --- .../java/com/optimizely/ab/Optimizely.java | 12 ++++++- .../ab/event/internal/BuildVersionInfo.java | 4 +-- .../ab/event/internal/ClientEngineInfo.java | 36 +++++++++++++++++-- .../ab/event/internal/EventFactory.java | 2 +- .../ab/event/internal/payload/EventBatch.java | 2 ++ .../optimizely/ab/odp/ODPEventManager.java | 2 +- .../optimizely/ab/OptimizelyBuilderTest.java | 5 ++- .../event/internal/ClientEngineInfoTest.java | 20 ++++++----- .../ab/event/internal/EventFactoryTest.java | 18 +++++----- gradle.properties | 3 +- 10 files changed, 75 insertions(+), 29 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index acd9d05fd..3524cb24a 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1633,10 +1633,20 @@ public Builder withUserProfileService(UserProfileService userProfileService) { /** * Override the SDK name and version (for client SDKs like android-sdk wrapping the core java-sdk) to be included in events. * - * @param clientEngine the client engine type. + * @param clientEngineName the client engine name ("java-sdk", "android-sdk", "flutter-sdk", etc.). * @param clientVersion the client SDK version. * @return An Optimizely builder */ + public Builder withClientInfo(String clientEngineName, String clientVersion) { + ClientEngineInfo.setClientEngineName(clientEngineName); + BuildVersionInfo.setClientVersion(clientVersion); + return this; + } + + /** + * @deprecated in favor of {@link withClientInfo(String, String)} which can set with arbitrary client names. + */ + @Deprecated public Builder withClientInfo(EventBatch.ClientEngine clientEngine, String clientVersion) { ClientEngineInfo.setClientEngine(clientEngine); BuildVersionInfo.setClientVersion(clientVersion); diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/BuildVersionInfo.java b/core-api/src/main/java/com/optimizely/ab/event/internal/BuildVersionInfo.java index d5620b4e9..f69be7cb5 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/BuildVersionInfo.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/BuildVersionInfo.java @@ -37,9 +37,9 @@ public final class BuildVersionInfo { @Deprecated public final static String VERSION = readVersionNumber(); + public final static String DEFAULT_VERSION = readVersionNumber(); // can be overridden by other wrapper client (android-sdk, etc) - - private static String clientVersion = readVersionNumber(); + private static String clientVersion = DEFAULT_VERSION; public static void setClientVersion(String version) { if (version == null || version.isEmpty()) { diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/ClientEngineInfo.java b/core-api/src/main/java/com/optimizely/ab/event/internal/ClientEngineInfo.java index beb64be3d..85573d7fc 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/ClientEngineInfo.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/ClientEngineInfo.java @@ -17,9 +17,13 @@ package com.optimizely.ab.event.internal; import com.optimizely.ab.event.internal.payload.EventBatch; +import com.optimizely.ab.notification.DecisionNotification; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + /** * ClientEngineInfo is a utility to globally get and set the ClientEngine used in * event tracking. The ClientEngine defaults to JAVA_SDK but can be overridden at @@ -28,9 +32,34 @@ public class ClientEngineInfo { private static final Logger logger = LoggerFactory.getLogger(ClientEngineInfo.class); + public static final String DEFAULT_NAME = "java-sdk"; + private static String clientEngineName = DEFAULT_NAME; + + public static void setClientEngineName(@Nullable String name) { + if (name == null || name.isEmpty()) { + logger.warn("ClientEngineName cannot be empty, defaulting to {}", ClientEngineInfo.clientEngineName); + return; + } + ClientEngineInfo.clientEngineName = name; + } + + @Nonnull + public static String getClientEngineName() { + return clientEngineName; + } + + private ClientEngineInfo() { + } + + @Deprecated public static final EventBatch.ClientEngine DEFAULT = EventBatch.ClientEngine.JAVA_SDK; + @Deprecated private static EventBatch.ClientEngine clientEngine = DEFAULT; + /** + * @deprecated in favor of {@link #setClientEngineName(String)} which can set with arbitrary client names. + */ + @Deprecated public static void setClientEngine(EventBatch.ClientEngine clientEngine) { if (clientEngine == null) { logger.warn("ClientEngine cannot be null, defaulting to {}", ClientEngineInfo.clientEngine.getClientEngineValue()); @@ -39,12 +68,15 @@ public static void setClientEngine(EventBatch.ClientEngine clientEngine) { logger.info("Setting Optimizely client engine to {}", clientEngine.getClientEngineValue()); ClientEngineInfo.clientEngine = clientEngine; + ClientEngineInfo.clientEngineName = clientEngine.getClientEngineValue(); } + /** + * @deprecated in favor of {@link #getClientEngineName()}. + */ + @Deprecated public static EventBatch.ClientEngine getClientEngine() { return clientEngine; } - private ClientEngineInfo() { - } } diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java b/core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java index f651be851..47839810d 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java @@ -72,7 +72,7 @@ public static LogEvent createLogEvent(List userEvents) { ProjectConfig projectConfig = userContext.getProjectConfig(); builder - .setClientName(ClientEngineInfo.getClientEngine().getClientEngineValue()) + .setClientName(ClientEngineInfo.getClientEngineName()) .setClientVersion(BuildVersionInfo.getClientVersion()) .setAccountId(projectConfig.getAccountId()) .setAnonymizeIp(projectConfig.getAnonymizeIP()) diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/EventBatch.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/EventBatch.java index c50ee6288..43965dafa 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/EventBatch.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/EventBatch.java @@ -24,6 +24,8 @@ import java.util.List; public class EventBatch { + + @Deprecated public enum ClientEngine { JAVA_SDK("java-sdk"), ANDROID_SDK("android-sdk"), diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java index ba668a138..efcdd6cda 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java @@ -148,7 +148,7 @@ protected Map augmentCommonData(Map sourceData) Map data = new HashMap<>(); data.put("idempotence_id", UUID.randomUUID().toString()); data.put("data_source_type", "sdk"); - data.put("data_source", ClientEngineInfo.getClientEngine().getClientEngineValue()); + data.put("data_source", ClientEngineInfo.getClientEngineName()); data.put("data_source_version", BuildVersionInfo.getClientVersion()); data.putAll(userCommonData); diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java index 566eec635..6f091fdf8 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java @@ -224,7 +224,6 @@ public void withClientInfo() throws Exception { reset(eventHandler); optimizely = Optimizely.builder(validConfigJsonV4(), eventHandler) - .withClientInfo(null, null) .build(); optimizely.track("basic_event", "tester"); @@ -245,8 +244,8 @@ public void withClientInfo() throws Exception { assertEquals(argument.getValue().getEventBatch().getClientVersion(), "1.2.3"); // restore the default values for other tests - ClientEngineInfo.setClientEngine(ClientEngineInfo.DEFAULT); - BuildVersionInfo.setClientVersion(BuildVersionInfo.VERSION); + ClientEngineInfo.setClientEngineName(ClientEngineInfo.DEFAULT_NAME); + BuildVersionInfo.setClientVersion(BuildVersionInfo.DEFAULT_VERSION); } @Test diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/ClientEngineInfoTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/ClientEngineInfoTest.java index 33be48517..55b04296a 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/ClientEngineInfoTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/ClientEngineInfoTest.java @@ -26,19 +26,21 @@ public class ClientEngineInfoTest { @After public void tearDown() throws Exception { - ClientEngineInfo.setClientEngine(ClientEngineInfo.DEFAULT); + ClientEngineInfo.setClientEngineName(ClientEngineInfo.DEFAULT_NAME); } @Test public void testSetAndGetClientEngine() { - for (EventBatch.ClientEngine expected: EventBatch.ClientEngine.values()) { - ClientEngineInfo.setClientEngine(expected); - assertEquals(expected, ClientEngineInfo.getClientEngine()); - } - } + // default "java-sdk" name + assertEquals("java-sdk", ClientEngineInfo.getClientEngineName()); - @Test - public void testDefaultValue() { - assertEquals(ClientEngineInfo.DEFAULT, ClientEngineInfo.getClientEngine()); + ClientEngineInfo.setClientEngineName(null); + assertEquals("java-sdk", ClientEngineInfo.getClientEngineName()); + + ClientEngineInfo.setClientEngineName(""); + assertEquals("java-sdk", ClientEngineInfo.getClientEngineName()); + + ClientEngineInfo.setClientEngineName("test-name"); + assertEquals("test-name", ClientEngineInfo.getClientEngineName()); } } diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java index 657bc4fbf..e347074a8 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java @@ -87,7 +87,7 @@ public EventFactoryTest(int datafileVersion, @After public void tearDown() { - ClientEngineInfo.setClientEngine(EventBatch.ClientEngine.JAVA_SDK); + ClientEngineInfo.setClientEngineName(ClientEngineInfo.DEFAULT_NAME); } /** @@ -554,7 +554,7 @@ public void createImpressionEventIgnoresNullAttributes() { */ @Test public void createImpressionEventAndroidClientEngineClientVersion() throws Exception { - ClientEngineInfo.setClientEngine(EventBatch.ClientEngine.ANDROID_SDK); + ClientEngineInfo.setClientEngineName("android-sdk"); ProjectConfig projectConfig = validProjectConfigV2(); Experiment activatedExperiment = projectConfig.getExperiments().get(0); Variation bucketedVariation = activatedExperiment.getVariations().get(0); @@ -566,7 +566,7 @@ public void createImpressionEventAndroidClientEngineClientVersion() throws Excep userId, attributeMap); EventBatch impression = gson.fromJson(impressionEvent.getBody(), EventBatch.class); - assertThat(impression.getClientName(), is(EventBatch.ClientEngine.ANDROID_SDK.getClientEngineValue())); + assertThat(impression.getClientName(), is("android-sdk")); // assertThat(impression.getClientVersion(), is("0.0.0")); } @@ -577,7 +577,7 @@ public void createImpressionEventAndroidClientEngineClientVersion() throws Excep @Test public void createImpressionEventAndroidTVClientEngineClientVersion() throws Exception { String clientVersion = "0.0.0"; - ClientEngineInfo.setClientEngine(EventBatch.ClientEngine.ANDROID_TV_SDK); + ClientEngineInfo.setClientEngineName("android-tv-sdk"); ProjectConfig projectConfig = validProjectConfigV2(); Experiment activatedExperiment = projectConfig.getExperiments().get(0); Variation bucketedVariation = activatedExperiment.getVariations().get(0); @@ -589,7 +589,7 @@ public void createImpressionEventAndroidTVClientEngineClientVersion() throws Exc userId, attributeMap); EventBatch impression = gson.fromJson(impressionEvent.getBody(), EventBatch.class); - assertThat(impression.getClientName(), is(EventBatch.ClientEngine.ANDROID_TV_SDK.getClientEngineValue())); + assertThat(impression.getClientName(), is("android-tv-sdk")); // assertThat(impression.getClientVersion(), is(clientVersion)); } @@ -816,7 +816,7 @@ public void createConversionEventExperimentStatusPrecedesForcedVariation() { */ @Test public void createConversionEventAndroidClientEngineClientVersion() throws Exception { - ClientEngineInfo.setClientEngine(EventBatch.ClientEngine.ANDROID_SDK); + ClientEngineInfo.setClientEngineName("android-sdk"); Attribute attribute = validProjectConfig.getAttributes().get(0); EventType eventType = validProjectConfig.getEventTypes().get(0); @@ -838,7 +838,7 @@ public void createConversionEventAndroidClientEngineClientVersion() throws Excep EventBatch conversion = gson.fromJson(conversionEvent.getBody(), EventBatch.class); - assertThat(conversion.getClientName(), is(EventBatch.ClientEngine.ANDROID_SDK.getClientEngineValue())); + assertThat(conversion.getClientName(), is("android-sdk")); // assertThat(conversion.getClientVersion(), is("0.0.0")); } @@ -849,7 +849,7 @@ public void createConversionEventAndroidClientEngineClientVersion() throws Excep @Test public void createConversionEventAndroidTVClientEngineClientVersion() throws Exception { String clientVersion = "0.0.0"; - ClientEngineInfo.setClientEngine(EventBatch.ClientEngine.ANDROID_TV_SDK); + ClientEngineInfo.setClientEngineName("android-tv-sdk"); ProjectConfig projectConfig = validProjectConfigV2(); Attribute attribute = projectConfig.getAttributes().get(0); EventType eventType = projectConfig.getEventTypes().get(0); @@ -873,7 +873,7 @@ public void createConversionEventAndroidTVClientEngineClientVersion() throws Exc EventBatch conversion = gson.fromJson(conversionEvent.getBody(), EventBatch.class); - assertThat(conversion.getClientName(), is(EventBatch.ClientEngine.ANDROID_TV_SDK.getClientEngineValue())); + assertThat(conversion.getClientName(), is("android-tv-sdk")); // assertThat(conversion.getClientVersion(), is(clientVersion)); } diff --git a/gradle.properties b/gradle.properties index 71d3f3310..f8816af24 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,8 @@ org.gradle.parallel = true gsonVersion = 2.10.1 guavaVersion = 22.0 hamcrestVersion = 1.3 -jacksonVersion = 2.15.2 +# NOTE: jackson 2.14+ uses Java8 stream apis not supported in android +jacksonVersion = 2.13.5 jsonVersion = 20190722 jsonSimpleVersion = 1.1.1 logbackVersion = 1.2.3 From f5528d5332d675063e4abdc68a0cabdc3a0e87b0 Mon Sep 17 00:00:00 2001 From: Muhammad Noman Date: Fri, 28 Jul 2023 21:10:30 +0500 Subject: [PATCH 117/147] [FSSDK-9493] fix: Added check in AsyncGetQualifiedSegments to check if userID is fsuserid or vuid (#527) * Added check in AsyncGetQualifiedSegments to check if userID is fsuserid or vuid * Update unit test and added additional test to verify that proper userKey is getting passed given vuid and userid --------- Co-authored-by: NomanShoaib --- .../optimizely/ab/odp/ODPSegmentManager.java | 12 ++- .../ab/OptimizelyUserContextTest.java | 97 +++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java index ce1aaffd3..8cd917269 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java @@ -110,12 +110,16 @@ public void getQualifiedSegments(ODPUserKey userKey, String userValue, ODPSegmen getQualifiedSegments(userKey, userValue, callback, Collections.emptyList()); } - public void getQualifiedSegments(String fsUserId, ODPSegmentFetchCallback callback, List segmentOptions) { - getQualifiedSegments(ODPUserKey.FS_USER_ID, fsUserId, callback, segmentOptions); + public void getQualifiedSegments(String userId, ODPSegmentFetchCallback callback, List options) { + if (ODPManager.isVuid(userId)) { + getQualifiedSegments(ODPUserKey.VUID, userId, callback, options); + } else { + getQualifiedSegments(ODPUserKey.FS_USER_ID, userId, callback, options); + } } - public void getQualifiedSegments(String fsUserId, ODPSegmentFetchCallback callback) { - getQualifiedSegments(ODPUserKey.FS_USER_ID, fsUserId, callback, Collections.emptyList()); + public void getQualifiedSegments(String userId, ODPSegmentFetchCallback callback) { + getQualifiedSegments(userId, callback, Collections.emptyList()); } private String getCacheKey(String userKey, String userValue) { diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java index 7c479f147..0c07ef56a 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -1723,6 +1723,103 @@ public void fetchQualifiedSegmentsAsync() throws InterruptedException { assertEquals(Arrays.asList("segment1", "segment2"), userContext.getQualifiedSegments()); } + @Test + public void fetchQualifiedSegmentsAsyncWithVUID() throws InterruptedException { + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPApiManager mockAPIManager = mock(ODPApiManager.class); + ODPSegmentManager mockODPSegmentManager = spy(new ODPSegmentManager(mockAPIManager)); + ODPManager mockODPManager = mock(ODPManager.class); + + doAnswer( + invocation -> { + ODPSegmentManager.ODPSegmentFetchCallback callback = invocation.getArgumentAt(2, ODPSegmentManager.ODPSegmentFetchCallback.class); + callback.onCompleted(Arrays.asList("segment1", "segment2")); + return null; + } + ).when(mockODPSegmentManager).getQualifiedSegments(any(), eq("vuid_f6db3d60ba3a493d8e41bc995bb"), (ODPSegmentManager.ODPSegmentFetchCallback) any(), any()); + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Mockito.when(mockODPManager.getSegmentManager()).thenReturn(mockODPSegmentManager); + + Optimizely optimizely = Optimizely.builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .withODPManager(mockODPManager) + .build(); + + OptimizelyUserContext userContext = optimizely.createUserContext("vuid_f6db3d60ba3a493d8e41bc995bb"); + + CountDownLatch countDownLatch = new CountDownLatch(1); + userContext.fetchQualifiedSegments((Boolean isFetchSuccessful) -> { + assertTrue(isFetchSuccessful); + countDownLatch.countDown(); + }); + + countDownLatch.await(); + verify(mockODPSegmentManager).getQualifiedSegments(eq(ODPUserKey.VUID), eq("vuid_f6db3d60ba3a493d8e41bc995bb"), any(ODPSegmentManager.ODPSegmentFetchCallback.class), eq(Collections.emptyList())); + assertEquals(Arrays.asList("segment1", "segment2"), userContext.getQualifiedSegments()); + + // reset qualified segments + userContext.setQualifiedSegments(Collections.emptyList()); + CountDownLatch countDownLatch2 = new CountDownLatch(1); + userContext.fetchQualifiedSegments((Boolean isFetchSuccessful) -> { + assertTrue(isFetchSuccessful); + countDownLatch2.countDown(); + }, Collections.singletonList(ODPSegmentOption.RESET_CACHE)); + + countDownLatch2.await(); + verify(mockODPSegmentManager).getQualifiedSegments(eq(ODPUserKey.VUID) ,eq("vuid_f6db3d60ba3a493d8e41bc995bb"), any(ODPSegmentManager.ODPSegmentFetchCallback.class), eq(Collections.singletonList(ODPSegmentOption.RESET_CACHE))); + assertEquals(Arrays.asList("segment1", "segment2"), userContext.getQualifiedSegments()); + } + + + @Test + public void fetchQualifiedSegmentsAsyncWithUserID() throws InterruptedException { + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPApiManager mockAPIManager = mock(ODPApiManager.class); + ODPSegmentManager mockODPSegmentManager = spy(new ODPSegmentManager(mockAPIManager)); + ODPManager mockODPManager = mock(ODPManager.class); + + doAnswer( + invocation -> { + ODPSegmentManager.ODPSegmentFetchCallback callback = invocation.getArgumentAt(2, ODPSegmentManager.ODPSegmentFetchCallback.class); + callback.onCompleted(Arrays.asList("segment1", "segment2")); + return null; + } + ).when(mockODPSegmentManager).getQualifiedSegments(any(), eq("f6db3d60ba3a493d8e41bc995bb"), (ODPSegmentManager.ODPSegmentFetchCallback) any(), any()); + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Mockito.when(mockODPManager.getSegmentManager()).thenReturn(mockODPSegmentManager); + + Optimizely optimizely = Optimizely.builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .withODPManager(mockODPManager) + .build(); + + OptimizelyUserContext userContext = optimizely.createUserContext("f6db3d60ba3a493d8e41bc995bb"); + + CountDownLatch countDownLatch = new CountDownLatch(1); + userContext.fetchQualifiedSegments((Boolean isFetchSuccessful) -> { + assertTrue(isFetchSuccessful); + countDownLatch.countDown(); + }); + + countDownLatch.await(); + verify(mockODPSegmentManager).getQualifiedSegments(eq(ODPUserKey.FS_USER_ID), eq("f6db3d60ba3a493d8e41bc995bb"), any(ODPSegmentManager.ODPSegmentFetchCallback.class), eq(Collections.emptyList())); + assertEquals(Arrays.asList("segment1", "segment2"), userContext.getQualifiedSegments()); + + // reset qualified segments + userContext.setQualifiedSegments(Collections.emptyList()); + CountDownLatch countDownLatch2 = new CountDownLatch(1); + userContext.fetchQualifiedSegments((Boolean isFetchSuccessful) -> { + assertTrue(isFetchSuccessful); + countDownLatch2.countDown(); + }, Collections.singletonList(ODPSegmentOption.RESET_CACHE)); + + countDownLatch2.await(); + verify(mockODPSegmentManager).getQualifiedSegments(eq(ODPUserKey.FS_USER_ID) ,eq("f6db3d60ba3a493d8e41bc995bb"), any(ODPSegmentManager.ODPSegmentFetchCallback.class), eq(Collections.singletonList(ODPSegmentOption.RESET_CACHE))); + assertEquals(Arrays.asList("segment1", "segment2"), userContext.getQualifiedSegments()); + } + @Test public void fetchQualifiedSegmentsAsyncError() throws InterruptedException { Optimizely optimizely = Optimizely.builder() From 65b3f43f239edb7faa165f007caaa277eeaa088a Mon Sep 17 00:00:00 2001 From: Muhammad Noman Date: Thu, 3 Aug 2023 17:32:47 +0500 Subject: [PATCH 118/147] [FSSDK-8739] refact: Implements a warning log for polling interval less than 30s (#528) * Added warning upon setting polling time period below 30 ms * Updated test and changed expecting time to lower than 30 seconds --- .../config/PollingProjectConfigManager.java | 4 ++- .../PollingProjectConfigManagerTest.java | 30 +++++++++++++++---- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java b/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java index 4ad34e7a8..5f0c44e74 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java +++ b/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java @@ -75,7 +75,9 @@ public PollingProjectConfigManager(long period, TimeUnit timeUnit, long blocking this.blockingTimeoutPeriod = blockingTimeoutPeriod; this.blockingTimeoutUnit = blockingTimeoutUnit; this.notificationCenter = notificationCenter; - + if (TimeUnit.SECONDS.convert(period, this.timeUnit) < 30) { + logger.warn("Polling intervals below 30 seconds are not recommended."); + } final ThreadFactory threadFactory = Executors.defaultThreadFactory(); this.scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(runnable -> { Thread thread = threadFactory.newThread(runnable); diff --git a/core-api/src/test/java/com/optimizely/ab/config/PollingProjectConfigManagerTest.java b/core-api/src/test/java/com/optimizely/ab/config/PollingProjectConfigManagerTest.java index 130f8844a..91a9b8715 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/PollingProjectConfigManagerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/PollingProjectConfigManagerTest.java @@ -16,13 +16,15 @@ */ package com.optimizely.ab.config; +import ch.qos.logback.classic.Level; +import com.optimizely.ab.internal.LogbackVerifier; import com.optimizely.ab.internal.NotificationRegistry; import com.optimizely.ab.notification.NotificationCenter; import com.optimizely.ab.notification.UpdateConfigNotification; -import org.junit.After; -import org.junit.Before; -import org.junit.Ignore; -import org.junit.Test; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.junit.*; +import org.junit.rules.ExpectedException; +import org.junit.rules.RuleChain; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; @@ -41,6 +43,13 @@ public class PollingProjectConfigManagerTest { private static final TimeUnit POLLING_UNIT = TimeUnit.MILLISECONDS; private static final int PROJECT_CONFIG_DELAY = 100; + public ExpectedException thrown = ExpectedException.none(); + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + + @Rule + @SuppressFBWarnings("URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD") + public RuleChain ruleChain = RuleChain.outerRule(thrown) + .around(logbackVerifier); private TestProjectConfigManager testProjectConfigManager; private ProjectConfig projectConfig; @@ -228,6 +237,13 @@ public void testUpdateConfigNotificationGetsTriggered() throws InterruptedExcept assertTrue(countDownLatch.await(500, TimeUnit.MILLISECONDS)); } + @Test + public void testSettingUpLowerPollingPeriodResultsInWarning() throws InterruptedException { + long pollingPeriod = 29; + new TestProjectConfigManager(projectConfig, pollingPeriod, TimeUnit.SECONDS, pollingPeriod / 2, TimeUnit.SECONDS, new NotificationCenter()); + logbackVerifier.expectMessage(Level.WARN, "Polling intervals below 30 seconds are not recommended."); + } + @Test public void testUpdateConfigNotificationDoesNotResultInDeadlock() throws Exception { NotificationCenter notificationCenter = new NotificationCenter(); @@ -257,7 +273,11 @@ private TestProjectConfigManager(ProjectConfig projectConfig) { } private TestProjectConfigManager(ProjectConfig projectConfig, long blockPeriod, NotificationCenter notificationCenter) { - super(POLLING_PERIOD, POLLING_UNIT, blockPeriod, POLLING_UNIT, notificationCenter); + this(projectConfig, POLLING_PERIOD, POLLING_UNIT, blockPeriod, POLLING_UNIT, notificationCenter); + } + + private TestProjectConfigManager(ProjectConfig projectConfig, long pollingPeriod, TimeUnit pollingUnit, long blockPeriod, TimeUnit blockingUnit, NotificationCenter notificationCenter) { + super(pollingPeriod, pollingUnit, blockPeriod, blockingUnit, notificationCenter); this.projectConfig = projectConfig; } From 9374090585fdfa14b25fe1a5bbf0542cac55a61c Mon Sep 17 00:00:00 2001 From: Muhammad Noman Date: Mon, 7 Aug 2023 19:23:17 +0500 Subject: [PATCH 119/147] added override close method in httpProjectConfigManager to avoid memory leak (#530) Co-authored-by: NomanShoaib --- .../ab/config/HttpProjectConfigManager.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java index 4be1715d2..15325350f 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019,2021, Optimizely + * Copyright 2019, 2021, 2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,18 +16,15 @@ */ package com.optimizely.ab.config; -import com.optimizely.ab.HttpClientUtils; import com.optimizely.ab.OptimizelyHttpClient; import com.optimizely.ab.annotations.VisibleForTesting; import com.optimizely.ab.config.parser.ConfigParseException; import com.optimizely.ab.internal.PropertyUtils; import com.optimizely.ab.notification.NotificationCenter; -import com.optimizely.ab.optimizelyconfig.OptimizelyConfig; import org.apache.http.*; import org.apache.http.client.ClientProtocolException; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; -import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.util.EntityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -148,6 +145,16 @@ protected ProjectConfig poll() { return null; } + @Override + public synchronized void close() { + super.close(); + try { + httpClient.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + @VisibleForTesting HttpGet createHttpRequest() { HttpGet httpGet = new HttpGet(uri); From 36d493bcbd08a2ec80982142a4530a92e353971a Mon Sep 17 00:00:00 2001 From: Muhammad Noman Date: Tue, 15 Aug 2023 18:00:53 +0500 Subject: [PATCH 120/147] [FSSDK-9549] chore: add github issue template (#531) * [FSSDK-9549] chore: add github issue template * Update config.yml --- .github/ISSUE_TEMPLATE/BUG-REPORT.yml | 88 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/ENHANCEMENT.yml | 45 ++++++++++++ .github/ISSUE_TEMPLATE/FEATURE-REQUEST.md | 4 ++ .github/ISSUE_TEMPLATE/config.yml | 5 ++ 4 files changed, 142 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/BUG-REPORT.yml create mode 100644 .github/ISSUE_TEMPLATE/ENHANCEMENT.yml create mode 100644 .github/ISSUE_TEMPLATE/FEATURE-REQUEST.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml new file mode 100644 index 000000000..9295c6a34 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml @@ -0,0 +1,88 @@ +name: 🐞 Bug +description: File a bug/issue +title: "[BUG] " +labels: ["bug", "needs-triage"] +body: +- type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue already exists for the bug you encountered. + options: + - label: I have searched the existing issues + required: true +- type: textarea + attributes: + label: SDK Version + description: Version of the SDK in use? + validations: + required: true +- type: textarea + attributes: + label: Current Behavior + description: A concise description of what you're experiencing. + validations: + required: true +- type: textarea + attributes: + label: Expected Behavior + description: A concise description of what you expected to happen. + validations: + required: true +- type: textarea + attributes: + label: Steps To Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. In this environment... + 1. With this config... + 1. Run '...' + 1. See error... + validations: + required: true +- type: textarea + attributes: + label: Java Version + description: What version of Java are you using? + validations: + required: false +- type: textarea + attributes: + label: Link + description: Link to code demonstrating the problem. + validations: + required: false +- type: textarea + attributes: + label: Logs + description: Logs/stack traces related to the problem (⚠️do not include sensitive information). + validations: + required: false +- type: dropdown + attributes: + label: Severity + description: What is the severity of the problem? + multiple: true + options: + - Blocking development + - Affecting users + - Minor issue + validations: + required: false +- type: textarea + attributes: + label: Workaround/Solution + description: Do you have any workaround or solution in mind for the problem? + validations: + required: false +- type: textarea + attributes: + label: "Recent Change" + description: Has this issue started happening after an update or experiment change? + validations: + required: false +- type: textarea + attributes: + label: Conflicts + description: Are there other libraries/dependencies potentially in conflict? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml b/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml new file mode 100644 index 000000000..2b315c010 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml @@ -0,0 +1,45 @@ +name: ✨Enhancement +description: Create a new ticket for a Enhancement/Tech-initiative for the benefit of the SDK which would be considered for a minor version update. +title: "[ENHANCEMENT] <title>" +labels: ["enhancement"] +body: + - type: textarea + id: description + attributes: + label: "Description" + description: Briefly describe the enhancement in a few sentences. + placeholder: Short description... + validations: + required: true + - type: textarea + id: benefits + attributes: + label: "Benefits" + description: How would the enhancement benefit to your product or usage? + placeholder: Benefits... + validations: + required: true + - type: textarea + id: detail + attributes: + label: "Detail" + description: How would you like the enhancement to work? Please provide as much detail as possible + placeholder: Detailed description... + validations: + required: false + - type: textarea + id: examples + attributes: + label: "Examples" + description: Are there any examples of this enhancement in other products/services? If so, please provide links or references. + placeholder: Links/References... + validations: + required: false + - type: textarea + id: risks + attributes: + label: "Risks/Downsides" + description: Do you think this enhancement could have any potential downsides or risks? + placeholder: Risks/Downsides... + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.md b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.md new file mode 100644 index 000000000..5aa42ce83 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.md @@ -0,0 +1,4 @@ +<!-- + Thanks for filing in issue! Are you requesting a new feature? If so, please share your feedback with us on the following link. +--> +## Feedback requesting a new feature can be shared [here.](https://feedback.optimizely.com/) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..dc7735bc9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: 💡Feature Requests + url: https://feedback.optimizely.com/ + about: Feedback requesting a new feature can be shared here. From e9be5d0d4ef946be733088c560e3c08b9b50b333 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Mon, 28 Aug 2023 23:14:43 +0600 Subject: [PATCH 121/147] [FSSDK-9617] chore: release 4.0.0-beta2 (#533) Prepare for release 4.0.0-beta2 --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 386c8eeb6..aef012ffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Optimizely Java X SDK Changelog +## [4.0.0-beta2] +August 28th, 2023 + +### Fixes +- Fix thread leak from httpClient in HttpProjectConfigManager ([#530](https://github.com/optimizely/java-sdk/pull/530)). +- Fix issue when vuid is passed as userid for `AsyncGetQualifiedSegments` ([#527](https://github.com/optimizely/java-sdk/pull/527)). +- Fix to support arbitrary client names to be included in logx and odp events ([#524](https://github.com/optimizely/java-sdk/pull/524)). +- Add evict timeout to logx connections ([#518](https://github.com/optimizely/java-sdk/pull/518)). + + ## [3.10.4] June 8th, 2023 From 6c5e5b2cbdd62650e1604c08064263bc3e8538fc Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Tue, 29 Aug 2023 20:54:29 +0600 Subject: [PATCH 122/147] [FSSDK-9617] chore: release 4.0.0-beta2 (#534) Release 4.0.0-beta2 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aef012ffd..7ec53d038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ August 28th, 2023 - Fix to support arbitrary client names to be included in logx and odp events ([#524](https://github.com/optimizely/java-sdk/pull/524)). - Add evict timeout to logx connections ([#518](https://github.com/optimizely/java-sdk/pull/518)). +### Functionality Enhancements +- Update Github Issue Templates ([#531](https://github.com/optimizely/java-sdk/pull/531)) + ## [3.10.4] June 8th, 2023 From ca2a8942920cfb34e2de44ba11ddaad33a219513 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Tue, 12 Sep 2023 03:20:26 -0700 Subject: [PATCH 123/147] add sample log4j config to demo app (#523) Co-authored-by: Muhammad Noman <Muhammadnoman@folio3.com> Co-authored-by: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> --- gradle.properties | 1 + java-quickstart/build.gradle | 14 ++++++++------ .../src/main/resources/log4j2.properties | 10 ++++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 java-quickstart/src/main/resources/log4j2.properties diff --git a/gradle.properties b/gradle.properties index f8816af24..ef1dd8bfd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,6 +20,7 @@ jsonSimpleVersion = 1.1.1 logbackVersion = 1.2.3 slf4jVersion = 1.7.30 httpClientVersion = 4.5.14 +log4jVersion = 2.20.0 # Style Packages findbugsAnnotationVersion = 3.0.1 diff --git a/java-quickstart/build.gradle b/java-quickstart/build.gradle index 3e6a978f8..a58fb090e 100644 --- a/java-quickstart/build.gradle +++ b/java-quickstart/build.gradle @@ -1,14 +1,16 @@ apply plugin: 'java' dependencies { - compile project(':core-api') - compile project(':core-httpclient-impl') + implementation project(':core-api') + implementation project(':core-httpclient-impl') - compile group: 'com.google.code.gson', name: 'gson', version: gsonVersion - compile group: 'org.apache.httpcomponents', name: 'httpclient', version: httpClientVersion - compile group: 'org.slf4j', name: 'slf4j-simple', version: slf4jVersion + implementation group: 'com.google.code.gson', name: 'gson', version: gsonVersion + implementation group: 'org.apache.httpcomponents', name: 'httpclient', version: httpClientVersion + implementation group: 'org.apache.logging.log4j', name: 'log4j-api', version: log4jVersion + implementation group: 'org.apache.logging.log4j', name: 'log4j-core', version: log4jVersion + implementation group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: log4jVersion - testCompile group: 'junit', name: 'junit', version: '4.12' + testImplementation group: 'junit', name: 'junit', version: junitVersion } task runExample(type: JavaExec) { diff --git a/java-quickstart/src/main/resources/log4j2.properties b/java-quickstart/src/main/resources/log4j2.properties new file mode 100644 index 000000000..d67078d5a --- /dev/null +++ b/java-quickstart/src/main/resources/log4j2.properties @@ -0,0 +1,10 @@ +# Set the root logger level to INFO and its appender to the console + +appender.console.type = Console +appender.console.name = STDOUT +appender.console.layout.type = PatternLayout +appender.console.layout.pattern = %d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n + +# Specify the loggers +rootLogger.level = debug +rootLogger.appenderRef.stdout.ref = STDOUT From e59c5953f03bff3a3bcce1cd1e9716464d0f395b Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Mon, 27 Nov 2023 09:38:14 -0800 Subject: [PATCH 124/147] [FSSDK-9785] handle duplicate experiment keys with a warning (#535) When duplicate experiment keys are found in a datafile, SDK returns a correct experimentMap in OptimizelyConfig: - the experimentMap will contain the experiment later in the datafile experiments list. - add a warning log about "Duplicate experiment keys found in datafile: {key-name}" --- .../OptimizelyConfigService.java | 11 +++++- .../OptimizelyConfigServiceTest.java | 39 +++++++++++++++++-- .../src/main/java/com/optimizely/Example.java | 2 +- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java index 8937d8572..c1ec93c01 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2020-2021, Optimizely, Inc. and contributors * + * Copyright 2020-2021, 2023, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -18,6 +18,8 @@ import com.optimizely.ab.annotations.VisibleForTesting; import com.optimizely.ab.config.*; import com.optimizely.ab.config.audience.Audience; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.*; @@ -31,6 +33,8 @@ public class OptimizelyConfigService { private Map<String, List<FeatureVariable>> featureIdToVariablesMap = new HashMap<>(); private Map<String, OptimizelyExperiment> experimentMapByExperimentId = new HashMap<>(); + private static final Logger logger = LoggerFactory.getLogger(OptimizelyConfigService.class); + public OptimizelyConfigService(ProjectConfig projectConfig) { this.projectConfig = projectConfig; this.audiences = getAudiencesList(projectConfig.getTypedAudiences(), projectConfig.getAudiences()); @@ -125,6 +129,11 @@ Map<String, OptimizelyExperiment> getExperimentsMap() { experiment.serializeConditions(this.audiencesMap) ); + if (featureExperimentMap.containsKey(experiment.getKey())) { + // continue with this warning, so the later experiment will be used. + logger.warn("Duplicate experiment keys found in datafile: {}", experiment.getKey()); + } + featureExperimentMap.put(experiment.getKey(), optimizelyExperiment); experimentMapByExperimentId.put(experiment.getId(), optimizelyExperiment); } diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java index 29cbe3695..418cb2494 100644 --- a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2020-2021, Optimizely, Inc. and contributors * + * Copyright 2020-2021, 2023, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -15,16 +15,19 @@ ***************************************************************************/ package com.optimizely.ab.optimizelyconfig; +import ch.qos.logback.classic.Level; import com.optimizely.ab.config.*; import com.optimizely.ab.config.audience.Audience; +import com.optimizely.ab.internal.LogbackVerifier; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.runners.MockitoJUnitRunner; import java.util.*; import static java.util.Arrays.asList; import static org.junit.Assert.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class OptimizelyConfigServiceTest { @@ -32,6 +35,9 @@ public class OptimizelyConfigServiceTest { private OptimizelyConfigService optimizelyConfigService; private OptimizelyConfig expectedConfig; + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + @Before public void initialize() { projectConfig = generateOptimizelyConfig(); @@ -46,6 +52,33 @@ public void testGetExperimentsMap() { assertEquals(expectedConfig.getExperimentsMap(), optimizelyExperimentMap); } + @Test + public void testGetExperimentsMapWithDuplicateKeys() { + List<Experiment> experiments = Arrays.asList( + new Experiment( + "first", + "duplicate_key", + null, null, Collections.<String>emptyList(), null, + Collections.<Variation>emptyList(), Collections.<String, String>emptyMap(), Collections.<TrafficAllocation>emptyList() + ), + new Experiment( + "second", + "duplicate_key", + null, null, Collections.<String>emptyList(), null, + Collections.<Variation>emptyList(), Collections.<String, String>emptyMap(), Collections.<TrafficAllocation>emptyList() + ) + ); + + ProjectConfig projectConfig = mock(ProjectConfig.class); + OptimizelyConfigService optimizelyConfigService = new OptimizelyConfigService(projectConfig); + when(projectConfig.getExperiments()).thenReturn(experiments); + + Map<String, OptimizelyExperiment> optimizelyExperimentMap = optimizelyConfigService.getExperimentsMap(); + assertEquals("Duplicate keys should be overwritten", optimizelyExperimentMap.size(), 1); + assertEquals("Duplicate keys should be overwritten", optimizelyExperimentMap.get("duplicate_key").getId(), "second"); + logbackVerifier.expectMessage(Level.WARN, "Duplicate experiment keys found in datafile: duplicate_key"); + } + @Test public void testRevision() { String revision = optimizelyConfigService.getConfig().getRevision(); diff --git a/java-quickstart/src/main/java/com/optimizely/Example.java b/java-quickstart/src/main/java/com/optimizely/Example.java index 04d7f78da..e3bccd483 100644 --- a/java-quickstart/src/main/java/com/optimizely/Example.java +++ b/java-quickstart/src/main/java/com/optimizely/Example.java @@ -56,7 +56,7 @@ private void processVisitor(String userId, Map<String, Object> attributes) { public static void main(String[] args) throws InterruptedException { Optimizely optimizely = OptimizelyFactory.newDefaultInstance("BX9Y3bTa4YErpHZEMpAwHm"); - + Example example = new Example(optimizely); Random random = new Random(); From 3afa432b04f199e3375d047917793e64ccb8205d Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Tue, 16 Jan 2024 13:29:33 -0800 Subject: [PATCH 125/147] [FSSDK-8579] chore: prepare for release 4.0.0 (#536) --- CHANGELOG.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++- LICENSE | 2 +- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ec53d038..764c83982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,61 @@ # Optimizely Java X SDK Changelog +## [4.0.0] +January 16th, 2024 + +### New Features +The 4.0.0 release introduces a new primary feature, [Advanced Audience Targeting]( https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting) +enabled through integration with [Optimizely Data Platform (ODP)](https://docs.developers.optimizely.com/optimizely-data-platform/docs) ( +[#474](https://github.com/optimizely/java-sdk/pull/474), +[#481](https://github.com/optimizely/java-sdk/pull/481), +[#482](https://github.com/optimizely/java-sdk/pull/482), +[#483](https://github.com/optimizely/java-sdk/pull/483), +[#484](https://github.com/optimizely/java-sdk/pull/484), +[#485](https://github.com/optimizely/java-sdk/pull/485), +[#487](https://github.com/optimizely/java-sdk/pull/487), +[#489](https://github.com/optimizely/java-sdk/pull/489), +[#490](https://github.com/optimizely/java-sdk/pull/490), +[#494](https://github.com/optimizely/java-sdk/pull/494) +). + +You can use ODP, a high-performance [Customer Data Platform (CDP)]( https://www.optimizely.com/optimization-glossary/customer-data-platform/), to easily create complex +real-time segments (RTS) using first-party and 50+ third-party data sources out of the box. You can create custom schemas that support the user attributes important +for your business, and stitch together user behavior done on different devices to better understand and target your customers for personalized user experiences. ODP can +be used as a single source of truth for these segments in any Optimizely or 3rd party tool. + +With ODP accounts integrated into Optimizely projects, you can build audiences using segments pre-defined in ODP. The SDK will fetch the segments for given users and +make decisions using the segments. For access to ODP audience targeting in your Feature Experimentation account, please contact your Optimizely Customer Success Manager. + +This version includes the following changes: +- New API added to `OptimizelyUserContext`: + - `fetchQualifiedSegments()`: this API will retrieve user segments from the ODP server. The fetched segments will be used for audience evaluation. The fetched data will be stored in the local cache to avoid repeated network delays. + - When an `OptimizelyUserContext` is created, the SDK will automatically send an identify request to the ODP server to facilitate observing user activities. +- New APIs added to `OptimizelyClient`: + - `sendOdpEvent()`: customers can build/send arbitrary ODP events that will bind user identifiers and data to user profiles in ODP. + +For details, refer to our documentation pages: +- [Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting) +- [Server SDK Support](https://docs.developers.optimizely.com/feature-experimentation/v1.0/docs/advanced-audience-targeting-for-server-side-sdks) +- [Initialize Java SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/initialize-sdk-java) +- [OptimizelyUserContext Java SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizelyusercontext-java) +- [Advanced Audience Targeting segment qualification methods](https://docs.developers.optimizely.com/feature-experimentation/docs/advanced-audience-targeting-segment-qualification-methods-java) +- [Send Optimizely Data Platform data using Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/send-odp-data-using-advanced-audience-targeting-java) + +### Breaking Changes +- `OdpManager` in the SDK is enabled by default, if initialized using OptimizelyFactory. Unless an ODP account is integrated into the Optimizely projects, most `OdpManager` functions will be ignored. If needed, ODP features can be disabled by initializing `OptimizelyClient` without passing `OdpManager`. +- `ProjectConfigManager` interface has been changed to add 2 more methods `getCachedConfig()` and `getSDKKey()`. Custom ProjectConfigManager should implement these new methods. See `PollingProjectConfigManager` for reference. This change is required to support ODPManager updated on datafile download ([#501](https://github.com/optimizely/java-sdk/pull/501)). + +### Fixes +- Fix thread leak from httpClient in HttpProjectConfigManager ([#530](https://github.com/optimizely/java-sdk/pull/530)). +- Fix issue when vuid is passed as userid for `AsyncGetQualifiedSegments` ([#527](https://github.com/optimizely/java-sdk/pull/527)). +- Fix to support arbitrary client names to be included in logx and odp events ([#524](https://github.com/optimizely/java-sdk/pull/524)). +- Add evict timeout to logx connections ([#518](https://github.com/optimizely/java-sdk/pull/518)). + +### Functionality Enhancements +- Update Github Issue Templates ([#531](https://github.com/optimizely/java-sdk/pull/531)) + + + ## [4.0.0-beta2] August 28th, 2023 @@ -7,7 +63,6 @@ August 28th, 2023 - Fix thread leak from httpClient in HttpProjectConfigManager ([#530](https://github.com/optimizely/java-sdk/pull/530)). - Fix issue when vuid is passed as userid for `AsyncGetQualifiedSegments` ([#527](https://github.com/optimizely/java-sdk/pull/527)). - Fix to support arbitrary client names to be included in logx and odp events ([#524](https://github.com/optimizely/java-sdk/pull/524)). -- Add evict timeout to logx connections ([#518](https://github.com/optimizely/java-sdk/pull/518)). ### Functionality Enhancements - Update Github Issue Templates ([#531](https://github.com/optimizely/java-sdk/pull/531)) diff --git a/LICENSE b/LICENSE index afc550977..c9f7279d1 100644 --- a/LICENSE +++ b/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2016, Optimizely + Copyright 2016-2024, Optimizely, Inc. and contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From a77eaa857197967774d3f89239f30ab2675b267e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rowicki?= <mirowicki@gmail.com> Date: Wed, 3 Apr 2024 23:07:42 +0200 Subject: [PATCH 126/147] Align to use virtual threads (#540) Align to use virtual threads - In scope of this ticket I removed synchronized blocks as they pin virtual thread to carrier thread. - Expose user way to inject ThreadFactory for creating virtual threads --- .../java/com/optimizely/ab/Optimizely.java | 8 +- .../config/PollingProjectConfigManager.java | 85 ++++++++++++------ .../ab/event/BatchEventProcessor.java | 23 +++-- .../ab/internal/DefaultLRUCache.java | 18 +++- .../ab/notification/NotificationManager.java | 12 ++- .../java/com/optimizely/ab/odp/ODPConfig.java | 90 ++++++++++++++----- .../optimizely/ab/odp/ODPEventManager.java | 10 ++- .../com/optimizely/ab/NamedThreadFactory.java | 12 ++- .../ab/config/HttpProjectConfigManager.java | 33 +++++-- .../ab/event/AsyncEventHandler.java | 21 ++++- 10 files changed, 234 insertions(+), 78 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 3524cb24a..e64882ed6 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -35,6 +35,7 @@ import com.optimizely.ab.optimizelyconfig.OptimizelyConfigService; import com.optimizely.ab.optimizelydecision.*; import com.optimizely.ab.optimizelyjson.OptimizelyJSON; +import java.util.concurrent.locks.ReentrantLock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -101,6 +102,8 @@ public class Optimizely implements AutoCloseable { @Nullable private final ODPManager odpManager; + private final ReentrantLock lock = new ReentrantLock(); + private Optimizely(@Nonnull EventHandler eventHandler, @Nonnull EventProcessor eventProcessor, @Nonnull ErrorHandler errorHandler, @@ -1451,8 +1454,11 @@ public List<String> fetchQualifiedSegments(String userId, @Nonnull List<ODPSegme return null; } if (odpManager != null) { - synchronized (odpManager) { + lock.lock(); + try { return odpManager.getSegmentManager().getQualifiedSegments(userId, segmentOptions); + } finally { + lock.unlock(); } } logger.error("Audience segments fetch failed (ODP is not enabled)."); diff --git a/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java b/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java index 5f0c44e74..6dd84470e 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java +++ b/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java @@ -22,6 +22,8 @@ import com.optimizely.ab.optimizelyconfig.OptimizelyConfig; import com.optimizely.ab.optimizelyconfig.OptimizelyConfigManager; import com.optimizely.ab.optimizelyconfig.OptimizelyConfigService; +import java.util.concurrent.locks.ReentrantLock; +import javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -60,6 +62,7 @@ public abstract class PollingProjectConfigManager implements ProjectConfigManage private volatile String sdkKey; private volatile boolean started; private ScheduledFuture<?> scheduledFuture; + private ReentrantLock lock = new ReentrantLock(); public PollingProjectConfigManager(long period, TimeUnit timeUnit) { this(period, timeUnit, Long.MAX_VALUE, TimeUnit.MILLISECONDS, new NotificationCenter()); @@ -70,6 +73,15 @@ public PollingProjectConfigManager(long period, TimeUnit timeUnit, NotificationC } public PollingProjectConfigManager(long period, TimeUnit timeUnit, long blockingTimeoutPeriod, TimeUnit blockingTimeoutUnit, NotificationCenter notificationCenter) { + this(period, timeUnit, blockingTimeoutPeriod, blockingTimeoutUnit, notificationCenter, null); + } + + public PollingProjectConfigManager(long period, + TimeUnit timeUnit, + long blockingTimeoutPeriod, + TimeUnit blockingTimeoutUnit, + NotificationCenter notificationCenter, + @Nullable ThreadFactory customThreadFactory) { this.period = period; this.timeUnit = timeUnit; this.blockingTimeoutPeriod = blockingTimeoutPeriod; @@ -78,7 +90,7 @@ public PollingProjectConfigManager(long period, TimeUnit timeUnit, long blocking if (TimeUnit.SECONDS.convert(period, this.timeUnit) < 30) { logger.warn("Polling intervals below 30 seconds are not recommended."); } - final ThreadFactory threadFactory = Executors.defaultThreadFactory(); + final ThreadFactory threadFactory = customThreadFactory != null ? customThreadFactory : Executors.defaultThreadFactory(); this.scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(runnable -> { Thread thread = threadFactory.newThread(runnable); thread.setDaemon(true); @@ -176,43 +188,58 @@ public String getSDKKey() { return this.sdkKey; } - public synchronized void start() { - if (started) { - logger.warn("Manager already started."); - return; - } + public void start() { + lock.lock(); + try { + if (started) { + logger.warn("Manager already started."); + return; + } - if (scheduledExecutorService.isShutdown()) { - logger.warn("Not starting. Already in shutdown."); - return; - } + if (scheduledExecutorService.isShutdown()) { + logger.warn("Not starting. Already in shutdown."); + return; + } - Runnable runnable = new ProjectConfigFetcher(); - scheduledFuture = scheduledExecutorService.scheduleAtFixedRate(runnable, 0, period, timeUnit); - started = true; + Runnable runnable = new ProjectConfigFetcher(); + scheduledFuture = scheduledExecutorService.scheduleAtFixedRate(runnable, 0, period, timeUnit); + started = true; + } finally { + lock.unlock(); + } } - public synchronized void stop() { - if (!started) { - logger.warn("Not pausing. Manager has not been started."); - return; - } + public void stop() { + lock.lock(); + try { + if (!started) { + logger.warn("Not pausing. Manager has not been started."); + return; + } - if (scheduledExecutorService.isShutdown()) { - logger.warn("Not pausing. Already in shutdown."); - return; - } + if (scheduledExecutorService.isShutdown()) { + logger.warn("Not pausing. Already in shutdown."); + return; + } - logger.info("pausing project watcher"); - scheduledFuture.cancel(true); - started = false; + logger.info("pausing project watcher"); + scheduledFuture.cancel(true); + started = false; + } finally { + lock.unlock(); + } } @Override - public synchronized void close() { - stop(); - scheduledExecutorService.shutdownNow(); - started = false; + public void close() { + lock.lock(); + try { + stop(); + scheduledExecutorService.shutdownNow(); + started = false; + } finally { + lock.unlock(); + } } protected void setSdkKey(String sdkKey) { diff --git a/core-api/src/main/java/com/optimizely/ab/event/BatchEventProcessor.java b/core-api/src/main/java/com/optimizely/ab/event/BatchEventProcessor.java index daf81d71a..740cfb8c3 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/BatchEventProcessor.java +++ b/core-api/src/main/java/com/optimizely/ab/event/BatchEventProcessor.java @@ -21,6 +21,7 @@ import com.optimizely.ab.event.internal.UserEvent; import com.optimizely.ab.internal.PropertyUtils; import com.optimizely.ab.notification.NotificationCenter; +import java.util.concurrent.locks.ReentrantLock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -67,6 +68,7 @@ public class BatchEventProcessor implements EventProcessor, AutoCloseable { private Future<?> future; private boolean isStarted = false; + private final ReentrantLock lock = new ReentrantLock(); private BatchEventProcessor(BlockingQueue<Object> eventQueue, EventHandler eventHandler, Integer batchSize, Long flushInterval, Long timeoutMillis, ExecutorService executor, NotificationCenter notificationCenter) { this.eventHandler = eventHandler; @@ -78,15 +80,20 @@ private BatchEventProcessor(BlockingQueue<Object> eventQueue, EventHandler event this.executor = executor; } - public synchronized void start() { - if (isStarted) { - logger.info("Executor already started."); - return; - } + public void start() { + lock.lock(); + try { + if (isStarted) { + logger.info("Executor already started."); + return; + } - isStarted = true; - EventConsumer runnable = new EventConsumer(); - future = executor.submit(runnable); + isStarted = true; + EventConsumer runnable = new EventConsumer(); + future = executor.submit(runnable); + } finally { + lock.unlock(); + } } @Override diff --git a/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java b/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java index a531c5c83..b946a65ea 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java @@ -19,10 +19,11 @@ import com.optimizely.ab.annotations.VisibleForTesting; import java.util.*; +import java.util.concurrent.locks.ReentrantLock; public class DefaultLRUCache<T> implements Cache<T> { - private final Object lock = new Object(); + private final ReentrantLock lock = new ReentrantLock(); private final Integer maxSize; @@ -51,8 +52,11 @@ public void save(String key, T value) { return; } - synchronized (lock) { + lock.lock(); + try { linkedHashMap.put(key, new CacheEntity(value)); + } finally { + lock.unlock(); } } @@ -62,7 +66,8 @@ public T lookup(String key) { return null; } - synchronized (lock) { + lock.lock(); + try { if (linkedHashMap.containsKey(key)) { CacheEntity entity = linkedHashMap.get(key); Long nowMs = new Date().getTime(); @@ -75,12 +80,17 @@ public T lookup(String key) { linkedHashMap.remove(key); } return null; + } finally { + lock.unlock(); } } public void reset() { - synchronized (lock) { + lock.lock(); + try { linkedHashMap.clear(); + } finally { + lock.unlock(); } } diff --git a/core-api/src/main/java/com/optimizely/ab/notification/NotificationManager.java b/core-api/src/main/java/com/optimizely/ab/notification/NotificationManager.java index 7415e6b23..986a142a8 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/NotificationManager.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/NotificationManager.java @@ -16,6 +16,7 @@ */ package com.optimizely.ab.notification; +import java.util.concurrent.locks.ReentrantLock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,6 +37,7 @@ public class NotificationManager<T> { private final Map<Integer, NotificationHandler<T>> handlers = Collections.synchronizedMap(new LinkedHashMap<>()); private final AtomicInteger counter; + private final ReentrantLock lock = new ReentrantLock(); public NotificationManager() { this(new AtomicInteger()); @@ -48,13 +50,16 @@ public NotificationManager(AtomicInteger counter) { public int addHandler(NotificationHandler<T> newHandler) { // Prevent registering a duplicate listener. - synchronized (handlers) { + lock.lock(); + try { for (NotificationHandler<T> handler : handlers.values()) { if (handler.equals(newHandler)) { logger.warn("Notification listener was already added"); return -1; } } + } finally { + lock.unlock(); } int notificationId = counter.incrementAndGet(); @@ -64,7 +69,8 @@ public int addHandler(NotificationHandler<T> newHandler) { } public void send(final T message) { - synchronized (handlers) { + lock.lock(); + try { for (Map.Entry<Integer, NotificationHandler<T>> handler: handlers.entrySet()) { try { handler.getValue().handle(message); @@ -72,6 +78,8 @@ public void send(final T message) { logger.warn("Catching exception sending notification for class: {}, handler: {}", message.getClass(), handler.getKey()); } } + } finally { + lock.unlock(); } } diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java index eb055e63f..8ffaaeada 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java @@ -18,6 +18,7 @@ import java.util.Collections; import java.util.Set; +import java.util.concurrent.locks.ReentrantLock; public class ODPConfig { @@ -27,6 +28,8 @@ public class ODPConfig { private Set<String> allSegments; + private final ReentrantLock lock = new ReentrantLock(); + public ODPConfig(String apiKey, String apiHost, Set<String> allSegments) { this.apiKey = apiKey; this.apiHost = apiHost; @@ -37,46 +40,91 @@ public ODPConfig(String apiKey, String apiHost) { this(apiKey, apiHost, Collections.emptySet()); } - public synchronized Boolean isReady() { - return !( - this.apiKey == null || this.apiKey.isEmpty() - || this.apiHost == null || this.apiHost.isEmpty() - ); + public Boolean isReady() { + lock.lock(); + try { + return !( + this.apiKey == null || this.apiKey.isEmpty() + || this.apiHost == null || this.apiHost.isEmpty() + ); + } finally { + lock.unlock(); + } } - public synchronized Boolean hasSegments() { - return allSegments != null && !allSegments.isEmpty(); + public Boolean hasSegments() { + lock.lock(); + try { + return allSegments != null && !allSegments.isEmpty(); + } finally { + lock.unlock(); + } } - public synchronized void setApiKey(String apiKey) { - this.apiKey = apiKey; + public void setApiKey(String apiKey) { + lock.lock(); + try { + this.apiKey = apiKey; + } finally { + lock.unlock(); + } } - public synchronized void setApiHost(String apiHost) { - this.apiHost = apiHost; + public void setApiHost(String apiHost) { + lock.lock(); + try { + this.apiHost = apiHost; + } finally { + lock.unlock(); + } } - public synchronized String getApiKey() { - return apiKey; + public String getApiKey() { + lock.lock(); + try { + return apiKey; + } finally { + lock.unlock(); + } } - public synchronized String getApiHost() { - return apiHost; + public String getApiHost() { + lock.lock(); + try { + return apiHost; + } finally { + lock.unlock(); + } } - public synchronized Set<String> getAllSegments() { - return allSegments; + public Set<String> getAllSegments() { + lock.lock(); + try { + return allSegments; + } finally { + lock.unlock(); + } } - public synchronized void setAllSegments(Set<String> allSegments) { - this.allSegments = allSegments; + public void setAllSegments(Set<String> allSegments) { + lock.lock(); + try { + this.allSegments = allSegments; + } finally { + lock.unlock(); + } } public Boolean equals(ODPConfig toCompare) { return getApiHost().equals(toCompare.getApiHost()) && getApiKey().equals(toCompare.getApiKey()) && getAllSegments().equals(toCompare.allSegments); } - public synchronized ODPConfig getClone() { - return new ODPConfig(apiKey, apiHost, allSegments); + public ODPConfig getClone() { + lock.lock(); + try { + return new ODPConfig(apiKey, apiHost, allSegments); + } finally { + lock.unlock(); + } } } diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java index efcdd6cda..b50c16045 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java @@ -59,16 +59,25 @@ public class ODPEventManager { // The eventQueue needs to be thread safe. We are not doing anything extra for thread safety here // because `LinkedBlockingQueue` itself is thread safe. private final BlockingQueue<Object> eventQueue = new LinkedBlockingQueue<>(); + private ThreadFactory threadFactory; public ODPEventManager(@Nonnull ODPApiManager apiManager) { this(apiManager, null, null); } public ODPEventManager(@Nonnull ODPApiManager apiManager, @Nullable Integer queueSize, @Nullable Integer flushInterval) { + this(apiManager, queueSize, flushInterval, null); + } + + public ODPEventManager(@Nonnull ODPApiManager apiManager, + @Nullable Integer queueSize, + @Nullable Integer flushInterval, + @Nullable ThreadFactory threadFactory) { this.apiManager = apiManager; this.queueSize = queueSize != null ? queueSize : DEFAULT_QUEUE_SIZE; this.flushInterval = (flushInterval != null && flushInterval > 0) ? flushInterval : DEFAULT_FLUSH_INTERVAL; this.batchSize = (flushInterval != null && flushInterval == 0) ? 1 : DEFAULT_BATCH_SIZE; + this.threadFactory = threadFactory != null ? threadFactory : Executors.defaultThreadFactory(); } // these user-provided common data are included in all ODP events in addition to the SDK-generated common data. @@ -86,7 +95,6 @@ public void start() { eventDispatcherThread = new EventDispatcherThread(); } if (!isRunning) { - final ThreadFactory threadFactory = Executors.defaultThreadFactory(); ExecutorService executor = Executors.newSingleThreadExecutor(runnable -> { Thread thread = threadFactory.newThread(runnable); thread.setDaemon(true); diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/NamedThreadFactory.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/NamedThreadFactory.java index 5b3cb2fbb..594ce0e20 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/NamedThreadFactory.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/NamedThreadFactory.java @@ -28,7 +28,7 @@ public class NamedThreadFactory implements ThreadFactory { private final String nameFormat; private final boolean daemon; - private final ThreadFactory backingThreadFactory = Executors.defaultThreadFactory(); + private final ThreadFactory backingThreadFactory; private final AtomicLong threadCount = new AtomicLong(0); /** @@ -36,8 +36,18 @@ public class NamedThreadFactory implements ThreadFactory { * @param daemon whether the threads created should be {@link Thread#daemon}s or not */ public NamedThreadFactory(String nameFormat, boolean daemon) { + this(nameFormat, daemon, null); + } + + /** + * @param nameFormat the thread name format which should include a string placeholder for the thread number + * @param daemon whether the threads created should be {@link Thread#daemon}s or not + * @param backingThreadFactory the backing {@link ThreadFactory} to use for creating threads + */ + public NamedThreadFactory(String nameFormat, boolean daemon, ThreadFactory backingThreadFactory) { this.nameFormat = nameFormat; this.daemon = daemon; + this.backingThreadFactory = backingThreadFactory != null ? backingThreadFactory : Executors.defaultThreadFactory(); } @Override diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java index 15325350f..a583eae98 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java @@ -21,6 +21,9 @@ import com.optimizely.ab.config.parser.ConfigParseException; import com.optimizely.ab.internal.PropertyUtils; import com.optimizely.ab.notification.NotificationCenter; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.locks.ReentrantLock; +import javax.annotation.Nullable; import org.apache.http.*; import org.apache.http.client.ClientProtocolException; import org.apache.http.client.methods.CloseableHttpResponse; @@ -62,6 +65,7 @@ public class HttpProjectConfigManager extends PollingProjectConfigManager { private final URI uri; private final String datafileAccessToken; private String datafileLastModified; + private final ReentrantLock lock = new ReentrantLock(); private HttpProjectConfigManager(long period, TimeUnit timeUnit, @@ -70,8 +74,9 @@ private HttpProjectConfigManager(long period, String datafileAccessToken, long blockingTimeoutPeriod, TimeUnit blockingTimeoutUnit, - NotificationCenter notificationCenter) { - super(period, timeUnit, blockingTimeoutPeriod, blockingTimeoutUnit, notificationCenter); + NotificationCenter notificationCenter, + @Nullable ThreadFactory threadFactory) { + super(period, timeUnit, blockingTimeoutPeriod, blockingTimeoutUnit, notificationCenter, threadFactory); this.httpClient = httpClient; this.uri = URI.create(url); this.datafileAccessToken = datafileAccessToken; @@ -146,12 +151,17 @@ protected ProjectConfig poll() { } @Override - public synchronized void close() { - super.close(); + public void close() { + lock.lock(); try { - httpClient.close(); - } catch (IOException e) { - e.printStackTrace(); + super.close(); + try { + httpClient.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } finally { + lock.unlock(); } } @@ -193,6 +203,7 @@ public static class Builder { // force-close the persistent connection after this idle time long evictConnectionIdleTimePeriod = PropertyUtils.getLong(CONFIG_EVICT_DURATION, DEFAULT_EVICT_DURATION); TimeUnit evictConnectionIdleTimeUnit = PropertyUtils.getEnum(CONFIG_EVICT_UNIT, TimeUnit.class, DEFAULT_EVICT_UNIT); + ThreadFactory threadFactory = null; public Builder withDatafile(String datafile) { this.datafile = datafile; @@ -302,6 +313,11 @@ public Builder withNotificationCenter(NotificationCenter notificationCenter) { return this; } + public Builder withThreadFactory(ThreadFactory threadFactory) { + this.threadFactory = threadFactory; + return this; + } + /** * HttpProjectConfigManager.Builder that builds and starts a HttpProjectConfigManager. * This is the default builder which will block until a config is available. @@ -363,7 +379,8 @@ public HttpProjectConfigManager build(boolean defer) { datafileAccessToken, blockingTimeoutPeriod, blockingTimeoutUnit, - notificationCenter); + notificationCenter, + threadFactory); httpProjectManager.setSdkKey(sdkKey); if (datafile != null) { try { diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java index 391f89b57..539b8b642 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java @@ -21,6 +21,8 @@ import com.optimizely.ab.annotations.VisibleForTesting; import com.optimizely.ab.internal.PropertyUtils; +import java.util.concurrent.ThreadFactory; +import javax.annotation.Nullable; import org.apache.http.HttpResponse; import org.apache.http.client.ClientProtocolException; import org.apache.http.client.ResponseHandler; @@ -108,6 +110,17 @@ public AsyncEventHandler(int queueCapacity, int validateAfter, long closeTimeout, TimeUnit closeTimeoutUnit) { + this(queueCapacity, numWorkers, maxConnections, connectionsPerRoute, validateAfter, closeTimeout, closeTimeoutUnit, null); + } + + public AsyncEventHandler(int queueCapacity, + int numWorkers, + int maxConnections, + int connectionsPerRoute, + int validateAfter, + long closeTimeout, + TimeUnit closeTimeoutUnit, + @Nullable ThreadFactory threadFactory) { queueCapacity = validateInput("queueCapacity", queueCapacity, DEFAULT_QUEUE_CAPACITY); numWorkers = validateInput("numWorkers", numWorkers, DEFAULT_NUM_WORKERS); @@ -123,10 +136,12 @@ public AsyncEventHandler(int queueCapacity, .withEvictIdleConnections(1L, TimeUnit.MINUTES) .build(); + NamedThreadFactory namedThreadFactory = new NamedThreadFactory("optimizely-event-dispatcher-thread-%s", true, threadFactory); + this.workerExecutor = new ThreadPoolExecutor(numWorkers, numWorkers, - 0L, TimeUnit.MILLISECONDS, - new ArrayBlockingQueue<>(queueCapacity), - new NamedThreadFactory("optimizely-event-dispatcher-thread-%s", true)); + 0L, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(queueCapacity), + namedThreadFactory); this.closeTimeout = closeTimeout; this.closeTimeoutUnit = closeTimeoutUnit; From da19ebb6d2c5909a9d97ce63f4c16a15d975abf5 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Thu, 4 Apr 2024 10:07:24 -0700 Subject: [PATCH 127/147] [FSSDK-10041] fix to inject common httpclient to projectConfigManager, eventHandler and odpManager (#542) OptimizelyFactory method for injecting customHttpClient is fixed to share the customHttpClient for all modules using httpClient - HttpProjectConfigManager - AsyncEventHander - ODPManager --- .../java/com/optimizely/ab/Optimizely.java | 4 +- .../ab/event/BatchEventProcessor.java | 4 +- .../optimizely/ab/odp/ODPEventManager.java | 4 +- .../com/optimizely/ab/odp/ODPManager.java | 1 - .../optimizely/ab/odp/ODPSegmentManager.java | 5 +- .../com/optimizely/ab/OptimizelyFactory.java | 44 ++++++++++++---- .../optimizely/ab/OptimizelyHttpClient.java | 1 - .../ab/config/HttpProjectConfigManager.java | 3 +- .../ab/event/AsyncEventHandler.java | 51 +++++++++++++------ .../ab/odp/DefaultODPApiManager.java | 16 ++++-- .../optimizely/ab/OptimizelyFactoryTest.java | 46 +++++++++++------ .../config/HttpProjectConfigManagerTest.java | 2 - .../ab/event/AsyncEventHandlerTest.java | 30 ++++++++--- .../ab/odp/DefaultODPApiManagerTest.java | 1 - 14 files changed, 149 insertions(+), 63 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index e64882ed6..fede007d5 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -78,7 +78,6 @@ public class Optimizely implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(Optimizely.class); final DecisionService decisionService; - @VisibleForTesting @Deprecated final EventHandler eventHandler; @VisibleForTesting @@ -88,7 +87,8 @@ public class Optimizely implements AutoCloseable { public final List<OptimizelyDecideOption> defaultDecideOptions; - private final ProjectConfigManager projectConfigManager; + @VisibleForTesting + final ProjectConfigManager projectConfigManager; @Nullable private final OptimizelyConfigManager optimizelyConfigManager; diff --git a/core-api/src/main/java/com/optimizely/ab/event/BatchEventProcessor.java b/core-api/src/main/java/com/optimizely/ab/event/BatchEventProcessor.java index 740cfb8c3..4f31b37e8 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/BatchEventProcessor.java +++ b/core-api/src/main/java/com/optimizely/ab/event/BatchEventProcessor.java @@ -16,6 +16,7 @@ */ package com.optimizely.ab.event; +import com.optimizely.ab.annotations.VisibleForTesting; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.event.internal.EventFactory; import com.optimizely.ab.event.internal.UserEvent; @@ -58,7 +59,8 @@ public class BatchEventProcessor implements EventProcessor, AutoCloseable { private static final Object FLUSH_SIGNAL = new Object(); private final BlockingQueue<Object> eventQueue; - private final EventHandler eventHandler; + @VisibleForTesting + public final EventHandler eventHandler; final int batchSize; final long flushInterval; diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java index b50c16045..43727b501 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java @@ -53,8 +53,8 @@ public class ODPEventManager { // needs to see the change immediately. private volatile ODPConfig odpConfig; private EventDispatcherThread eventDispatcherThread; - - private final ODPApiManager apiManager; + @VisibleForTesting + public final ODPApiManager apiManager; // The eventQueue needs to be thread safe. We are not doing anything extra for thread safety here // because `LinkedBlockingQueue` itself is thread safe. diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java index 4f1ddc52d..3a47e3f04 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java @@ -21,7 +21,6 @@ import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java index 8cd917269..6caae29ca 100644 --- a/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java @@ -16,6 +16,7 @@ */ package com.optimizely.ab.odp; +import com.optimizely.ab.annotations.VisibleForTesting; import com.optimizely.ab.internal.Cache; import com.optimizely.ab.internal.DefaultLRUCache; import com.optimizely.ab.odp.parser.ResponseJsonParser; @@ -31,8 +32,8 @@ public class ODPSegmentManager { private static final Logger logger = LoggerFactory.getLogger(ODPSegmentManager.class); private static final String SEGMENT_URL_PATH = "/v3/graphql"; - - private final ODPApiManager apiManager; + @VisibleForTesting + public final ODPApiManager apiManager; private volatile ODPConfig odpConfig; diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java index 1c6ee2820..f26851375 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java @@ -279,23 +279,37 @@ public static Optimizely newDefaultInstance(String sdkKey, String fallback, Stri * @param customHttpClient Customizable CloseableHttpClient to build OptimizelyHttpClient. * @return A new Optimizely instance */ - public static Optimizely newDefaultInstance(String sdkKey, String fallback, String datafileAccessToken, CloseableHttpClient customHttpClient) { + public static Optimizely newDefaultInstance( + String sdkKey, + String fallback, + String datafileAccessToken, + CloseableHttpClient customHttpClient + ) { + OptimizelyHttpClient optimizelyHttpClient = customHttpClient == null ? null : new OptimizelyHttpClient(customHttpClient); + NotificationCenter notificationCenter = new NotificationCenter(); - OptimizelyHttpClient optimizelyHttpClient = new OptimizelyHttpClient(customHttpClient); - HttpProjectConfigManager.Builder builder; - builder = HttpProjectConfigManager.builder() + + HttpProjectConfigManager.Builder builder = HttpProjectConfigManager.builder() .withDatafile(fallback) .withNotificationCenter(notificationCenter) - .withOptimizelyHttpClient(customHttpClient == null ? null : optimizelyHttpClient) + .withOptimizelyHttpClient(optimizelyHttpClient) .withSdkKey(sdkKey); if (datafileAccessToken != null) { builder.withDatafileAccessToken(datafileAccessToken); } - return newDefaultInstance(builder.build(), notificationCenter); - } + ProjectConfigManager configManager = builder.build(); + + EventHandler eventHandler = AsyncEventHandler.builder() + .withOptimizelyHttpClient(optimizelyHttpClient) + .build(); + ODPApiManager odpApiManager = new DefaultODPApiManager(optimizelyHttpClient); + + return newDefaultInstance(configManager, notificationCenter, eventHandler, odpApiManager); + } + /** * Returns a new Optimizely instance based on preset configuration. * EventHandler - {@link AsyncEventHandler} @@ -329,6 +343,19 @@ public static Optimizely newDefaultInstance(ProjectConfigManager configManager, * @return A new Optimizely instance * */ public static Optimizely newDefaultInstance(ProjectConfigManager configManager, NotificationCenter notificationCenter, EventHandler eventHandler) { + return newDefaultInstance(configManager, notificationCenter, eventHandler, null); + } + + /** + * Returns a new Optimizely instance based on preset configuration. + * + * @param configManager The {@link ProjectConfigManager} supplied to Optimizely instance. + * @param notificationCenter The {@link ProjectConfigManager} supplied to Optimizely instance. + * @param eventHandler The {@link EventHandler} supplied to Optimizely instance. + * @param odpApiManager The {@link ODPApiManager} supplied to Optimizely instance. + * @return A new Optimizely instance + * */ + public static Optimizely newDefaultInstance(ProjectConfigManager configManager, NotificationCenter notificationCenter, EventHandler eventHandler, ODPApiManager odpApiManager) { if (notificationCenter == null) { notificationCenter = new NotificationCenter(); } @@ -338,9 +365,8 @@ public static Optimizely newDefaultInstance(ProjectConfigManager configManager, .withNotificationCenter(notificationCenter) .build(); - ODPApiManager defaultODPApiManager = new DefaultODPApiManager(); ODPManager odpManager = ODPManager.builder() - .withApiManager(defaultODPApiManager) + .withApiManager(odpApiManager != null ? odpApiManager : new DefaultODPApiManager()) .build(); return Optimizely.builder() diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyHttpClient.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyHttpClient.java index f4040276f..363bce59c 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyHttpClient.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyHttpClient.java @@ -41,7 +41,6 @@ public class OptimizelyHttpClient implements Closeable { private static final Logger logger = LoggerFactory.getLogger(OptimizelyHttpClient.class); - private final CloseableHttpClient httpClient; OptimizelyHttpClient(CloseableHttpClient httpClient) { diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java index a583eae98..095e32a67 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java @@ -61,7 +61,8 @@ public class HttpProjectConfigManager extends PollingProjectConfigManager { private static final Logger logger = LoggerFactory.getLogger(HttpProjectConfigManager.class); - private final OptimizelyHttpClient httpClient; + @VisibleForTesting + public final OptimizelyHttpClient httpClient; private final URI uri; private final String datafileAccessToken; private String datafileLastModified; diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java index 539b8b642..434b29103 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java @@ -45,6 +45,7 @@ import java.util.concurrent.TimeUnit; import javax.annotation.CheckForNull; +import javax.annotation.Nullable; /** * {@link EventHandler} implementation that queues events and has a separate pool of threads responsible @@ -67,7 +68,8 @@ public class AsyncEventHandler implements EventHandler, AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(AsyncEventHandler.class); private static final ProjectConfigResponseHandler EVENT_RESPONSE_HANDLER = new ProjectConfigResponseHandler(); - private final OptimizelyHttpClient httpClient; + @VisibleForTesting + public final OptimizelyHttpClient httpClient; private final ExecutorService workerExecutor; private final long closeTimeout; @@ -110,7 +112,15 @@ public AsyncEventHandler(int queueCapacity, int validateAfter, long closeTimeout, TimeUnit closeTimeoutUnit) { - this(queueCapacity, numWorkers, maxConnections, connectionsPerRoute, validateAfter, closeTimeout, closeTimeoutUnit, null); + this(queueCapacity, + numWorkers, + maxConnections, + connectionsPerRoute, + validateAfter, + closeTimeout, + closeTimeoutUnit, + null, + null); } public AsyncEventHandler(int queueCapacity, @@ -120,24 +130,27 @@ public AsyncEventHandler(int queueCapacity, int validateAfter, long closeTimeout, TimeUnit closeTimeoutUnit, + @Nullable OptimizelyHttpClient httpClient, @Nullable ThreadFactory threadFactory) { + if (httpClient != null) { + this.httpClient = httpClient; + } else { + maxConnections = validateInput("maxConnections", maxConnections, DEFAULT_MAX_CONNECTIONS); + connectionsPerRoute = validateInput("connectionsPerRoute", connectionsPerRoute, DEFAULT_MAX_PER_ROUTE); + validateAfter = validateInput("validateAfter", validateAfter, DEFAULT_VALIDATE_AFTER_INACTIVITY); + this.httpClient = OptimizelyHttpClient.builder() + .withMaxTotalConnections(maxConnections) + .withMaxPerRoute(connectionsPerRoute) + .withValidateAfterInactivity(validateAfter) + // infrequent event discards observed. staled connections force-closed after a long idle time. + .withEvictIdleConnections(1L, TimeUnit.MINUTES) + .build(); + } queueCapacity = validateInput("queueCapacity", queueCapacity, DEFAULT_QUEUE_CAPACITY); numWorkers = validateInput("numWorkers", numWorkers, DEFAULT_NUM_WORKERS); - maxConnections = validateInput("maxConnections", maxConnections, DEFAULT_MAX_CONNECTIONS); - connectionsPerRoute = validateInput("connectionsPerRoute", connectionsPerRoute, DEFAULT_MAX_PER_ROUTE); - validateAfter = validateInput("validateAfter", validateAfter, DEFAULT_VALIDATE_AFTER_INACTIVITY); - - this.httpClient = OptimizelyHttpClient.builder() - .withMaxTotalConnections(maxConnections) - .withMaxPerRoute(connectionsPerRoute) - .withValidateAfterInactivity(validateAfter) - // infrequent event discards observed. staled connections force-closed after a long idle time. - .withEvictIdleConnections(1L, TimeUnit.MINUTES) - .build(); NamedThreadFactory namedThreadFactory = new NamedThreadFactory("optimizely-event-dispatcher-thread-%s", true, threadFactory); - this.workerExecutor = new ThreadPoolExecutor(numWorkers, numWorkers, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(queueCapacity), @@ -302,6 +315,7 @@ public static class Builder { int validateAfterInactivity = PropertyUtils.getInteger(CONFIG_VALIDATE_AFTER_INACTIVITY, DEFAULT_VALIDATE_AFTER_INACTIVITY); private long closeTimeout = Long.MAX_VALUE; private TimeUnit closeTimeoutUnit = TimeUnit.MILLISECONDS; + private OptimizelyHttpClient httpClient; public Builder withQueueCapacity(int queueCapacity) { if (queueCapacity <= 0) { @@ -344,6 +358,11 @@ public Builder withCloseTimeout(long closeTimeout, TimeUnit unit) { return this; } + public Builder withOptimizelyHttpClient(OptimizelyHttpClient httpClient) { + this.httpClient = httpClient; + return this; + } + public AsyncEventHandler build() { return new AsyncEventHandler( queueCapacity, @@ -352,7 +371,9 @@ public AsyncEventHandler build() { maxPerRoute, validateAfterInactivity, closeTimeout, - closeTimeoutUnit + closeTimeoutUnit, + httpClient, + null ); } } diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java index 3a7ae3291..b733427de 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java @@ -27,6 +27,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.annotation.Nullable; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.util.Iterator; @@ -36,11 +37,13 @@ public class DefaultODPApiManager implements ODPApiManager { private static final Logger logger = LoggerFactory.getLogger(DefaultODPApiManager.class); - private final OptimizelyHttpClient httpClientSegments; - private final OptimizelyHttpClient httpClientEvents; + @VisibleForTesting + public final OptimizelyHttpClient httpClientSegments; + @VisibleForTesting + public final OptimizelyHttpClient httpClientEvents; public DefaultODPApiManager() { - this(OptimizelyHttpClient.builder().build()); + this(null); } public DefaultODPApiManager(int segmentFetchTimeoutMillis, int eventDispatchTimeoutMillis) { @@ -53,8 +56,11 @@ public DefaultODPApiManager(int segmentFetchTimeoutMillis, int eventDispatchTime } } - @VisibleForTesting - DefaultODPApiManager(OptimizelyHttpClient httpClient) { + public DefaultODPApiManager(@Nullable OptimizelyHttpClient customHttpClient) { + OptimizelyHttpClient httpClient = customHttpClient; + if (httpClient == null) { + httpClient = OptimizelyHttpClient.builder().build(); + } this.httpClientSegments = httpClient; this.httpClientEvents = httpClient; } diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java index aaa3a67fa..a15085645 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java @@ -25,12 +25,10 @@ import com.optimizely.ab.event.BatchEventProcessor; import com.optimizely.ab.internal.PropertyUtils; import com.optimizely.ab.notification.NotificationCenter; -import org.apache.http.HttpHost; -import org.apache.http.conn.routing.HttpRoutePlanner; +import com.optimizely.ab.odp.DefaultODPApiManager; +import com.optimizely.ab.odp.ODPManager; import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.impl.client.HttpClients; -import org.apache.http.impl.conn.DefaultProxyRoutePlanner; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -247,21 +245,39 @@ public void newDefaultInstanceWithDatafileAccessToken() throws Exception { @Test public void newDefaultInstanceWithDatafileAccessTokenAndCustomHttpClient() throws Exception { - // Add custom Proxy and Port here - int port = 443; - String proxyHostName = "someProxy.com"; - HttpHost proxyHost = new HttpHost(proxyHostName, port); + CloseableHttpClient customHttpClient = HttpClients.custom().build(); - HttpRoutePlanner routePlanner = new DefaultProxyRoutePlanner(proxyHost); - - HttpClientBuilder clientBuilder = HttpClients.custom(); - clientBuilder = clientBuilder.setRoutePlanner(routePlanner); - - CloseableHttpClient httpClient = clientBuilder.build(); String datafileString = Resources.toString(Resources.getResource("valid-project-config-v4.json"), Charsets.UTF_8); - optimizely = OptimizelyFactory.newDefaultInstance("sdk-key", datafileString, "auth-token", httpClient); + optimizely = OptimizelyFactory.newDefaultInstance("sdk-key", datafileString, "auth-token", customHttpClient); assertTrue(optimizely.isValid()); + + // HttpProjectConfigManager should be using the customHttpClient + + HttpProjectConfigManager projectConfigManager = (HttpProjectConfigManager) optimizely.projectConfigManager; + assert(doesUseCustomHttpClient(projectConfigManager.httpClient, customHttpClient)); + + // AsyncEventHandler should be using the customHttpClient + + BatchEventProcessor eventProcessor = (BatchEventProcessor) optimizely.eventProcessor; + AsyncEventHandler eventHandler = (AsyncEventHandler)eventProcessor.eventHandler; + assert(doesUseCustomHttpClient(eventHandler.httpClient, customHttpClient)); + + // ODPManager should be using the customHttpClient + + ODPManager odpManager = optimizely.getODPManager(); + assert odpManager != null; + DefaultODPApiManager odpApiManager = (DefaultODPApiManager) odpManager.getEventManager().apiManager; + assert(doesUseCustomHttpClient(odpApiManager.httpClientSegments, customHttpClient)); + assert(doesUseCustomHttpClient(odpApiManager.httpClientEvents, customHttpClient)); + } + + boolean doesUseCustomHttpClient(OptimizelyHttpClient optimizelyHttpClient, CloseableHttpClient customHttpClient) { + if (optimizelyHttpClient == null) { + return false; + } + return optimizelyHttpClient.getHttpClient() == customHttpClient; } + public ProjectConfigManager projectConfigManagerReturningNull = new ProjectConfigManager() { @Override public ProjectConfig getConfig() { diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java index 9cbc0bb01..77960d518 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java @@ -20,7 +20,6 @@ import com.google.common.io.Resources; import com.optimizely.ab.OptimizelyHttpClient; import org.apache.http.HttpHeaders; -import org.apache.http.HttpResponse; import org.apache.http.ProtocolVersion; import org.apache.http.StatusLine; import org.apache.http.client.ClientProtocolException; @@ -44,7 +43,6 @@ import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.MINUTES; import static org.junit.Assert.*; -import static org.mockito.Matchers.any; import static org.mockito.Mockito.*; @RunWith(MockitoJUnitRunner.class) diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/event/AsyncEventHandlerTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/event/AsyncEventHandlerTest.java index 79a4105a1..f87811b96 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/event/AsyncEventHandlerTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/event/AsyncEventHandlerTest.java @@ -22,14 +22,9 @@ import com.optimizely.ab.event.internal.payload.EventBatch; import org.apache.http.client.ResponseHandler; import org.apache.http.client.methods.HttpGet; -import org.apache.http.impl.client.CloseableHttpClient; -import org.junit.Rule; import org.junit.Test; -import org.junit.rules.ExpectedException; import org.junit.runner.RunWith; import org.mockito.Mock; -import org.mockito.junit.MockitoJUnit; -import org.mockito.junit.MockitoRule; import java.io.IOException; import java.util.HashMap; @@ -38,7 +33,6 @@ import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.mockito.runners.MockitoJUnitRunner; import static com.optimizely.ab.event.AsyncEventHandler.builder; @@ -124,6 +118,30 @@ public void testShutdownAndForcedTermination() throws Exception { verify(mockHttpClient).close(); } + @Test + public void testBuilderWithCustomHttpClient() { + OptimizelyHttpClient customHttpClient = OptimizelyHttpClient.builder().build(); + + AsyncEventHandler eventHandler = builder() + .withOptimizelyHttpClient(customHttpClient) + .withMaxTotalConnections(1) + .withMaxPerRoute(2) + .withCloseTimeout(10, TimeUnit.SECONDS) + .build(); + + assert eventHandler.httpClient == customHttpClient; + } + + @Test + public void testBuilderWithDefaultHttpClient() { + AsyncEventHandler eventHandler = builder() + .withMaxTotalConnections(3) + .withMaxPerRoute(4) + .withCloseTimeout(10, TimeUnit.SECONDS) + .build(); + assert(eventHandler.httpClient != null); + } + @Test public void testInvalidQueueCapacity() { AsyncEventHandler.Builder builder = builder(); diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java index a268cacc7..780831ff2 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java @@ -33,7 +33,6 @@ import java.util.List; import static org.junit.Assert.*; -import static org.mockito.Matchers.any; import static org.mockito.Mockito.*; public class DefaultODPApiManagerTest { From 8fdbfbf631085e08e2b6dd4577574892d0edb896 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Fri, 12 Apr 2024 12:57:48 -0700 Subject: [PATCH 128/147] prepare release 4.1.0 (#544) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 764c83982..67458f732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Optimizely Java X SDK Changelog +## [4.1.0] +April 12th, 2024 + +### New Features +* OptimizelyFactory method for injecting customHttpClient is fixed to share the customHttpClient for all modules using httpClient (HttpProjectConfigManager, AsyncEventHander, ODPManager) ([#542](https://github.com/optimizely/java-sdk/pull/542)). +* A custom ThreadFactory can be injected to support virtual threads (Loom) ([#540](https://github.com/optimizely/java-sdk/pull/540)). + + ## [4.0.0] January 16th, 2024 From 71f9e75c3ae9c408dd1d70d9ee8b94689675401c Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Wed, 8 May 2024 15:48:58 -0700 Subject: [PATCH 129/147] [FSSDK-10095] fix events dropped on staled connections. (#545) Events can be discarded for staled connections with httpclient connection pooling. This PR fixs it with - - reduce the time for connection validation from 5 to 1sec. - enable retries (x3) for event POST. --- .../ab/odp/ODPEventManagerTest.java | 5 - core-httpclient-impl/README.md | 10 +- core-httpclient-impl/build.gradle | 3 +- .../com/optimizely/ab/HttpClientUtils.java | 4 +- .../optimizely/ab/OptimizelyHttpClient.java | 23 +++- .../ab/event/AsyncEventHandler.java | 21 ++-- .../ab/OptimizelyHttpClientTest.java | 102 ++++++++++++++++-- .../ab/event/AsyncEventHandlerTest.java | 12 +++ 8 files changed, 147 insertions(+), 33 deletions(-) diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java index 8a7546300..0ade4652f 100644 --- a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java @@ -51,11 +51,6 @@ public class ODPEventManagerTest { @Captor ArgumentCaptor<String> payloadCaptor; - @Before - public void setup() { - mockApiManager = mock(ODPApiManager.class); - } - @Test public void logAndDiscardEventWhenEventManagerIsNotRunning() { ODPConfig odpConfig = new ODPConfig("key", "host", null); diff --git a/core-httpclient-impl/README.md b/core-httpclient-impl/README.md index 8e70b2ddb..762acb31a 100644 --- a/core-httpclient-impl/README.md +++ b/core-httpclient-impl/README.md @@ -107,23 +107,23 @@ The number of workers determines the number of threads the thread pool uses. The following builder methods can be used to custom configure the `AsyncEventHandler`. |Method Name|Default Value|Description| -|---|---|---| +|---|---|-----------------------------------------------| |`withQueueCapacity(int)`|10000|Queue size for pending logEvents| |`withNumWorkers(int)`|2|Number of worker threads| |`withMaxTotalConnections(int)`|200|Maximum number of connections| |`withMaxPerRoute(int)`|20|Maximum number of connections per route| -|`withValidateAfterInactivity(int)`|5000|Time to maintain idol connections (in milliseconds)| +|`withValidateAfterInactivity(int)`|1000|Time to maintain idle connections (in milliseconds)| ### Advanced configuration The following properties can be set to override the default configuration. |Property Name|Default Value|Description| -|---|---|---| +|---|---|-----------------------------------------------| |**async.event.handler.queue.capacity**|10000|Queue size for pending logEvents| |**async.event.handler.num.workers**|2|Number of worker threads| |**async.event.handler.max.connections**|200|Maximum number of connections| |**async.event.handler.event.max.per.route**|20|Maximum number of connections per route| -|**async.event.handler.validate.after**|5000|Time to maintain idol connections (in milliseconds)| +|**async.event.handler.validate.after**|1000|Time to maintain idle connections (in milliseconds)| ## HttpProjectConfigManager @@ -243,4 +243,4 @@ Optimizely optimizely = OptimizelyFactory.newDefaultInstance(); to enable request batching to the Optimizely logging endpoint. By default, a maximum of 10 events are included in each batch for a maximum interval of 30 seconds. These parameters are configurable via systems properties or through the `OptimizelyFactory#setMaxEventBatchSize` and `OptimizelyFactory#setMaxEventBatchInterval` methods. - \ No newline at end of file + diff --git a/core-httpclient-impl/build.gradle b/core-httpclient-impl/build.gradle index b43c70269..e4cdd4b99 100644 --- a/core-httpclient-impl/build.gradle +++ b/core-httpclient-impl/build.gradle @@ -1,9 +1,8 @@ dependencies { compile project(':core-api') - compileOnly group: 'com.google.code.gson', name: 'gson', version: gsonVersion - compile group: 'org.apache.httpcomponents', name: 'httpclient', version: httpClientVersion + testCompile 'org.mock-server:mockserver-netty:5.1.1' } task exhaustiveTest { diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/HttpClientUtils.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/HttpClientUtils.java index dc786c4f6..bc697e642 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/HttpClientUtils.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/HttpClientUtils.java @@ -26,7 +26,9 @@ public final class HttpClientUtils { public static final int CONNECTION_TIMEOUT_MS = 10000; public static final int CONNECTION_REQUEST_TIMEOUT_MS = 5000; public static final int SOCKET_TIMEOUT_MS = 10000; - + public static final int DEFAULT_VALIDATE_AFTER_INACTIVITY = 1000; + public static final int DEFAULT_MAX_CONNECTIONS = 200; + public static final int DEFAULT_MAX_PER_ROUTE = 20; private static RequestConfig requestConfigWithTimeout; private HttpClientUtils() { diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyHttpClient.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyHttpClient.java index 363bce59c..5b515aea6 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyHttpClient.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyHttpClient.java @@ -17,11 +17,15 @@ package com.optimizely.ab; import com.optimizely.ab.annotations.VisibleForTesting; +import com.optimizely.ab.HttpClientUtils; + import org.apache.http.client.HttpClient; +import org.apache.http.client.HttpRequestRetryHandler; import org.apache.http.client.ResponseHandler; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.DefaultHttpRequestRetryHandler; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.impl.client.HttpClients; import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; @@ -73,16 +77,20 @@ public static class Builder { // The following static values are public so that they can be tweaked if necessary. // These are the recommended settings for http protocol. https://hc.apache.org/httpcomponents-client-ga/tutorial/html/connmgmt.html // The maximum number of connections allowed across all routes. - private int maxTotalConnections = 200; + int maxTotalConnections = HttpClientUtils.DEFAULT_MAX_CONNECTIONS; // The maximum number of connections allowed for a route - private int maxPerRoute = 20; + int maxPerRoute = HttpClientUtils.DEFAULT_MAX_PER_ROUTE; // Defines period of inactivity in milliseconds after which persistent connections must be re-validated prior to being leased to the consumer. - private int validateAfterInactivity = 5000; + // If this is too long, it's expected to see more requests dropped on staled connections (dropped by the server or networks). + // We can configure retries (POST for AsyncEventDispatcher) to cover the staled connections. + int validateAfterInactivity = HttpClientUtils.DEFAULT_VALIDATE_AFTER_INACTIVITY; // force-close the connection after this idle time (with 0, eviction is disabled by default) long evictConnectionIdleTimePeriod = 0; + HttpRequestRetryHandler customRetryHandler = null; TimeUnit evictConnectionIdleTimeUnit = TimeUnit.MILLISECONDS; private int timeoutMillis = HttpClientUtils.CONNECTION_TIMEOUT_MS; + private Builder() { } @@ -107,6 +115,12 @@ public Builder withEvictIdleConnections(long maxIdleTime, TimeUnit maxIdleTimeUn this.evictConnectionIdleTimeUnit = maxIdleTimeUnit; return this; } + + // customize retryHandler (DefaultHttpRequestRetryHandler will be used by default) + public Builder withRetryHandler(HttpRequestRetryHandler retryHandler) { + this.customRetryHandler = retryHandler; + return this; + } public Builder setTimeoutMillis(int timeoutMillis) { this.timeoutMillis = timeoutMillis; @@ -124,6 +138,9 @@ public OptimizelyHttpClient build() { .setConnectionManager(poolingHttpClientConnectionManager) .disableCookieManagement() .useSystemProperties(); + if (customRetryHandler != null) { + builder.setRetryHandler(customRetryHandler); + } logger.debug("Creating HttpClient with timeout: " + timeoutMillis); diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java index 434b29103..2a9c10ec9 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java @@ -16,6 +16,7 @@ */ package com.optimizely.ab.event; +import com.optimizely.ab.HttpClientUtils; import com.optimizely.ab.NamedThreadFactory; import com.optimizely.ab.OptimizelyHttpClient; import com.optimizely.ab.annotations.VisibleForTesting; @@ -31,6 +32,7 @@ import org.apache.http.client.methods.HttpRequestBase; import org.apache.http.client.utils.URIBuilder; import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.DefaultHttpRequestRetryHandler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -45,7 +47,6 @@ import java.util.concurrent.TimeUnit; import javax.annotation.CheckForNull; -import javax.annotation.Nullable; /** * {@link EventHandler} implementation that queues events and has a separate pool of threads responsible @@ -61,9 +62,7 @@ public class AsyncEventHandler implements EventHandler, AutoCloseable { public static final int DEFAULT_QUEUE_CAPACITY = 10000; public static final int DEFAULT_NUM_WORKERS = 2; - public static final int DEFAULT_MAX_CONNECTIONS = 200; - public static final int DEFAULT_MAX_PER_ROUTE = 20; - public static final int DEFAULT_VALIDATE_AFTER_INACTIVITY = 5000; + private static final Logger logger = LoggerFactory.getLogger(AsyncEventHandler.class); private static final ProjectConfigResponseHandler EVENT_RESPONSE_HANDLER = new ProjectConfigResponseHandler(); @@ -135,15 +134,17 @@ public AsyncEventHandler(int queueCapacity, if (httpClient != null) { this.httpClient = httpClient; } else { - maxConnections = validateInput("maxConnections", maxConnections, DEFAULT_MAX_CONNECTIONS); - connectionsPerRoute = validateInput("connectionsPerRoute", connectionsPerRoute, DEFAULT_MAX_PER_ROUTE); - validateAfter = validateInput("validateAfter", validateAfter, DEFAULT_VALIDATE_AFTER_INACTIVITY); + maxConnections = validateInput("maxConnections", maxConnections, HttpClientUtils.DEFAULT_MAX_CONNECTIONS); + connectionsPerRoute = validateInput("connectionsPerRoute", connectionsPerRoute, HttpClientUtils.DEFAULT_MAX_PER_ROUTE); + validateAfter = validateInput("validateAfter", validateAfter, HttpClientUtils.DEFAULT_VALIDATE_AFTER_INACTIVITY); this.httpClient = OptimizelyHttpClient.builder() .withMaxTotalConnections(maxConnections) .withMaxPerRoute(connectionsPerRoute) .withValidateAfterInactivity(validateAfter) // infrequent event discards observed. staled connections force-closed after a long idle time. .withEvictIdleConnections(1L, TimeUnit.MINUTES) + // enable retry on event POST (default: retry on GET only) + .withRetryHandler(new DefaultHttpRequestRetryHandler(3, true)) .build(); } @@ -310,9 +311,9 @@ public static class Builder { int queueCapacity = PropertyUtils.getInteger(CONFIG_QUEUE_CAPACITY, DEFAULT_QUEUE_CAPACITY); int numWorkers = PropertyUtils.getInteger(CONFIG_NUM_WORKERS, DEFAULT_NUM_WORKERS); - int maxTotalConnections = PropertyUtils.getInteger(CONFIG_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS); - int maxPerRoute = PropertyUtils.getInteger(CONFIG_MAX_PER_ROUTE, DEFAULT_MAX_PER_ROUTE); - int validateAfterInactivity = PropertyUtils.getInteger(CONFIG_VALIDATE_AFTER_INACTIVITY, DEFAULT_VALIDATE_AFTER_INACTIVITY); + int maxTotalConnections = PropertyUtils.getInteger(CONFIG_MAX_CONNECTIONS, HttpClientUtils.DEFAULT_MAX_CONNECTIONS); + int maxPerRoute = PropertyUtils.getInteger(CONFIG_MAX_PER_ROUTE, HttpClientUtils.DEFAULT_MAX_PER_ROUTE); + int validateAfterInactivity = PropertyUtils.getInteger(CONFIG_VALIDATE_AFTER_INACTIVITY, HttpClientUtils.DEFAULT_VALIDATE_AFTER_INACTIVITY); private long closeTimeout = Long.MAX_VALUE; private TimeUnit closeTimeoutUnit = TimeUnit.MILLISECONDS; private OptimizelyHttpClient httpClient; diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyHttpClientTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyHttpClientTest.java index 4667bec34..d80a4f1ef 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyHttpClientTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyHttpClientTest.java @@ -16,27 +16,39 @@ */ package com.optimizely.ab; +import org.apache.http.HttpException; +import org.apache.http.client.HttpRequestRetryHandler; import org.apache.http.client.ResponseHandler; +import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.client.methods.RequestBuilder; import org.apache.http.conn.HttpHostConnectException; import org.apache.http.impl.client.CloseableHttpClient; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; +import org.apache.http.impl.client.DefaultHttpRequestRetryHandler; +import org.apache.http.protocol.HttpContext; +import org.junit.*; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.ConnectionOptions; +import org.mockserver.model.HttpError; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; import java.io.IOException; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import static com.optimizely.ab.OptimizelyHttpClient.builder; import static java.util.concurrent.TimeUnit.*; import static org.junit.Assert.*; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; +import static org.mockserver.model.HttpForward.forward; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.*; +import static org.mockserver.model.HttpResponse.response; +import static org.mockserver.verify.VerificationTimes.exactly; public class OptimizelyHttpClientTest { - @Before public void setUp() { System.setProperty("https.proxyHost", "localhost"); @@ -51,7 +63,13 @@ public void tearDown() { @Test public void testDefaultConfiguration() { - OptimizelyHttpClient optimizelyHttpClient = builder().build(); + OptimizelyHttpClient.Builder builder = builder(); + assertEquals(builder.validateAfterInactivity, 1000); + assertEquals(builder.maxTotalConnections, 200); + assertEquals(builder.maxPerRoute, 20); + assertNull(builder.customRetryHandler); + + OptimizelyHttpClient optimizelyHttpClient = builder.build(); assertTrue(optimizelyHttpClient.getHttpClient() instanceof CloseableHttpClient); } @@ -101,4 +119,74 @@ public void testExecute() throws IOException { OptimizelyHttpClient optimizelyHttpClient = new OptimizelyHttpClient(mockHttpClient); assertTrue(optimizelyHttpClient.execute(httpUriRequest, responseHandler)); } + + @Test + public void testRetriesWithCustomRetryHandler() throws IOException { + + // [NOTE] Request retries are all handled inside HttpClient. Not easy for unit test. + // - "DefaultHttpRetryHandler" in HttpClient retries only with special types of Exceptions + // like "NoHttpResponseException", etc. + // Other exceptions (SocketTimeout, ProtocolException, etc.) all ignored. + // - Not easy to force the specific exception type in the low-level. + // - This test just validates custom retry handler injected ok by validating the number of retries. + + class CustomRetryHandler implements HttpRequestRetryHandler { + private final int maxRetries; + + public CustomRetryHandler(int maxRetries) { + this.maxRetries = maxRetries; + } + + @Override + public boolean retryRequest(IOException exception, int executionCount, HttpContext context) { + // override to retry for any type of exceptions + return executionCount < maxRetries; + } + } + + int port = 9999; + ClientAndServer mockServer; + int retryCount; + + // default httpclient (retries enabled by default, but no retry for timeout connection) + + mockServer = ClientAndServer.startClientAndServer(port); + mockServer + .when(request().withMethod("GET").withPath("/")) + .error(HttpError.error()); + + OptimizelyHttpClient clientDefault = OptimizelyHttpClient.builder() + .setTimeoutMillis(100) + .build(); + + try { + clientDefault.execute(new HttpGet("http://localhost:" + port)); + fail(); + } catch (Exception e) { + retryCount = mockServer.retrieveRecordedRequests(request()).length; + assertEquals(1, retryCount); + } + mockServer.stop(); + + // httpclient with custom retry handler (5 times retries for any request) + + mockServer = ClientAndServer.startClientAndServer(port); + mockServer + .when(request().withMethod("GET").withPath("/")) + .error(HttpError.error()); + + OptimizelyHttpClient clientWithRetries = OptimizelyHttpClient.builder() + .withRetryHandler(new CustomRetryHandler(5)) + .setTimeoutMillis(100) + .build(); + + try { + clientWithRetries.execute(new HttpGet("http://localhost:" + port)); + fail(); + } catch (Exception e) { + retryCount = mockServer.retrieveRecordedRequests(request()).length; + assertEquals(5, retryCount); + } + mockServer.stop(); + } } diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/event/AsyncEventHandlerTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/event/AsyncEventHandlerTest.java index f87811b96..19f1faba9 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/event/AsyncEventHandlerTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/event/AsyncEventHandlerTest.java @@ -124,6 +124,7 @@ public void testBuilderWithCustomHttpClient() { AsyncEventHandler eventHandler = builder() .withOptimizelyHttpClient(customHttpClient) + // these params will be ignored when customHttpClient is injected .withMaxTotalConnections(1) .withMaxPerRoute(2) .withCloseTimeout(10, TimeUnit.SECONDS) @@ -134,6 +135,17 @@ public void testBuilderWithCustomHttpClient() { @Test public void testBuilderWithDefaultHttpClient() { + AsyncEventHandler.Builder builder = builder(); + assertEquals(builder.validateAfterInactivity, 1000); + assertEquals(builder.maxTotalConnections, 200); + assertEquals(builder.maxPerRoute, 20); + + AsyncEventHandler eventHandler = builder.build(); + assert(eventHandler.httpClient != null); + } + + @Test + public void testBuilderWithDefaultHttpClientAndCustomParams() { AsyncEventHandler eventHandler = builder() .withMaxTotalConnections(3) .withMaxPerRoute(4) From 3b42949f16762ba06a88f68dc0af383b20b3be47 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Wed, 8 May 2024 16:30:32 -0700 Subject: [PATCH 130/147] prepare for v3.10.5 (#546) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67458f732..8bcd3276f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Optimizely Java X SDK Changelog + +## [3.10.5] +May 8th, 2024 + +### Fixes +- Fix logx events discarded for staled connections with httpclient connection pooling ([#545](https://github.com/optimizely/java-sdk/pull/545)). + + ## [4.1.0] April 12th, 2024 From 7cd42fc15d12d460793c41932f8aff25e84c3009 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Wed, 8 May 2024 17:33:19 -0700 Subject: [PATCH 131/147] prepare for 4.1.1 (#547) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bcd3276f..d92e70fbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Optimizely Java X SDK Changelog -## [3.10.5] +## [4.1.1] May 8th, 2024 ### Fixes From e813bfe1910af25d1dc2be1daf5764b61ae42c9f Mon Sep 17 00:00:00 2001 From: Farhan Anjum <Farhan.Anjum@optimizely.com> Date: Tue, 24 Sep 2024 22:43:41 +0600 Subject: [PATCH 132/147] [FSSDK-10665] fix: Github Actions YAML files vulnerable to script injections corrected (#548) --- .github/workflows/integration_test.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 9471458fc..c54149edd 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -23,15 +23,19 @@ jobs: path: 'home/runner/travisci-tools' ref: 'master' - name: set SDK Branch if PR + env: + HEAD_REF: ${{ github.head_ref }} if: ${{ github.event_name == 'pull_request' }} run: | - echo "SDK_BRANCH=${{ github.head_ref }}" >> $GITHUB_ENV - echo "TRAVIS_BRANCH=${{ github.head_ref }}" >> $GITHUB_ENV + echo "SDK_BRANCH=$HEAD_REF" >> $GITHUB_ENV + echo "TRAVIS_BRANCH=$HEAD_REF" >> $GITHUB_ENV - name: set SDK Branch if not pull request + env: + REF_NAME: ${{ github.ref_name }} if: ${{ github.event_name != 'pull_request' }} run: | - echo "SDK_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV - echo "TRAVIS_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV + echo "SDK_BRANCH=$REF_NAME" >> $GITHUB_ENV + echo "TRAVIS_BRANCH=$REF_NAME" >> $GITHUB_ENV - name: Trigger build env: SDK: java From fe503181eed8df601c356e4e226edae1af7bcdb0 Mon Sep 17 00:00:00 2001 From: Raju Ahmed <raju.ahmed@optimizely.com> Date: Wed, 23 Oct 2024 00:47:38 +0600 Subject: [PATCH 133/147] [FSSDK-10762] Implement UPS request batching for decideForKeys (#549) --- .../java/com/optimizely/ab/Optimizely.java | 128 ++++++++---- .../ab/bucketing/DecisionService.java | 188 ++++++++++++------ .../ab/bucketing/UserProfileTracker.java | 109 ++++++++++ .../ab/OptimizelyUserContextTest.java | 98 ++++++++- .../ab/bucketing/DecisionServiceTest.java | 76 ++++--- 5 files changed, 458 insertions(+), 141 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/bucketing/UserProfileTracker.java diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index fede007d5..0e260072e 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2016-2023, Optimizely, Inc. and contributors * + * Copyright 2016-2024, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -42,8 +42,10 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.ThreadSafe; + import java.io.Closeable; import java.util.*; +import java.util.stream.Collectors; import static com.optimizely.ab.internal.SafetyUtils.tryClose; @@ -1194,42 +1196,26 @@ private OptimizelyUserContext createUserContextCopy(@Nonnull String userId, @Non OptimizelyDecision decide(@Nonnull OptimizelyUserContext user, @Nonnull String key, @Nonnull List<OptimizelyDecideOption> options) { - ProjectConfig projectConfig = getProjectConfig(); if (projectConfig == null) { return OptimizelyDecision.newErrorDecision(key, user, DecisionMessage.SDK_NOT_READY.reason()); } - FeatureFlag flag = projectConfig.getFeatureKeyMapping().get(key); - if (flag == null) { - return OptimizelyDecision.newErrorDecision(key, user, DecisionMessage.FLAG_KEY_INVALID.reason(key)); - } - - String userId = user.getUserId(); - Map<String, Object> attributes = user.getAttributes(); - Boolean decisionEventDispatched = false; List<OptimizelyDecideOption> allOptions = getAllOptions(options); - DecisionReasons decisionReasons = DefaultDecisionReasons.newInstance(allOptions); + allOptions.remove(OptimizelyDecideOption.ENABLED_FLAGS_ONLY); - Map<String, ?> copiedAttributes = new HashMap<>(attributes); - FeatureDecision flagDecision; - - // Check Forced Decision - OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flag.getKey(), null); - DecisionResponse<Variation> forcedDecisionVariation = decisionService.validatedForcedDecision(optimizelyDecisionContext, projectConfig, user); - decisionReasons.merge(forcedDecisionVariation.getReasons()); - if (forcedDecisionVariation.getResult() != null) { - flagDecision = new FeatureDecision(null, forcedDecisionVariation.getResult(), FeatureDecision.DecisionSource.FEATURE_TEST); - } else { - // Regular decision - DecisionResponse<FeatureDecision> decisionVariation = decisionService.getVariationForFeature( - flag, - user, - projectConfig, - allOptions); - flagDecision = decisionVariation.getResult(); - decisionReasons.merge(decisionVariation.getReasons()); - } + return decideForKeys(user, Arrays.asList(key), allOptions, true).get(key); + } + + private OptimizelyDecision createOptimizelyDecision( + OptimizelyUserContext user, + String flagKey, + FeatureDecision flagDecision, + DecisionReasons decisionReasons, + List<OptimizelyDecideOption> allOptions, + ProjectConfig projectConfig + ) { + String userId = user.getUserId(); Boolean flagEnabled = false; if (flagDecision.variation != null) { @@ -1237,12 +1223,12 @@ OptimizelyDecision decide(@Nonnull OptimizelyUserContext user, flagEnabled = true; } } - logger.info("Feature \"{}\" is enabled for user \"{}\"? {}", key, userId, flagEnabled); + logger.info("Feature \"{}\" is enabled for user \"{}\"? {}", flagKey, userId, flagEnabled); Map<String, Object> variableMap = new HashMap<>(); if (!allOptions.contains(OptimizelyDecideOption.EXCLUDE_VARIABLES)) { DecisionResponse<Map<String, Object>> decisionVariables = getDecisionVariableMap( - flag, + projectConfig.getFeatureKeyMapping().get(flagKey), flagDecision.variation, flagEnabled); variableMap = decisionVariables.getResult(); @@ -1261,6 +1247,12 @@ OptimizelyDecision decide(@Nonnull OptimizelyUserContext user, // add to event metadata as well (currently set to experimentKey) String ruleKey = flagDecision.experiment != null ? flagDecision.experiment.getKey() : null; + + Boolean decisionEventDispatched = false; + + Map<String, Object> attributes = user.getAttributes(); + Map<String, ?> copiedAttributes = new HashMap<>(attributes); + if (!allOptions.contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT)) { decisionEventDispatched = sendImpression( projectConfig, @@ -1268,7 +1260,7 @@ OptimizelyDecision decide(@Nonnull OptimizelyUserContext user, userId, copiedAttributes, flagDecision.variation, - key, + flagKey, decisionSource.toString(), flagEnabled); } @@ -1276,7 +1268,7 @@ OptimizelyDecision decide(@Nonnull OptimizelyUserContext user, DecisionNotification decisionNotification = DecisionNotification.newFlagDecisionNotificationBuilder() .withUserId(userId) .withAttributes(copiedAttributes) - .withFlagKey(key) + .withFlagKey(flagKey) .withEnabled(flagEnabled) .withVariables(variableMap) .withVariationKey(variationKey) @@ -1291,30 +1283,84 @@ OptimizelyDecision decide(@Nonnull OptimizelyUserContext user, flagEnabled, optimizelyJSON, ruleKey, - key, + flagKey, user, reasonsToReport); } Map<String, OptimizelyDecision> decideForKeys(@Nonnull OptimizelyUserContext user, + @Nonnull List<String> keys, + @Nonnull List<OptimizelyDecideOption> options) { + return decideForKeys(user, keys, options, false); + } + + private Map<String, OptimizelyDecision> decideForKeys(@Nonnull OptimizelyUserContext user, @Nonnull List<String> keys, - @Nonnull List<OptimizelyDecideOption> options) { + @Nonnull List<OptimizelyDecideOption> options, + boolean ignoreDefaultOptions) { Map<String, OptimizelyDecision> decisionMap = new HashMap<>(); ProjectConfig projectConfig = getProjectConfig(); if (projectConfig == null) { - logger.error("Optimizely instance is not valid, failing isFeatureEnabled call."); + logger.error("Optimizely instance is not valid, failing decideForKeys call."); return decisionMap; } if (keys.isEmpty()) return decisionMap; - List<OptimizelyDecideOption> allOptions = getAllOptions(options); + List<OptimizelyDecideOption> allOptions = ignoreDefaultOptions ? options: getAllOptions(options); + + Map<String, FeatureDecision> flagDecisions = new HashMap<>(); + Map<String, DecisionReasons> decisionReasonsMap = new HashMap<>(); + + List<FeatureFlag> flagsWithoutForcedDecision = new ArrayList<>(); + + List<String> validKeys = new ArrayList<>(); for (String key : keys) { - OptimizelyDecision decision = decide(user, key, options); - if (!allOptions.contains(OptimizelyDecideOption.ENABLED_FLAGS_ONLY) || decision.getEnabled()) { - decisionMap.put(key, decision); + FeatureFlag flag = projectConfig.getFeatureKeyMapping().get(key); + if (flag == null) { + decisionMap.put(key, OptimizelyDecision.newErrorDecision(key, user, DecisionMessage.FLAG_KEY_INVALID.reason(key))); + continue; + } + + validKeys.add(key); + + DecisionReasons decisionReasons = DefaultDecisionReasons.newInstance(allOptions); + decisionReasonsMap.put(key, decisionReasons); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(key, null); + DecisionResponse<Variation> forcedDecisionVariation = decisionService.validatedForcedDecision(optimizelyDecisionContext, projectConfig, user); + decisionReasons.merge(forcedDecisionVariation.getReasons()); + + if (forcedDecisionVariation.getResult() != null) { + flagDecisions.put(key, + new FeatureDecision(null, forcedDecisionVariation.getResult(), FeatureDecision.DecisionSource.FEATURE_TEST)); + } else { + flagsWithoutForcedDecision.add(flag); + } + } + + List<DecisionResponse<FeatureDecision>> decisionList = + decisionService.getVariationsForFeatureList(flagsWithoutForcedDecision, user, projectConfig, allOptions); + + for (int i = 0; i < flagsWithoutForcedDecision.size(); i++) { + DecisionResponse<FeatureDecision> decision = decisionList.get(i); + String flagKey = flagsWithoutForcedDecision.get(i).getKey(); + flagDecisions.put(flagKey, decision.getResult()); + decisionReasonsMap.get(flagKey).merge(decision.getReasons()); + } + + for (String key: validKeys) { + FeatureDecision flagDecision = flagDecisions.get(key); + DecisionReasons decisionReasons = decisionReasonsMap.get((key)); + + OptimizelyDecision optimizelyDecision = createOptimizelyDecision( + user, key, flagDecision, decisionReasons, allOptions, projectConfig + ); + + if (!allOptions.contains(OptimizelyDecideOption.ENABLED_FLAGS_ONLY) || optimizelyDecision.getEnabled()) { + decisionMap.put(key, optimizelyDecision); } } diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java index 84d47d03f..ff48ffb99 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2017-2022, Optimizely, Inc. and contributors * + * Copyright 2017-2022, 2024, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -81,18 +81,24 @@ public DecisionService(@Nonnull Bucketer bucketer, /** * Get a {@link Variation} of an {@link Experiment} for a user to be allocated into. * - * @param experiment The Experiment the user will be bucketed into. - * @param user The current OptimizelyUserContext - * @param projectConfig The current projectConfig - * @param options An array of decision options + * @param experiment The Experiment the user will be bucketed into. + * @param user The current OptimizelyUserContext + * @param projectConfig The current projectConfig + * @param options An array of decision options + * @param userProfileTracker tracker for reading and updating user profile of the user + * @param reasons Decision reasons * @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) and the decision reasons */ @Nonnull public DecisionResponse<Variation> getVariation(@Nonnull Experiment experiment, @Nonnull OptimizelyUserContext user, @Nonnull ProjectConfig projectConfig, - @Nonnull List<OptimizelyDecideOption> options) { - DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + @Nonnull List<OptimizelyDecideOption> options, + @Nullable UserProfileTracker userProfileTracker, + @Nullable DecisionReasons reasons) { + if (reasons == null) { + reasons = DefaultDecisionReasons.newInstance(); + } if (!ExperimentUtils.isExperimentActive(experiment)) { String message = reasons.addInfo("Experiment \"%s\" is not running.", experiment.getKey()); @@ -116,39 +122,13 @@ public DecisionResponse<Variation> getVariation(@Nonnull Experiment experiment, return new DecisionResponse(variation, reasons); } - // fetch the user profile map from the user profile service - boolean ignoreUPS = options.contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE); - UserProfile userProfile = null; - - if (userProfileService != null && !ignoreUPS) { - try { - Map<String, Object> userProfileMap = userProfileService.lookup(user.getUserId()); - if (userProfileMap == null) { - String message = reasons.addInfo("We were unable to get a user profile map from the UserProfileService."); - logger.info(message); - } else if (UserProfileUtils.isValidUserProfileMap(userProfileMap)) { - userProfile = UserProfileUtils.convertMapToUserProfile(userProfileMap); - } else { - String message = reasons.addInfo("The UserProfileService returned an invalid map."); - logger.warn(message); - } - } catch (Exception exception) { - String message = reasons.addInfo(exception.getMessage()); - logger.error(message); - errorHandler.handleError(new OptimizelyRuntimeException(exception)); - } - - // check if user exists in user profile - if (userProfile != null) { - decisionVariation = getStoredVariation(experiment, userProfile, projectConfig); - reasons.merge(decisionVariation.getReasons()); - variation = decisionVariation.getResult(); - // return the stored variation if it exists - if (variation != null) { - return new DecisionResponse(variation, reasons); - } - } else { // if we could not find a user profile, make a new one - userProfile = new UserProfile(user.getUserId(), new HashMap<String, Decision>()); + if (userProfileTracker != null) { + decisionVariation = getStoredVariation(experiment, userProfileTracker.getUserProfile(), projectConfig); + reasons.merge(decisionVariation.getReasons()); + variation = decisionVariation.getResult(); + // return the stored variation if it exists + if (variation != null) { + return new DecisionResponse(variation, reasons); } } @@ -162,8 +142,8 @@ public DecisionResponse<Variation> getVariation(@Nonnull Experiment experiment, variation = decisionVariation.getResult(); if (variation != null) { - if (userProfileService != null && !ignoreUPS) { - saveVariation(experiment, variation, userProfile); + if (userProfileTracker != null) { + userProfileTracker.updateUserProfile(experiment, variation); } else { logger.debug("This decision will not be saved since the UserProfileService is null."); } @@ -177,6 +157,39 @@ public DecisionResponse<Variation> getVariation(@Nonnull Experiment experiment, return new DecisionResponse(null, reasons); } + /** + * Get a {@link Variation} of an {@link Experiment} for a user to be allocated into. + * + * @param experiment The Experiment the user will be bucketed into. + * @param user The current OptimizelyUserContext + * @param projectConfig The current projectConfig + * @param options An array of decision options + * @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) and the decision reasons + */ + @Nonnull + public DecisionResponse<Variation> getVariation(@Nonnull Experiment experiment, + @Nonnull OptimizelyUserContext user, + @Nonnull ProjectConfig projectConfig, + @Nonnull List<OptimizelyDecideOption> options) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + + // fetch the user profile map from the user profile service + boolean ignoreUPS = options.contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE); + UserProfileTracker userProfileTracker = null; + + if (userProfileService != null && !ignoreUPS) { + userProfileTracker = new UserProfileTracker(user.getUserId(), userProfileService, logger); + userProfileTracker.loadUserProfile(reasons, errorHandler); + } + + DecisionResponse<Variation> response = getVariation(experiment, user, projectConfig, options, userProfileTracker, reasons); + + if(userProfileService != null && !ignoreUPS) { + userProfileTracker.saveUserProfile(errorHandler); + } + return response; + } + @Nonnull public DecisionResponse<Variation> getVariation(@Nonnull Experiment experiment, @Nonnull OptimizelyUserContext user, @@ -198,31 +211,70 @@ public DecisionResponse<FeatureDecision> getVariationForFeature(@Nonnull Feature @Nonnull OptimizelyUserContext user, @Nonnull ProjectConfig projectConfig, @Nonnull List<OptimizelyDecideOption> options) { - DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + return getVariationsForFeatureList(Arrays.asList(featureFlag), user, projectConfig, options).get(0); + } + + /** + * Get the variations the user is bucketed into for the list of feature flags + * + * @param featureFlags The feature flag list the user wants to access. + * @param user The current OptimizelyuserContext + * @param projectConfig The current projectConfig + * @param options An array of decision options + * @return A {@link DecisionResponse} including a {@link FeatureDecision} and the decision reasons + */ + @Nonnull + public List<DecisionResponse<FeatureDecision>> getVariationsForFeatureList(@Nonnull List<FeatureFlag> featureFlags, + @Nonnull OptimizelyUserContext user, + @Nonnull ProjectConfig projectConfig, + @Nonnull List<OptimizelyDecideOption> options) { + DecisionReasons upsReasons = DefaultDecisionReasons.newInstance(); - DecisionResponse<FeatureDecision> decisionVariationResponse = getVariationFromExperiment(projectConfig, featureFlag, user, options); - reasons.merge(decisionVariationResponse.getReasons()); + boolean ignoreUPS = options.contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE); + UserProfileTracker userProfileTracker = null; - FeatureDecision decision = decisionVariationResponse.getResult(); - if (decision != null) { - return new DecisionResponse(decision, reasons); + if (userProfileService != null && !ignoreUPS) { + userProfileTracker = new UserProfileTracker(user.getUserId(), userProfileService, logger); + userProfileTracker.loadUserProfile(upsReasons, errorHandler); } - DecisionResponse<FeatureDecision> decisionFeatureResponse = getVariationForFeatureInRollout(featureFlag, user, projectConfig); - reasons.merge(decisionFeatureResponse.getReasons()); - decision = decisionFeatureResponse.getResult(); + List<DecisionResponse<FeatureDecision>> decisions = new ArrayList<>(); - String message; - if (decision.variation == null) { - message = reasons.addInfo("The user \"%s\" was not bucketed into a rollout for feature flag \"%s\".", - user.getUserId(), featureFlag.getKey()); - } else { - message = reasons.addInfo("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".", - user.getUserId(), featureFlag.getKey()); + for (FeatureFlag featureFlag: featureFlags) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + reasons.merge(upsReasons); + + DecisionResponse<FeatureDecision> decisionVariationResponse = getVariationFromExperiment(projectConfig, featureFlag, user, options, userProfileTracker); + reasons.merge(decisionVariationResponse.getReasons()); + + FeatureDecision decision = decisionVariationResponse.getResult(); + if (decision != null) { + decisions.add(new DecisionResponse(decision, reasons)); + continue; + } + + DecisionResponse<FeatureDecision> decisionFeatureResponse = getVariationForFeatureInRollout(featureFlag, user, projectConfig); + reasons.merge(decisionFeatureResponse.getReasons()); + decision = decisionFeatureResponse.getResult(); + + String message; + if (decision.variation == null) { + message = reasons.addInfo("The user \"%s\" was not bucketed into a rollout for feature flag \"%s\".", + user.getUserId(), featureFlag.getKey()); + } else { + message = reasons.addInfo("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".", + user.getUserId(), featureFlag.getKey()); + } + logger.info(message); + + decisions.add(new DecisionResponse(decision, reasons)); } - logger.info(message); - return new DecisionResponse(decision, reasons); + if (userProfileService != null && !ignoreUPS) { + userProfileTracker.saveUserProfile(errorHandler); + } + + return decisions; } @Nonnull @@ -244,13 +296,15 @@ public DecisionResponse<FeatureDecision> getVariationForFeature(@Nonnull Feature DecisionResponse<FeatureDecision> getVariationFromExperiment(@Nonnull ProjectConfig projectConfig, @Nonnull FeatureFlag featureFlag, @Nonnull OptimizelyUserContext user, - @Nonnull List<OptimizelyDecideOption> options) { + @Nonnull List<OptimizelyDecideOption> options, + @Nullable UserProfileTracker userProfileTracker) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); if (!featureFlag.getExperimentIds().isEmpty()) { for (String experimentId : featureFlag.getExperimentIds()) { Experiment experiment = projectConfig.getExperimentIdMapping().get(experimentId); - DecisionResponse<Variation> decisionVariation = getVariationFromExperimentRule(projectConfig, featureFlag.getKey(), experiment, user, options); + DecisionResponse<Variation> decisionVariation = + getVariationFromExperimentRule(projectConfig, featureFlag.getKey(), experiment, user, options, userProfileTracker); reasons.merge(decisionVariation.getReasons()); Variation variation = decisionVariation.getResult(); @@ -365,6 +419,9 @@ DecisionResponse<Variation> getWhitelistedVariation(@Nonnull Experiment experime return new DecisionResponse(null, reasons); } + + // TODO: Logically, it makes sense to move this method to UserProfileTracker. But some tests are also calling this + // method, requiring us to refactor those tests as well. We'll look to refactor this later. /** * Get the {@link Variation} that has been stored for the user in the {@link UserProfileService} implementation. * @@ -615,11 +672,12 @@ public DecisionResponse<Variation> getForcedVariation(@Nonnull Experiment experi } - public DecisionResponse<Variation> getVariationFromExperimentRule(@Nonnull ProjectConfig projectConfig, + private DecisionResponse<Variation> getVariationFromExperimentRule(@Nonnull ProjectConfig projectConfig, @Nonnull String flagKey, @Nonnull Experiment rule, @Nonnull OptimizelyUserContext user, - @Nonnull List<OptimizelyDecideOption> options) { + @Nonnull List<OptimizelyDecideOption> options, + @Nullable UserProfileTracker userProfileTracker) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); String ruleKey = rule != null ? rule.getKey() : null; @@ -634,7 +692,7 @@ public DecisionResponse<Variation> getVariationFromExperimentRule(@Nonnull Proje return new DecisionResponse(variation, reasons); } //regular decision - DecisionResponse<Variation> decisionResponse = getVariation(rule, user, projectConfig, options); + DecisionResponse<Variation> decisionResponse = getVariation(rule, user, projectConfig, options, userProfileTracker, null); reasons.merge(decisionResponse.getReasons()); variation = decisionResponse.getResult(); diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/UserProfileTracker.java b/core-api/src/main/java/com/optimizely/ab/bucketing/UserProfileTracker.java new file mode 100644 index 000000000..2dee3d171 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/UserProfileTracker.java @@ -0,0 +1,109 @@ +/**************************************************************************** + * Copyright 2024, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.bucketing; + +import com.optimizely.ab.OptimizelyRuntimeException; +import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.Variation; +import com.optimizely.ab.error.ErrorHandler; +import com.optimizely.ab.optimizelydecision.DecisionReasons; + +import javax.annotation.Nonnull; +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; + +class UserProfileTracker { + private UserProfileService userProfileService; + private Logger logger; + private UserProfile userProfile; + private boolean profileUpdated; + private String userId; + + UserProfileTracker( + @Nonnull String userId, + @Nonnull UserProfileService userProfileService, + @Nonnull Logger logger + ) { + this.userId = userId; + this.userProfileService = userProfileService; + this.logger = logger; + this.profileUpdated = false; + this.userProfile = null; + } + + public UserProfile getUserProfile() { + return userProfile; + } + + public void loadUserProfile(DecisionReasons reasons, ErrorHandler errorHandler) { + try { + Map<String, Object> userProfileMap = userProfileService.lookup(userId); + if (userProfileMap == null) { + String message = reasons.addInfo("We were unable to get a user profile map from the UserProfileService."); + logger.info(message); + } else if (UserProfileUtils.isValidUserProfileMap(userProfileMap)) { + userProfile = UserProfileUtils.convertMapToUserProfile(userProfileMap); + } else { + String message = reasons.addInfo("The UserProfileService returned an invalid map."); + logger.warn(message); + } + } catch (Exception exception) { + String message = reasons.addInfo(exception.getMessage()); + logger.error(message); + errorHandler.handleError(new OptimizelyRuntimeException(exception)); + } + + if (userProfile == null) { + userProfile = new UserProfile(userId, new HashMap<String, Decision>()); + } + } + + public void updateUserProfile(@Nonnull Experiment experiment, + @Nonnull Variation variation) { + String experimentId = experiment.getId(); + String variationId = variation.getId(); + Decision decision; + if (userProfile.experimentBucketMap.containsKey(experimentId)) { + decision = userProfile.experimentBucketMap.get(experimentId); + decision.variationId = variationId; + } else { + decision = new Decision(variationId); + } + userProfile.experimentBucketMap.put(experimentId, decision); + profileUpdated = true; + logger.info("Updated variation \"{}\" of experiment \"{}\" for user \"{}\".", + variationId, experimentId, userProfile.userId); + } + + public void saveUserProfile(ErrorHandler errorHandler) { + // if there were no updates, no need to save + if (!this.profileUpdated) { + return; + } + + try { + userProfileService.save(userProfile.toMap()); + logger.info("Saved user profile of user \"{}\".", + userProfile.userId); + } catch (Exception exception) { + logger.warn("Failed to save user profile of user \"{}\".", + userProfile.userId); + errorHandler.handleError(new OptimizelyRuntimeException(exception)); + } + } +} \ No newline at end of file diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java index 0c07ef56a..34cf61543 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2021-2023, Optimizely and contributors + * Copyright 2021-2024, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,10 +20,15 @@ import com.google.common.base.Charsets; import com.google.common.io.Resources; import com.optimizely.ab.bucketing.FeatureDecision; +import com.optimizely.ab.bucketing.UserProfile; import com.optimizely.ab.bucketing.UserProfileService; +import com.optimizely.ab.bucketing.UserProfileUtils; import com.optimizely.ab.config.*; import com.optimizely.ab.config.parser.ConfigParseException; +import com.optimizely.ab.event.EventHandler; +import com.optimizely.ab.event.EventProcessor; import com.optimizely.ab.event.ForwardingEventProcessor; +import com.optimizely.ab.event.internal.ImpressionEvent; import com.optimizely.ab.event.internal.payload.DecisionMetadata; import com.optimizely.ab.internal.LogbackVerifier; import com.optimizely.ab.notification.NotificationCenter; @@ -37,7 +42,10 @@ import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mockito; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.*; import java.util.concurrent.CountDownLatch; @@ -345,9 +353,11 @@ public void decideAll_twoFlags() { @Test public void decideAll_allFlags() { + EventProcessor mockEventProcessor = mock(EventProcessor.class); + optimizely = new Optimizely.Builder() .withDatafile(datafile) - .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .withEventProcessor(mockEventProcessor) .build(); String flagKey1 = "feature_1"; @@ -361,8 +371,7 @@ public void decideAll_allFlags() { OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); Map<String, OptimizelyDecision> decisions = user.decideAll(); - - assertTrue(decisions.size() == 3); + assertEquals(decisions.size(), 3); assertEquals( decisions.get(flagKey1), @@ -395,9 +404,84 @@ public void decideAll_allFlags() { user, Collections.emptyList())); - eventHandler.expectImpression("10390977673", "10389729780", userId, attributes); - eventHandler.expectImpression("10420810910", "10418551353", userId, attributes); - eventHandler.expectImpression(null, "", userId, attributes); + ArgumentCaptor<ImpressionEvent> argumentCaptor = ArgumentCaptor.forClass(ImpressionEvent.class); + verify(mockEventProcessor, times(3)).process(argumentCaptor.capture()); + + List<ImpressionEvent> sentEvents = argumentCaptor.getAllValues(); + assertEquals(sentEvents.size(), 3); + + assertEquals(sentEvents.get(0).getExperimentKey(), "exp_with_audience"); + assertEquals(sentEvents.get(0).getVariationKey(), "a"); + assertEquals(sentEvents.get(0).getUserContext().getUserId(), userId); + + + assertEquals(sentEvents.get(1).getExperimentKey(), "exp_no_audience"); + assertEquals(sentEvents.get(1).getVariationKey(), "variation_with_traffic"); + assertEquals(sentEvents.get(1).getUserContext().getUserId(), userId); + + assertEquals(sentEvents.get(2).getExperimentKey(), ""); + assertEquals(sentEvents.get(2).getUserContext().getUserId(), userId); + } + + @Test + public void decideForKeys_ups_batching() throws Exception { + UserProfileService ups = mock(UserProfileService.class); + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withUserProfileService(ups) + .build(); + + String flagKey1 = "feature_1"; + String flagKey2 = "feature_2"; + String flagKey3 = "feature_3"; + Map<String, Object> attributes = Collections.singletonMap("gender", "f"); + + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + Map<String, OptimizelyDecision> decisions = user.decideForKeys(Arrays.asList( + flagKey1, flagKey2, flagKey3 + )); + + assertEquals(decisions.size(), 3); + + ArgumentCaptor<Map> argumentCaptor = ArgumentCaptor.forClass(Map.class); + + + verify(ups, times(1)).lookup(userId); + verify(ups, times(1)).save(argumentCaptor.capture()); + + Map<String, Object> savedUps = argumentCaptor.getValue(); + UserProfile savedProfile = UserProfileUtils.convertMapToUserProfile(savedUps); + + assertEquals(savedProfile.userId, userId); + } + + @Test + public void decideAll_ups_batching() throws Exception { + UserProfileService ups = mock(UserProfileService.class); + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withUserProfileService(ups) + .build(); + + Map<String, Object> attributes = Collections.singletonMap("gender", "f"); + + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + Map<String, OptimizelyDecision> decisions = user.decideAll(); + + assertEquals(decisions.size(), 3); + + ArgumentCaptor<Map> argumentCaptor = ArgumentCaptor.forClass(Map.class); + + + verify(ups, times(1)).lookup(userId); + verify(ups, times(1)).save(argumentCaptor.capture()); + + Map<String, Object> savedUps = argumentCaptor.getValue(); + UserProfile savedProfile = UserProfileUtils.convertMapToUserProfile(savedUps); + + assertEquals(savedProfile.userId, userId); } @Test diff --git a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java index 6057b43cf..d818826d4 100644 --- a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2017-2022, Optimizely, Inc. and contributors * + * Copyright 2017-2022, 2024, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -24,6 +24,7 @@ import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.internal.ControlAttribute; import com.optimizely.ab.internal.LogbackVerifier; +import com.optimizely.ab.optimizelydecision.DecisionReasons; import com.optimizely.ab.optimizelydecision.DecisionResponse; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Before; @@ -297,7 +298,9 @@ public void getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperiment any(Experiment.class), any(OptimizelyUserContext.class), any(ProjectConfig.class), - anyObject() + anyObject(), + anyObject(), + any(DecisionReasons.class) ); // do not bucket to any rollouts doReturn(DecisionResponse.responseNoReasons(new FeatureDecision(null, null, null))).when(decisionService).getVariationForFeatureInRollout( @@ -381,7 +384,9 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() eq(featureExperiment), any(OptimizelyUserContext.class), any(ProjectConfig.class), - anyObject() + anyObject(), + anyObject(), + any(DecisionReasons.class) ); // return variation for rollout @@ -413,7 +418,9 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() any(Experiment.class), any(OptimizelyUserContext.class), any(ProjectConfig.class), - anyObject() + anyObject(), + anyObject(), + any(DecisionReasons.class) ); } @@ -438,7 +445,9 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails eq(featureExperiment), any(OptimizelyUserContext.class), any(ProjectConfig.class), - anyObject() + anyObject(), + anyObject(), + any(DecisionReasons.class) ); // return variation for rollout @@ -470,7 +479,9 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails any(Experiment.class), any(OptimizelyUserContext.class), any(ProjectConfig.class), - anyObject() + anyObject(), + anyObject(), + any(DecisionReasons.class) ); logbackVerifier.expectMessage( @@ -480,6 +491,33 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails ); } + //========== getVariationForFeatureList tests ==========// + + @Test + public void getVariationsForFeatureListBatchesUpsLoadAndSave() throws Exception { + Bucketer bucketer = new Bucketer(); + ErrorHandler mockErrorHandler = mock(ErrorHandler.class); + UserProfileService mockUserProfileService = mock(UserProfileService.class); + + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, mockUserProfileService); + + FeatureFlag featureFlag1 = FEATURE_FLAG_MULTI_VARIATE_FEATURE; + FeatureFlag featureFlag2 = FEATURE_FLAG_MULTI_VARIATE_FUTURE_FEATURE; + FeatureFlag featureFlag3 = FEATURE_FLAG_MUTEX_GROUP_FEATURE; + + List<DecisionResponse<FeatureDecision>> decisions = decisionService.getVariationsForFeatureList( + Arrays.asList(featureFlag1, featureFlag2, featureFlag3), + optimizely.createUserContext(genericUserId), + v4ProjectConfig, + new ArrayList<>() + ); + + assertEquals(decisions.size(), 3); + verify(mockUserProfileService, times(1)).lookup(genericUserId); + verify(mockUserProfileService, times(1)).save(anyObject()); + } + + //========== getVariationForFeatureInRollout tests ==========// /** @@ -743,27 +781,6 @@ public void getVariationFromDeliveryRuleTest() { assertFalse(skipToEveryoneElse); } - @Test - public void getVariationFromExperimentRuleTest() { - int index = 3; - Experiment experiment = ROLLOUT_2.getExperiments().get(index); - Variation expectedVariation = null; - for (Variation variation : experiment.getVariations()) { - if (variation.getKey().equals("3137445031")) { - expectedVariation = variation; - } - } - DecisionResponse<Variation> decisionResponse = decisionService.getVariationFromExperimentRule( - v4ProjectConfig, - FEATURE_FLAG_MULTI_VARIATE_FEATURE.getKey(), - experiment, - optimizely.createUserContext(genericUserId, Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, AUDIENCE_ENGLISH_CITIZENS_VALUE)), - Collections.emptyList() - ); - - assertEquals(expectedVariation, decisionResponse.getResult()); - } - @Test public void validatedForcedDecisionWithRuleKey() { String userId = "testUser1"; @@ -961,9 +978,12 @@ public void getVariationSavesBucketedVariationIntoUserProfile() throws Exception experiment, optimizely.createUserContext(userProfileId, Collections.emptyMap()), noAudienceProjectConfig).getResult() ); logbackVerifier.expectMessage(Level.INFO, - String.format("Saved variation \"%s\" of experiment \"%s\" for user \"" + userProfileId + "\".", variation.getId(), + String.format("Updated variation \"%s\" of experiment \"%s\" for user \"" + userProfileId + "\".", variation.getId(), experiment.getId())); + logbackVerifier.expectMessage(Level.INFO, + String.format("Saved user profile of user \"%s\".", userProfileId)); + verify(userProfileService).save(eq(expectedUserProfile.toMap())); } From a763358be683909a4ae9112efc9a1ea3b69c5538 Mon Sep 17 00:00:00 2001 From: Raju Ahmed <raju.ahmed@optimizely.com> Date: Wed, 6 Nov 2024 19:51:14 +0600 Subject: [PATCH 134/147] [FSSDK-10839] prepare for release 4.2.0 (#551) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d92e70fbe..104422c93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Optimizely Java X SDK Changelog +## [4.2.0] +November 6th, 2024 + +### New Features +* Batch UPS lookup and save calls in decideAll and decideForKeys methods ([#549](https://github.com/optimizely/java-sdk/pull/549)). + ## [4.1.1] May 8th, 2024 From 392d679971d88d889defebaa64edad5cdb4c96fa Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Thu, 2 Jan 2025 21:08:13 +0600 Subject: [PATCH 135/147] [FSSDK-11016] chore: update gradle from 6.5 to 7.2 (#553) Update gradle to 7.2 --- .github/workflows/java.yml | 2 +- build.gradle | 36 ++++++++++++++--------- core-api/build.gradle | 17 +++++++---- core-httpclient-impl/build.gradle | 9 +++--- gradle/wrapper/gradle-wrapper.jar | Bin 54333 -> 59536 bytes gradle/wrapper/gradle-wrapper.properties | 3 +- 6 files changed, 41 insertions(+), 26 deletions(-) diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index 1c6c57a02..cec97cd7b 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -90,7 +90,7 @@ jobs: - name: Check on success if: always() && steps.unit_tests.outcome == 'success' run: | - ./gradlew coveralls uploadArchives --console plain + ./gradlew coveralls --console plain publish: if: startsWith(github.ref, 'refs/tags/') diff --git a/build.gradle b/build.gradle index b8405e39b..3301eda25 100644 --- a/build.gradle +++ b/build.gradle @@ -1,10 +1,11 @@ plugins { - id 'com.github.kt3k.coveralls' version '2.8.2' + id 'com.github.kt3k.coveralls' version '2.12.2' id 'jacoco' - id 'me.champeau.gradle.jmh' version '0.4.5' + id 'me.champeau.gradle.jmh' version '0.5.3' id 'nebula.optional-base' version '3.2.0' - id 'com.github.hierynomus.license' version '0.15.0' + id 'com.github.hierynomus.license' version '0.16.1' id 'com.github.spotbugs' version "4.5.0" + id 'maven-publish' } allprojects { @@ -94,23 +95,30 @@ configure(publishedProjects) { } dependencies { - compile group: 'commons-codec', name: 'commons-codec', version: commonCodecVersion + implementation group: 'commons-codec', name: 'commons-codec', version: commonCodecVersion - testCompile group: 'junit', name: 'junit', version: junitVersion - testCompile group: 'org.mockito', name: 'mockito-core', version: mockitoVersion - testCompile group: 'org.hamcrest', name: 'hamcrest-all', version: hamcrestVersion - testCompile group: 'com.google.guava', name: 'guava', version: guavaVersion + testImplementation group: 'junit', name: 'junit', version: junitVersion + testImplementation group: 'org.mockito', name: 'mockito-core', version: mockitoVersion + testImplementation group: 'org.hamcrest', name: 'hamcrest-all', version: hamcrestVersion + testImplementation group: 'com.google.guava', name: 'guava', version: guavaVersion // logging dependencies (logback) - testCompile group: 'ch.qos.logback', name: 'logback-classic', version: logbackVersion - testCompile group: 'ch.qos.logback', name: 'logback-core', version: logbackVersion + testImplementation group: 'ch.qos.logback', name: 'logback-classic', version: logbackVersion + testImplementation group: 'ch.qos.logback', name: 'logback-core', version: logbackVersion - testCompile group: 'com.google.code.gson', name: 'gson', version: gsonVersion - testCompile group: 'org.json', name: 'json', version: jsonVersion - testCompile group: 'com.googlecode.json-simple', name: 'json-simple', version: jsonSimpleVersion - testCompile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: jacksonVersion + testImplementation group: 'com.google.code.gson', name: 'gson', version: gsonVersion + testImplementation group: 'org.json', name: 'json', version: jsonVersion + testImplementation group: 'com.googlecode.json-simple', name: 'json-simple', version: jsonSimpleVersion + testImplementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: jacksonVersion } + configurations.all { + resolutionStrategy { + force "junit:junit:${junitVersion}" + } + } + + def docTitle = "Optimizely Java SDK" if (name.equals('core-httpclient-impl')) { docTitle = "Optimizely Java SDK: Httpclient" diff --git a/core-api/build.gradle b/core-api/build.gradle index d2609a97d..602131cd3 100644 --- a/core-api/build.gradle +++ b/core-api/build.gradle @@ -1,9 +1,10 @@ dependencies { - compile group: 'org.slf4j', name: 'slf4j-api', version: slf4jVersion - compile group: 'com.fasterxml.jackson.core', name: 'jackson-annotations', version: jacksonVersion - - compile group: 'com.google.code.findbugs', name: 'annotations', version: findbugsAnnotationVersion - compile group: 'com.google.code.findbugs', name: 'jsr305', version: findbugsJsrVersion + implementation group: 'org.slf4j', name: 'slf4j-api', version: slf4jVersion + implementation group: 'com.fasterxml.jackson.core', name: 'jackson-annotations', version: jacksonVersion + implementation group: 'com.google.code.findbugs', name: 'annotations', version: findbugsAnnotationVersion + implementation group: 'com.google.code.findbugs', name: 'jsr305', version: findbugsJsrVersion + testImplementation group: 'junit', name: 'junit', version: junitVersion + testImplementation group: 'ch.qos.logback', name: 'logback-classic', version: logbackVersion // an assortment of json parsers compileOnly group: 'com.google.code.gson', name: 'gson', version: gsonVersion, optional @@ -12,6 +13,11 @@ dependencies { compileOnly group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: jacksonVersion, optional } +tasks.named('processJmhResources') { + duplicatesStrategy = DuplicatesStrategy.WARN +} + + test { useJUnit { excludeCategories 'com.optimizely.ab.categories.ExhaustiveTest' @@ -24,6 +30,7 @@ task exhaustiveTest(type: Test) { } } + task generateVersionFile { // add the build version information into a file that'll go into the distribution ext.buildVersion = new File(projectDir, "src/main/resources/optimizely-build-version") diff --git a/core-httpclient-impl/build.gradle b/core-httpclient-impl/build.gradle index e4cdd4b99..4affcda17 100644 --- a/core-httpclient-impl/build.gradle +++ b/core-httpclient-impl/build.gradle @@ -1,8 +1,9 @@ dependencies { - compile project(':core-api') - compileOnly group: 'com.google.code.gson', name: 'gson', version: gsonVersion - compile group: 'org.apache.httpcomponents', name: 'httpclient', version: httpClientVersion - testCompile 'org.mock-server:mockserver-netty:5.1.1' + implementation project(':core-api') + implementation group: 'org.apache.httpcomponents', name: 'httpclient', version: httpClientVersion + implementation group: 'org.slf4j', name: 'slf4j-api', version: slf4jVersion + implementation group: 'com.google.code.findbugs', name: 'jsr305', version: findbugsJsrVersion + testImplementation 'org.mock-server:mockserver-netty:5.1.1' } task exhaustiveTest { diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index c44b679acd3f794ddbb3aa5e919244914911014a..7454180f2ae8848c63b8b4dea2cb829da983f2fa 100644 GIT binary patch literal 59536 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 QuEJmfE|#`Dqx|px0L@K;Y5)KL literal 54333 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 E17(ftEC2ui diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a9a50f830..ffed3a254 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Mon Sep 24 09:56:45 PDT 2018 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip From be78ee93f38de2b1371efe9efcb97b110a216168 Mon Sep 17 00:00:00 2001 From: alexjoeyyong <96444887+alexjoeyyong@users.noreply.github.com> Date: Wed, 22 Jan 2025 12:05:42 -0500 Subject: [PATCH 136/147] EC3-1687 workflow versions (#557) --- .github/workflows/java.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index cec97cd7b..d13131ffd 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -17,7 +17,7 @@ jobs: lint_markdown_files: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: @@ -55,7 +55,7 @@ jobs: optimizely_default_parser: [GSON_CONFIG_PARSER, JACKSON_CONFIG_PARSER, JSON_CONFIG_PARSER, JSON_SIMPLE_CONFIG_PARSER] steps: - name: checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: set up JDK ${{ matrix.jdk }} uses: AdoptOpenJDK/install-jdk@v1 @@ -67,7 +67,7 @@ jobs: run: chmod +x gradlew - name: Gradle cache - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: | ~/.gradle/caches From 02aa4bb6ce837369a7d4d0d97d67528566fab5e3 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Thu, 13 Feb 2025 23:41:25 +0600 Subject: [PATCH 137/147] [FSSDK-11075] chore: remove travis from doc (#558) * Remove travis udpate status from readme * remove travis token * update yml * Update branch --- .github/workflows/build.yml | 5 +---- .github/workflows/integration_test.yml | 13 +++---------- .github/workflows/java.yml | 4 +--- README.md | 1 - 4 files changed, 5 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e7ba7782e..618bf5d61 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,9 +6,6 @@ on: action: required: true type: string - travis_tag: - required: true - type: string secrets: MAVEN_SIGNING_KEY_BASE64: required: true @@ -37,4 +34,4 @@ jobs: MAVEN_SIGNING_PASSPHRASE: ${{ secrets.MAVEN_SIGNING_PASSPHRASE }} MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - run: TRAVIS_TAG=${{ inputs.travis_tag }} ./gradlew ${{ inputs.action }} + run: ./gradlew ${{ inputs.action }} diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index c54149edd..35fb78590 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -9,8 +9,6 @@ on: secrets: CI_USER_TOKEN: required: true - TRAVIS_COM_TOKEN: - required: true jobs: test: runs-on: ubuntu-latest @@ -19,8 +17,8 @@ jobs: with: # You should create a personal access token and store it in your repository token: ${{ secrets.CI_USER_TOKEN }} - repository: 'optimizely/travisci-tools' - path: 'home/runner/travisci-tools' + repository: 'optimizely/ci-helper-tools' + path: 'home/runner/ci-helper-tools' ref: 'master' - name: set SDK Branch if PR env: @@ -28,14 +26,12 @@ jobs: if: ${{ github.event_name == 'pull_request' }} run: | echo "SDK_BRANCH=$HEAD_REF" >> $GITHUB_ENV - echo "TRAVIS_BRANCH=$HEAD_REF" >> $GITHUB_ENV - name: set SDK Branch if not pull request env: REF_NAME: ${{ github.ref_name }} if: ${{ github.event_name != 'pull_request' }} run: | echo "SDK_BRANCH=$REF_NAME" >> $GITHUB_ENV - echo "TRAVIS_BRANCH=$REF_NAME" >> $GITHUB_ENV - name: Trigger build env: SDK: java @@ -45,16 +41,13 @@ jobs: GITHUB_TOKEN: ${{ secrets.CI_USER_TOKEN }} EVENT_TYPE: ${{ github.event_name }} GITHUB_CONTEXT: ${{ toJson(github) }} - #REPO_SLUG: ${{ github.repository }} PULL_REQUEST_SLUG: ${{ github.repository }} UPSTREAM_REPO: ${{ github.repository }} PULL_REQUEST_SHA: ${{ github.event.pull_request.head.sha }} PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} UPSTREAM_SHA: ${{ github.sha }} - TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} - TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} EVENT_MESSAGE: ${{ github.event.message }} HOME: 'home/runner' run: | echo "$GITHUB_CONTEXT" - home/runner/travisci-tools/trigger-script-with-status-update.sh + home/runner/ci-helper-tools/trigger-script-with-status-update.sh diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index d13131ffd..311686c26 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -31,10 +31,9 @@ jobs: integration_tests: if: ${{ startsWith(github.ref, 'refs/tags/') != true && github.event.inputs.SNAPSHOT != 'true' }} - uses: optimizely/java-sdk/.github/workflows/integration_test.yml@mnoman/fsc-gitaction-test + uses: optimizely/java-sdk/.github/workflows/integration_test.yml@master secrets: CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }} - TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} fullstack_production_suite: if: ${{ startsWith(github.ref, 'refs/tags/') != true && github.event.inputs.SNAPSHOT != 'true' }} @@ -43,7 +42,6 @@ jobs: FULLSTACK_TEST_REPO: ProdTesting secrets: CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }} - TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} test: if: ${{ startsWith(github.ref, 'refs/tags/') != true && github.event.inputs.SNAPSHOT != 'true' }} diff --git a/README.md b/README.md index 33e55928d..1f97786a1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # Optimizely Java SDK -[![Build Status](https://travis-ci.org/optimizely/java-sdk.svg?branch=master)](https://travis-ci.org/optimizely/java-sdk) [![Apache 2.0](https://img.shields.io/badge/license-APACHE%202.0-blue.svg)](http://www.apache.org/licenses/LICENSE-2.0) This repository houses the Java SDK for use with Optimizely Feature Experimentation and Optimizely Full Stack (legacy). From 8bc0136eeff5daa08a9a22531ed5edb09f32d70c Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Mon, 17 Feb 2025 23:19:02 +0600 Subject: [PATCH 138/147] [FSSDK-11075] chore: update github tag (#559) * update tag --- .github/workflows/build.yml | 3 +++ .github/workflows/java.yml | 4 ++-- README.md | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 618bf5d61..e10de2d47 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,9 @@ on: action: required: true type: string + github_tag: + required: true + type: string secrets: MAVEN_SIGNING_KEY_BASE64: required: true diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index 311686c26..7ebc17304 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -95,7 +95,7 @@ jobs: uses: optimizely/java-sdk/.github/workflows/build.yml@master with: action: ship - travis_tag: ${GITHUB_REF#refs/*/} + github_tag: ${GITHUB_REF#refs/*/} secrets: MAVEN_SIGNING_KEY_BASE64: ${{ secrets.MAVEN_SIGNING_KEY_BASE64 }} MAVEN_SIGNING_PASSPHRASE: ${{ secrets.MAVEN_SIGNING_PASSPHRASE }} @@ -107,7 +107,7 @@ jobs: uses: optimizely/java-sdk/.github/workflows/build.yml@master with: action: ship - travis_tag: BB-SNAPSHOT + github_tag: BB-SNAPSHOT secrets: MAVEN_SIGNING_KEY_BASE64: ${{ secrets.MAVEN_SIGNING_KEY_BASE64 }} MAVEN_SIGNING_PASSPHRASE: ${{ secrets.MAVEN_SIGNING_PASSPHRASE }} diff --git a/README.md b/README.md index 1f97786a1..b4b1f0be1 100644 --- a/README.md +++ b/README.md @@ -175,3 +175,4 @@ License (Apache 2.0): [https://github.com/apache/httpcomponents-client/blob/mast - Ruby - https://github.com/optimizely/ruby-sdk - Swift - https://github.com/optimizely/swift-sdk + From ab02b4970418d4aec999df1d6f3b924fd3168df8 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Wed, 19 Feb 2025 23:12:41 +0600 Subject: [PATCH 139/147] fix: long integer issue (#556) --- .../java/com/optimizely/ab/Optimizely.java | 170 ++++++++++---- .../com/optimizely/ab/OptimizelyTest.java | 221 +++++++++++------- .../ab/config/ValidProjectConfigV4.java | 13 ++ 3 files changed, 275 insertions(+), 129 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 0e260072e..4f942ff69 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -20,44 +20,80 @@ import com.optimizely.ab.bucketing.DecisionService; import com.optimizely.ab.bucketing.FeatureDecision; import com.optimizely.ab.bucketing.UserProfileService; -import com.optimizely.ab.config.*; +import com.optimizely.ab.config.AtomicProjectConfigManager; +import com.optimizely.ab.config.DatafileProjectConfig; +import com.optimizely.ab.config.EventType; +import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.FeatureFlag; +import com.optimizely.ab.config.FeatureVariable; +import com.optimizely.ab.config.FeatureVariableUsageInstance; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.ProjectConfigManager; +import com.optimizely.ab.config.Variation; import com.optimizely.ab.config.parser.ConfigParseException; import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.error.NoOpErrorHandler; -import com.optimizely.ab.event.*; -import com.optimizely.ab.event.internal.*; +import com.optimizely.ab.event.EventHandler; +import com.optimizely.ab.event.EventProcessor; +import com.optimizely.ab.event.ForwardingEventProcessor; +import com.optimizely.ab.event.LogEvent; +import com.optimizely.ab.event.NoopEventHandler; +import com.optimizely.ab.event.internal.BuildVersionInfo; +import com.optimizely.ab.event.internal.ClientEngineInfo; +import com.optimizely.ab.event.internal.EventFactory; +import com.optimizely.ab.event.internal.UserEvent; +import com.optimizely.ab.event.internal.UserEventFactory; import com.optimizely.ab.event.internal.payload.EventBatch; import com.optimizely.ab.internal.NotificationRegistry; -import com.optimizely.ab.notification.*; -import com.optimizely.ab.odp.*; +import com.optimizely.ab.notification.ActivateNotification; +import com.optimizely.ab.notification.DecisionNotification; +import com.optimizely.ab.notification.FeatureTestSourceInfo; +import com.optimizely.ab.notification.NotificationCenter; +import com.optimizely.ab.notification.NotificationHandler; +import com.optimizely.ab.notification.RolloutSourceInfo; +import com.optimizely.ab.notification.SourceInfo; +import com.optimizely.ab.notification.TrackNotification; +import com.optimizely.ab.notification.UpdateConfigNotification; +import com.optimizely.ab.odp.ODPEvent; +import com.optimizely.ab.odp.ODPManager; +import com.optimizely.ab.odp.ODPSegmentManager; +import com.optimizely.ab.odp.ODPSegmentOption; import com.optimizely.ab.optimizelyconfig.OptimizelyConfig; import com.optimizely.ab.optimizelyconfig.OptimizelyConfigManager; import com.optimizely.ab.optimizelyconfig.OptimizelyConfigService; -import com.optimizely.ab.optimizelydecision.*; +import com.optimizely.ab.optimizelydecision.DecisionMessage; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.DecisionResponse; +import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; import com.optimizely.ab.optimizelyjson.OptimizelyJSON; -import java.util.concurrent.locks.ReentrantLock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.ThreadSafe; - import java.io.Closeable; -import java.util.*; -import java.util.stream.Collectors; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.locks.ReentrantLock; import static com.optimizely.ab.internal.SafetyUtils.tryClose; /** * Top-level container class for Optimizely functionality. * Thread-safe, so can be created as a singleton and safely passed around. - * + * <p> * Example instantiation: * <pre> * Optimizely optimizely = Optimizely.builder(projectWatcher, eventHandler).build(); * </pre> - * + * <p> * To activate an experiment and perform variation specific processing: * <pre> * Variation variation = optimizely.activate(experimentKey, userId, attributes); @@ -136,7 +172,9 @@ private Optimizely(@Nonnull EventHandler eventHandler, if (projectConfigManager.getSDKKey() != null) { NotificationRegistry.getInternalNotificationCenter(projectConfigManager.getSDKKey()). addNotificationHandler(UpdateConfigNotification.class, - configNotification -> { updateODPSettings(); }); + configNotification -> { + updateODPSettings(); + }); } } @@ -634,6 +672,53 @@ public Integer getFeatureVariableInteger(@Nonnull String featureKey, return variableValue; } + /** + * Get the Long value of the specified variable in the feature. + * + * @param featureKey The unique key of the feature. + * @param variableKey The unique key of the variable. + * @param userId The ID of the user. + * @return The Integer value of the integer single variable feature. + * Null if the feature or variable could not be found. + */ + @Nullable + public Long getFeatureVariableLong(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId) { + return getFeatureVariableLong(featureKey, variableKey, userId, Collections.emptyMap()); + } + + /** + * Get the Integer value of the specified variable in the feature. + * + * @param featureKey The unique key of the feature. + * @param variableKey The unique key of the variable. + * @param userId The ID of the user. + * @param attributes The user's attributes. + * @return The Integer value of the integer single variable feature. + * Null if the feature or variable could not be found. + */ + @Nullable + public Long getFeatureVariableLong(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes) { + try { + return getFeatureVariableValueForType( + featureKey, + variableKey, + userId, + attributes, + FeatureVariable.INTEGER_TYPE + ); + + } catch (Exception exception) { + logger.error("NumberFormatException while trying to parse value as Long. {}", String.valueOf(exception)); + } + + return null; + } + /** * Get the String value of the specified variable in the feature. * @@ -828,8 +913,13 @@ Object convertStringToType(String variableValue, String type) { try { return Integer.parseInt(variableValue); } catch (NumberFormatException exception) { - logger.error("NumberFormatException while trying to parse \"" + variableValue + - "\" as Integer. " + exception.toString()); + try { + return Long.parseLong(variableValue); + } catch (NumberFormatException longException) { + logger.error("NumberFormatException while trying to parse \"{}\" as Integer. {}", + variableValue, + exception.toString()); + } } break; case FeatureVariable.JSON_TYPE: @@ -845,11 +935,10 @@ Object convertStringToType(String variableValue, String type) { /** * Get the values of all variables in the feature. * - * @param featureKey The unique key of the feature. - * @param userId The ID of the user. + * @param featureKey The unique key of the feature. + * @param userId The ID of the user. * @return An OptimizelyJSON instance for all variable values. * Null if the feature could not be found. - * */ @Nullable public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, @@ -860,12 +949,11 @@ public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, /** * Get the values of all variables in the feature. * - * @param featureKey The unique key of the feature. - * @param userId The ID of the user. - * @param attributes The user's attributes. + * @param featureKey The unique key of the feature. + * @param userId The ID of the user. + * @param attributes The user's attributes. * @return An OptimizelyJSON instance for all variable values. * Null if the feature could not be found. - * */ @Nullable public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, @@ -949,7 +1037,6 @@ public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, * @param attributes The user's attributes. * @return List of the feature keys that are enabled for the user if the userId is empty it will * return Empty List. - * */ public List<String> getEnabledFeatures(@Nonnull String userId, @Nonnull Map<String, ?> attributes) { List<String> enabledFeaturesList = new ArrayList(); @@ -1164,10 +1251,10 @@ public OptimizelyConfig getOptimizelyConfig() { /** * Create a context of the user for which decision APIs will be called. - * + * <p> * A user context will be created successfully even when the SDK is not fully configured yet. * - * @param userId The user ID to be used for bucketing. + * @param userId The user ID to be used for bucketing. * @param attributes: A map of attribute names to current user attribute values. * @return An OptimizelyUserContext associated with this OptimizelyClient. */ @@ -1289,15 +1376,15 @@ private OptimizelyDecision createOptimizelyDecision( } Map<String, OptimizelyDecision> decideForKeys(@Nonnull OptimizelyUserContext user, - @Nonnull List<String> keys, - @Nonnull List<OptimizelyDecideOption> options) { + @Nonnull List<String> keys, + @Nonnull List<OptimizelyDecideOption> options) { return decideForKeys(user, keys, options, false); } private Map<String, OptimizelyDecision> decideForKeys(@Nonnull OptimizelyUserContext user, - @Nonnull List<String> keys, - @Nonnull List<OptimizelyDecideOption> options, - boolean ignoreDefaultOptions) { + @Nonnull List<String> keys, + @Nonnull List<OptimizelyDecideOption> options, + boolean ignoreDefaultOptions) { Map<String, OptimizelyDecision> decisionMap = new HashMap<>(); ProjectConfig projectConfig = getProjectConfig(); @@ -1308,7 +1395,7 @@ private Map<String, OptimizelyDecision> decideForKeys(@Nonnull OptimizelyUserCon if (keys.isEmpty()) return decisionMap; - List<OptimizelyDecideOption> allOptions = ignoreDefaultOptions ? options: getAllOptions(options); + List<OptimizelyDecideOption> allOptions = ignoreDefaultOptions ? options : getAllOptions(options); Map<String, FeatureDecision> flagDecisions = new HashMap<>(); Map<String, DecisionReasons> decisionReasonsMap = new HashMap<>(); @@ -1351,7 +1438,7 @@ private Map<String, OptimizelyDecision> decideForKeys(@Nonnull OptimizelyUserCon decisionReasonsMap.get(flagKey).merge(decision.getReasons()); } - for (String key: validKeys) { + for (String key : validKeys) { FeatureDecision flagDecision = flagDecisions.get(key); DecisionReasons decisionReasons = decisionReasonsMap.get((key)); @@ -1484,9 +1571,9 @@ public int addLogEventNotificationHandler(NotificationHandler<LogEvent> handler) /** * Convenience method for adding NotificationHandlers * - * @param clazz The class of NotificationHandler + * @param clazz The class of NotificationHandler * @param handler NotificationHandler handler - * @param <T> This is the type parameter + * @param <T> This is the type parameter * @return A handler Id (greater than 0 if succeeded) */ public <T> int addNotificationHandler(Class<T> clazz, NotificationHandler<T> handler) { @@ -1535,10 +1622,10 @@ public ODPManager getODPManager() { /** * Send an event to the ODP server. * - * @param type the event type (default = "fullstack"). - * @param action the event action name. + * @param type the event type (default = "fullstack"). + * @param action the event action name. * @param identifiers a dictionary for identifiers. The caller must provide at least one key-value pair unless non-empty common identifiers have been set already with {@link ODPManager.Builder#withUserCommonIdentifiers(Map) }. - * @param data a dictionary for associated data. The default event data will be added to this data before sending to the ODP server. + * @param data a dictionary for associated data. The default event data will be added to this data before sending to the ODP server. */ public void sendODPEvent(@Nullable String type, @Nonnull String action, @Nullable Map<String, String> identifiers, @Nullable Map<String, Object> data) { ProjectConfig projectConfig = getProjectConfig(); @@ -1586,7 +1673,7 @@ private void updateODPSettings() { * {@link Builder#withDatafile(java.lang.String)} and * {@link Builder#withEventHandler(com.optimizely.ab.event.EventHandler)} * respectively. - * + * <p> * Example: * <pre> * Optimizely optimizely = Optimizely.builder() @@ -1595,7 +1682,7 @@ private void updateODPSettings() { * .build(); * </pre> * - * @param datafile A datafile + * @param datafile A datafile * @param eventHandler An EventHandler * @return An Optimizely builder */ @@ -1644,7 +1731,8 @@ public Builder(@Nonnull String datafile, this.datafile = datafile; } - public Builder() { } + public Builder() { + } public Builder withErrorHandler(ErrorHandler errorHandler) { this.errorHandler = errorHandler; @@ -1686,7 +1774,7 @@ public Builder withUserProfileService(UserProfileService userProfileService) { * Override the SDK name and version (for client SDKs like android-sdk wrapping the core java-sdk) to be included in events. * * @param clientEngineName the client engine name ("java-sdk", "android-sdk", "flutter-sdk", etc.). - * @param clientVersion the client SDK version. + * @param clientVersion the client SDK version. * @return An Optimizely builder */ public Builder withClientInfo(String clientEngineName, String clientVersion) { diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java index 260de9945..b444dbc26 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -1101,7 +1101,7 @@ public void trackEventWithNullAttributeValues() throws Exception { * (i.e., not in the config) is passed through. * <p> * In this case, the track event call should not remove the unknown attribute from the given map but should go on and track the event successfully. - * + * <p> * TODO: Is this a dupe?? Also not sure the intent of the test since the attributes are stripped by the EventFactory */ @Test @@ -1569,8 +1569,7 @@ private NotificationHandler<DecisionNotification> getDecisionListener( final String testType, final String testUserId, final Map<String, ?> testUserAttributes, - final Map<String, ?> testDecisionInfo) - { + final Map<String, ?> testDecisionInfo) { return decisionNotification -> { assertEquals(decisionNotification.getType(), testType); assertEquals(decisionNotification.getUserId(), testUserId); @@ -1609,10 +1608,10 @@ public void activateEndToEndWithDecisionListener() throws Exception { int notificationId = optimizely.notificationCenter.<DecisionNotification>getNotificationManager(DecisionNotification.class) .addHandler( - getDecisionListener(NotificationCenter.DecisionNotificationType.FEATURE_TEST.toString(), - userId, - testUserAttributes, - testDecisionInfoMap)); + getDecisionListener(NotificationCenter.DecisionNotificationType.FEATURE_TEST.toString(), + userId, + testUserAttributes, + testDecisionInfoMap)); // activate the experiment Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), userId, null); @@ -1752,7 +1751,8 @@ public void getEnabledFeaturesWithNoFeatureEnabled() throws Exception { any(OptimizelyUserContext.class), any(ProjectConfig.class) ); - int notificationId = optimizely.addDecisionNotificationHandler( decisionNotification -> { }); + int notificationId = optimizely.addDecisionNotificationHandler(decisionNotification -> { + }); List<String> featureFlags = optimizely.getEnabledFeatures(genericUserId, Collections.emptyMap()); assertTrue(featureFlags.isEmpty()); @@ -2012,10 +2012,10 @@ public void getFeatureVariableWithListenerUserInExperimentFeatureOn() throws Exc testDecisionInfoMap)); assertEquals(optimizely.getFeatureVariableString( - validFeatureKey, - validVariableKey, - testUserId, - Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), + validFeatureKey, + validVariableKey, + testUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), expectedValue); // Verify that listener being called @@ -2062,10 +2062,10 @@ public void getFeatureVariableWithListenerUserInExperimentFeatureOff() { testDecisionInfoMap)); assertEquals(optimizely.getFeatureVariableString( - validFeatureKey, - validVariableKey, - userID, - null), + validFeatureKey, + validVariableKey, + userID, + null), expectedValue); // Verify that listener being called @@ -2109,10 +2109,10 @@ public void getFeatureVariableWithListenerUserInRollOutFeatureOn() throws Except testDecisionInfoMap)); assertEquals(optimizely.getFeatureVariableString( - validFeatureKey, - validVariableKey, - genericUserId, - Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), expectedValue); // Verify that listener being called @@ -2156,10 +2156,10 @@ public void getFeatureVariableWithListenerUserNotInRollOutFeatureOff() { testDecisionInfoMap)); assertEquals(optimizely.getFeatureVariableBoolean( - validFeatureKey, - validVariableKey, - genericUserId, - Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), expectedValue); // Verify that listener being called @@ -2201,12 +2201,14 @@ public void getFeatureVariableIntegerWithListenerUserInRollOutFeatureOn() { testUserAttributes, testDecisionInfoMap)); - assertEquals((long) optimizely.getFeatureVariableInteger( - validFeatureKey, - validVariableKey, - genericUserId, - Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), - (long) expectedValue); + assertEquals( + expectedValue, + (long) optimizely.getFeatureVariableInteger( + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)) + ); // Verify that listener being called assertTrue(isListenerCalled); @@ -2251,10 +2253,10 @@ public void getFeatureVariableDoubleWithListenerUserInExperimentFeatureOn() thro testDecisionInfoMap)); assertEquals(optimizely.getFeatureVariableDouble( - validFeatureKey, - validVariableKey, - genericUserId, - Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_SLYTHERIN_VALUE)), + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_SLYTHERIN_VALUE)), Math.PI, 2); // Verify that listener being called @@ -2453,7 +2455,7 @@ public void getAllFeatureVariablesWithListenerUserInExperimentFeatureOff() { assertTrue(isListenerCalled); assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); } - + /** * Verify that the {@link Optimizely#activate(String, String, Map<String, String>)} call * correctly builds an endpoint url and request params @@ -2526,7 +2528,7 @@ public void activateWithListenerNullAttributes() throws Exception { * com.optimizely.ab.notification.NotificationListener)} properly used * and the listener is * added and notified when an experiment is activated. - * + * <p> * Feels redundant with the above tests */ @SuppressWarnings("unchecked") @@ -2572,7 +2574,7 @@ public void addNotificationListenerFromNotificationCenter() throws Exception { /** * Verify that {@link com.optimizely.ab.notification.NotificationCenter} properly * calls and the listener is removed and no longer notified when an experiment is activated. - * + * <p> * TODO move this to NotificationCenter. */ @SuppressWarnings("unchecked") @@ -2619,7 +2621,7 @@ public void removeNotificationListenerNotificationCenter() throws Exception { * Verify that {@link com.optimizely.ab.notification.NotificationCenter} * clearAllListerners removes all listeners * and no longer notified when an experiment is activated. - * + * <p> * TODO Should be part of NotificationCenter tests. */ @SuppressWarnings("unchecked") @@ -2741,7 +2743,7 @@ public void trackEventWithListenerNullAttributes() throws Exception { //======== Feature Accessor Tests ========// /** - * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, FeatureVariable.VariableType)} + * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)} * returns null and logs a message * when it is called with a feature key that has no corresponding feature in the datafile. */ @@ -2770,7 +2772,7 @@ public void getFeatureVariableValueForTypeReturnsNullWhenFeatureNotFound() throw } /** - * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, FeatureVariable.VariableType)} + * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)} * returns null and logs a message * when the feature key is valid, but no variable could be found for the variable key in the feature. */ @@ -2796,7 +2798,7 @@ public void getFeatureVariableValueForTypeReturnsNullWhenVariableNotFoundInValid } /** - * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, FeatureVariable.VariableType)} + * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)} * returns null when the variable's type does not match the type with which it was attempted to be accessed. */ @Test @@ -2825,7 +2827,7 @@ public void getFeatureVariableValueReturnsNullWhenVariableTypeDoesNotMatch() thr } /** - * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, FeatureVariable.VariableType)} + * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)} * returns the String default value of a feature variable * when the feature is not attached to an experiment or a rollout. */ @@ -2866,7 +2868,7 @@ public void getFeatureVariableValueForTypeReturnsDefaultValueWhenFeatureIsNotAtt } /** - * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, FeatureVariable.VariableType)} + * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)} * returns the String default value for a feature variable * when the feature is attached to an experiment and no rollout, but the user is excluded from the experiment. */ @@ -2910,7 +2912,7 @@ public void getFeatureVariableValueReturnsDefaultValueWhenFeatureIsAttachedToOne } /** - * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, FeatureVariable.VariableType)} + * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)} * is called when the variation is not null and feature enabled is false * returns the default variable value */ @@ -2964,10 +2966,10 @@ public void getFeatureVariableUserInExperimentFeatureOn() throws Exception { Optimizely optimizely = optimizelyBuilder.build(); assertEquals(optimizely.getFeatureVariableString( - validFeatureKey, - validVariableKey, - testUserId, - Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), + validFeatureKey, + validVariableKey, + testUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), expectedValue); logbackVerifier.expectMessage( @@ -2994,10 +2996,10 @@ public void getFeatureVariableUserInExperimentFeatureOff() { Optimizely optimizely = optimizelyBuilder.build(); assertEquals(optimizely.getFeatureVariableString( - validFeatureKey, - validVariableKey, - userID, - null), + validFeatureKey, + validVariableKey, + userID, + null), expectedValue); } @@ -3017,10 +3019,10 @@ public void getFeatureVariableUserInRollOutFeatureOn() throws Exception { Optimizely optimizely = optimizelyBuilder.build(); assertEquals(optimizely.getFeatureVariableString( - validFeatureKey, - validVariableKey, - genericUserId, - Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), expectedValue); } @@ -3040,10 +3042,10 @@ public void getFeatureVariableUserNotInRollOutFeatureOff() { Optimizely optimizely = optimizelyBuilder.build(); assertEquals(optimizely.getFeatureVariableBoolean( - validFeatureKey, - validVariableKey, - genericUserId, - Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), expectedValue); } @@ -3062,12 +3064,39 @@ public void getFeatureVariableIntegerUserInRollOutFeatureOn() { Optimizely optimizely = optimizelyBuilder.build(); - assertEquals((long) optimizely.getFeatureVariableInteger( - validFeatureKey, - validVariableKey, - genericUserId, - Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), - (long) expectedValue); + assertEquals( + expectedValue, + (int) optimizely.getFeatureVariableInteger( + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)) + ); + } + + /** + * Verify that the {@link Optimizely#getFeatureVariableInteger(String, String, String, Map)} + * is called when feature is in rollout and feature enabled is true + * return rollout variable value + */ + @Test + public void getFeatureVariableLongUserInRollOutFeatureOn() { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + final String validFeatureKey = FEATURE_SINGLE_VARIABLE_INTEGER_KEY; + String validVariableKey = VARIABLE_INTEGER_VARIABLE_KEY; + int expectedValue = 7; + + Optimizely optimizely = optimizelyBuilder.build(); + + assertEquals( + expectedValue, + (int) optimizely.getFeatureVariableInteger( + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)) + ); } /** @@ -3085,15 +3114,15 @@ public void getFeatureVariableDoubleUserInExperimentFeatureOn() throws Exception Optimizely optimizely = optimizelyBuilder.build(); assertEquals(optimizely.getFeatureVariableDouble( - validFeatureKey, - validVariableKey, - genericUserId, - Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_SLYTHERIN_VALUE)), + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_SLYTHERIN_VALUE)), Math.PI, 2); } /** - * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, FeatureVariable.VariableType)} + * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)} * returns the default value for the feature variable * when there is no variable usage present for the variation the user is bucketed into. */ @@ -4160,6 +4189,18 @@ public void convertStringToTypeIntegerCatchesExceptionFromParsing() throws Numbe ); } + /** + * Verify that {@link Optimizely#convertStringToType(String, String)} + * is able to parse Long. + */ + @Test + public void convertStringToTypeIntegerReturnsLongCorrectly() throws NumberFormatException { + String longValue = "8949425362117"; + + Optimizely optimizely = optimizelyBuilder.build(); + assertEquals(Long.valueOf(longValue), optimizely.convertStringToType(longValue, FeatureVariable.INTEGER_TYPE)); + } + /** * Verify {@link Optimizely#getFeatureVariableInteger(String, String, String)} * calls through to {@link Optimizely#getFeatureVariableInteger(String, String, String, Map)} @@ -4234,7 +4275,7 @@ public void getFeatureVariableIntegerReturnsNullWhenUserIdIsNull() throws Except * Verify {@link Optimizely#getFeatureVariableInteger(String, String, String)} * calls through to {@link Optimizely#getFeatureVariableInteger(String, String, String, Map)} * and both return the parsed Integer value from the value returned from - * {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, FeatureVariable.VariableType)}. + * {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)}. */ @Test public void getFeatureVariableIntegerReturnsWhatInternalReturns() throws Exception { @@ -4333,8 +4374,8 @@ public void getFeatureVariableJSONUserInExperimentFeatureOn() throws Exception { assertEquals(json.toMap().get("k1"), "s1"); assertEquals(json.toMap().get("k2"), 103.5); assertEquals(json.toMap().get("k3"), false); - assertEquals(((Map)json.toMap().get("k4")).get("kk1"), "ss1"); - assertEquals(((Map)json.toMap().get("k4")).get("kk2"), true); + assertEquals(((Map) json.toMap().get("k4")).get("kk1"), "ss1"); + assertEquals(((Map) json.toMap().get("k4")).get("kk2"), true); assertEquals(json.getValue("k1", String.class), "s1"); assertEquals(json.getValue("k4.kk2", Boolean.class), true); @@ -4368,15 +4409,15 @@ public void getFeatureVariableJSONUserInExperimentFeatureOff() throws Exception assertEquals(json.toMap().get("k1"), "v1"); assertEquals(json.toMap().get("k2"), 3.5); assertEquals(json.toMap().get("k3"), true); - assertEquals(((Map)json.toMap().get("k4")).get("kk1"), "vv1"); - assertEquals(((Map)json.toMap().get("k4")).get("kk2"), false); + assertEquals(((Map) json.toMap().get("k4")).get("kk1"), "vv1"); + assertEquals(((Map) json.toMap().get("k4")).get("kk2"), false); assertEquals(json.getValue("k1", String.class), "v1"); assertEquals(json.getValue("k4.kk2", Boolean.class), false); } /** - * Verify that the {@link Optimizely#getAllFeatureVariables(String,String, Map)} + * Verify that the {@link Optimizely#getAllFeatureVariables(String, String, Map)} * is called when feature is in experiment and feature enabled is true * returns variable value */ @@ -4398,12 +4439,12 @@ public void getAllFeatureVariablesUserInExperimentFeatureOn() throws Exception { assertEquals(json.toMap().get("first_letter"), "F"); assertEquals(json.toMap().get("rest_of_name"), "red"); - Map subMap = (Map)json.toMap().get("json_patched"); + Map subMap = (Map) json.toMap().get("json_patched"); assertEquals(subMap.get("k1"), "s1"); assertEquals(subMap.get("k2"), 103.5); assertEquals(subMap.get("k3"), false); - assertEquals(((Map)subMap.get("k4")).get("kk1"), "ss1"); - assertEquals(((Map)subMap.get("k4")).get("kk2"), true); + assertEquals(((Map) subMap.get("k4")).get("kk1"), "ss1"); + assertEquals(((Map) subMap.get("k4")).get("kk2"), true); assertEquals(json.getValue("first_letter", String.class), "F"); assertEquals(json.getValue("json_patched.k1", String.class), "s1"); @@ -4435,12 +4476,12 @@ public void getAllFeatureVariablesUserInExperimentFeatureOff() throws Exception assertEquals(json.toMap().get("first_letter"), "H"); assertEquals(json.toMap().get("rest_of_name"), "arry"); - Map subMap = (Map)json.toMap().get("json_patched"); + Map subMap = (Map) json.toMap().get("json_patched"); assertEquals(subMap.get("k1"), "v1"); assertEquals(subMap.get("k2"), 3.5); assertEquals(subMap.get("k3"), true); - assertEquals(((Map)subMap.get("k4")).get("kk1"), "vv1"); - assertEquals(((Map)subMap.get("k4")).get("kk2"), false); + assertEquals(((Map) subMap.get("k4")).get("kk1"), "vv1"); + assertEquals(((Map) subMap.get("k4")).get("kk2"), false); assertEquals(json.getValue("first_letter", String.class), "H"); assertEquals(json.getValue("json_patched.k1", String.class), "v1"); @@ -4448,7 +4489,7 @@ public void getAllFeatureVariablesUserInExperimentFeatureOff() throws Exception } /** - * Verify {@link Optimizely#getAllFeatureVariables(String,String, Map)} with invalid parameters + * Verify {@link Optimizely#getAllFeatureVariables(String, String, Map)} with invalid parameters */ @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @Test @@ -4532,7 +4573,8 @@ public void testAddTrackNotificationHandler() { NotificationManager<TrackNotification> manager = optimizely.getNotificationCenter() .getNotificationManager(TrackNotification.class); - int notificationId = optimizely.addTrackNotificationHandler(message -> {}); + int notificationId = optimizely.addTrackNotificationHandler(message -> { + }); assertTrue(manager.remove(notificationId)); } @@ -4542,7 +4584,8 @@ public void testAddDecisionNotificationHandler() { NotificationManager<DecisionNotification> manager = optimizely.getNotificationCenter() .getNotificationManager(DecisionNotification.class); - int notificationId = optimizely.addDecisionNotificationHandler(message -> {}); + int notificationId = optimizely.addDecisionNotificationHandler(message -> { + }); assertTrue(manager.remove(notificationId)); } @@ -4552,7 +4595,8 @@ public void testAddUpdateConfigNotificationHandler() { NotificationManager<UpdateConfigNotification> manager = optimizely.getNotificationCenter() .getNotificationManager(UpdateConfigNotification.class); - int notificationId = optimizely.addUpdateConfigNotificationHandler(message -> {}); + int notificationId = optimizely.addUpdateConfigNotificationHandler(message -> { + }); assertTrue(manager.remove(notificationId)); } @@ -4562,7 +4606,8 @@ public void testAddLogEventNotificationHandler() { NotificationManager<LogEvent> manager = optimizely.getNotificationCenter() .getNotificationManager(LogEvent.class); - int notificationId = optimizely.addLogEventNotificationHandler(message -> {}); + int notificationId = optimizely.addLogEventNotificationHandler(message -> { + }); assertTrue(manager.remove(notificationId)); } diff --git a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java index 0ed8d5945..faacfda76 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java +++ b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java @@ -266,6 +266,19 @@ public class ValidProjectConfigV4 { FeatureVariable.INTEGER_TYPE, null ); + private static final String FEATURE_SINGLE_VARIABLE_LONG_ID = "964006971"; + public static final String FEATURE_SINGLE_VARIABLE_LONG_KEY = "long_single_variable_feature"; + private static final String VARIABLE_LONG_VARIABLE_ID = "4339640697"; + public static final String VARIABLE_LONG_VARIABLE_KEY = "long_variable"; + private static final String VARIABLE_LONG_DEFAULT_VALUE = "379993881340"; + private static final FeatureVariable VARIABLE_LONG_VARIABLE = new FeatureVariable( + VARIABLE_LONG_VARIABLE_ID, + VARIABLE_LONG_VARIABLE_KEY, + VARIABLE_LONG_DEFAULT_VALUE, + null, + FeatureVariable.INTEGER_TYPE, + null + ); private static final String FEATURE_SINGLE_VARIABLE_BOOLEAN_ID = "2591051011"; public static final String FEATURE_SINGLE_VARIABLE_BOOLEAN_KEY = "boolean_single_variable_feature"; private static final String VARIABLE_BOOLEAN_VARIABLE_ID = "3974680341"; From 5a1b1c8ea8c4d029eb440e76137ebba6062ecca6 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Thu, 20 Feb 2025 14:49:06 +0600 Subject: [PATCH 140/147] Update change log (#561) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 104422c93..0db74cd39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Optimizely Java X SDK Changelog +## [4.2.1] +Feb 19th, 2025 + +### Fixes +- Fix big integer conversion ([#556](https://github.com/optimizely/java-sdk/pull/556)). + ## [4.2.0] November 6th, 2024 From f90da0c073ee28243e3fe98b8e38271d9486a38f Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Thu, 20 Feb 2025 23:18:17 +0600 Subject: [PATCH 141/147] [FSSDK-11076] fix: release tag (#562) * fix github tag * clean up * clean up --- .github/workflows/build.yml | 2 +- build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e10de2d47..2965e4f9e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,4 +37,4 @@ jobs: MAVEN_SIGNING_PASSPHRASE: ${{ secrets.MAVEN_SIGNING_PASSPHRASE }} MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - run: ./gradlew ${{ inputs.action }} + run: GITHUB_TAG=${{ inputs.github_tag }} ./gradlew ${{ inputs.action }} diff --git a/build.gradle b/build.gradle index 3301eda25..4c8c2a513 100644 --- a/build.gradle +++ b/build.gradle @@ -24,7 +24,7 @@ allprojects { allprojects { group = 'com.optimizely.ab' - def travis_defined_version = System.getenv('TRAVIS_TAG') + def travis_defined_version = System.getenv('GITHUB_TAG') if (travis_defined_version != null) { version = travis_defined_version } From 84710ccace02a86da9b2e9159e94ae8528006b12 Mon Sep 17 00:00:00 2001 From: Jae Kim <45045038+jaeopt@users.noreply.github.com> Date: Wed, 23 Apr 2025 16:41:59 -0700 Subject: [PATCH 142/147] [FSSDK-1135] upgrade spotbugs to 4.8.5 (#565) --- .github/workflows/java.yml | 4 ++-- README.md | 2 +- build.gradle | 3 ++- core-httpclient-impl/build.gradle | 1 + .../com/optimizely/ab/config/HttpProjectConfigManager.java | 2 ++ 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index 7ebc17304..95e8ccf8d 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -83,8 +83,8 @@ jobs: - name: Check on failures if: always() && steps.unit_tests.outcome != 'success' run: | - cat /home/runner/java-sdk/core-api/build/reports/findbugs/main.html - cat /home/runner/java-sdk/core-api/build/reports/findbugs/test.html + cat /Users/runner/work/java-sdk/core-api/build/reports/spotbugs/main.html + cat /Users/runner/work/java-sdk/core-api/build/reports/spotbugs/test.html - name: Check on success if: always() && steps.unit_tests.outcome == 'success' run: | diff --git a/README.md b/README.md index b4b1f0be1..1a7370c43 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ You can run all unit tests with: ### Checking for bugs -We utilize [FindBugs](http://findbugs.sourceforge.net/) to identify possible bugs in the SDK. To run the check: +We utilize [SpotBugs](https://spotbugs.github.io/) to identify possible bugs in the SDK. To run the check: ``` diff --git a/build.gradle b/build.gradle index 4c8c2a513..54426f6e7 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ plugins { id 'me.champeau.gradle.jmh' version '0.5.3' id 'nebula.optional-base' version '3.2.0' id 'com.github.hierynomus.license' version '0.16.1' - id 'com.github.spotbugs' version "4.5.0" + id 'com.github.spotbugs' version "6.0.14" id 'maven-publish' } @@ -73,6 +73,7 @@ configure(publishedProjects) { spotbugs { spotbugsJmh.enabled = false + reportLevel = com.github.spotbugs.snom.Confidence.valueOf('HIGH') } test { diff --git a/core-httpclient-impl/build.gradle b/core-httpclient-impl/build.gradle index 4affcda17..ab5644555 100644 --- a/core-httpclient-impl/build.gradle +++ b/core-httpclient-impl/build.gradle @@ -2,6 +2,7 @@ dependencies { implementation project(':core-api') implementation group: 'org.apache.httpcomponents', name: 'httpclient', version: httpClientVersion implementation group: 'org.slf4j', name: 'slf4j-api', version: slf4jVersion + implementation group: 'com.google.code.findbugs', name: 'annotations', version: findbugsAnnotationVersion implementation group: 'com.google.code.findbugs', name: 'jsr305', version: findbugsJsrVersion testImplementation 'org.mock-server:mockserver-netty:5.1.1' } diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java index 095e32a67..2e99d3ae9 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java @@ -24,6 +24,7 @@ import java.util.concurrent.ThreadFactory; import java.util.concurrent.locks.ReentrantLock; import javax.annotation.Nullable; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.apache.http.*; import org.apache.http.client.ClientProtocolException; import org.apache.http.client.methods.CloseableHttpResponse; @@ -309,6 +310,7 @@ public Builder withPollingInterval(Long period, TimeUnit timeUnit) { return this; } + @SuppressFBWarnings("EI_EXPOSE_REP2") public Builder withNotificationCenter(NotificationCenter notificationCenter) { this.notificationCenter = notificationCenter; return this; From 64868864d2480a0c7f1081b1086d0d149b4bb160 Mon Sep 17 00:00:00 2001 From: esrakartalOpt <102107327+esrakartalOpt@users.noreply.github.com> Date: Mon, 12 May 2025 09:09:07 -0500 Subject: [PATCH 143/147] [FSSDK-11448] Java Implementation: Add Experiment ID and Variation ID to Decision Notification (#566) * [FSSDK-11448] Java Implementation: Add Experiment ID and Variation ID to Decision Notification * Fix test * Fix test * Fix decision test * Fix test * Fix the tests * Fix test * Fix last test case * Fix the test case * Remove experiment decision changes * Fix test * Fix test * Fix test --- .../main/java/com/optimizely/ab/Optimizely.java | 6 ++++++ .../ab/notification/DecisionNotification.java | 16 ++++++++++++++++ .../optimizely/ab/OptimizelyUserContextTest.java | 4 ++++ 3 files changed, 26 insertions(+) diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 4f942ff69..6eead11c6 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1303,6 +1303,8 @@ private OptimizelyDecision createOptimizelyDecision( ProjectConfig projectConfig ) { String userId = user.getUserId(); + String experimentId = null; + String variationId = null; Boolean flagEnabled = false; if (flagDecision.variation != null) { @@ -1336,6 +1338,8 @@ private OptimizelyDecision createOptimizelyDecision( Boolean decisionEventDispatched = false; + experimentId = flagDecision.experiment != null ? flagDecision.experiment.getId() : null; + variationId = flagDecision.variation != null ? flagDecision.variation.getId() : null; Map<String, Object> attributes = user.getAttributes(); Map<String, ?> copiedAttributes = new HashMap<>(attributes); @@ -1362,6 +1366,8 @@ private OptimizelyDecision createOptimizelyDecision( .withRuleKey(ruleKey) .withReasons(reasonsToReport) .withDecisionEventDispatched(decisionEventDispatched) + .withExperimentId(experimentId) + .withVariationId(variationId) .build(); notificationCenter.send(decisionNotification); diff --git a/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java b/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java index d97e5bf40..ab3fdc03d 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java @@ -364,6 +364,8 @@ public static class FlagDecisionNotificationBuilder { public final static String RULE_KEY = "ruleKey"; public final static String REASONS = "reasons"; public final static String DECISION_EVENT_DISPATCHED = "decisionEventDispatched"; + public final static String EXPERIMENT_ID = "experimentId"; + public final static String VARIATION_ID = "variationId"; private String flagKey; private Boolean enabled; @@ -374,6 +376,8 @@ public static class FlagDecisionNotificationBuilder { private String ruleKey; private List<String> reasons; private Boolean decisionEventDispatched; + private String experimentId; + private String variationId; private Map<String, Object> decisionInfo; @@ -422,6 +426,16 @@ public FlagDecisionNotificationBuilder withDecisionEventDispatched(Boolean dispa return this; } + public FlagDecisionNotificationBuilder withExperimentId(String experimentId) { + this.experimentId = experimentId; + return this; + } + + public FlagDecisionNotificationBuilder withVariationId(String variationId) { + this.variationId = variationId; + return this; + } + public DecisionNotification build() { if (flagKey == null) { throw new OptimizelyRuntimeException("flagKey not set"); @@ -439,6 +453,8 @@ public DecisionNotification build() { put(RULE_KEY, ruleKey); put(REASONS, reasons); put(DECISION_EVENT_DISPATCHED, decisionEventDispatched); + put(EXPERIMENT_ID, experimentId); + put(VARIATION_ID, variationId); }}; return new DecisionNotification( diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java index 34cf61543..bb2d36192 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -707,6 +707,8 @@ public void decisionNotification() { OptimizelyJSON variables = optimizely.getAllFeatureVariables(flagKey, userId); String ruleKey = "exp_no_audience"; List<String> reasons = Collections.emptyList(); + String experimentId = "10420810910"; + String variationId = "10418551353"; final Map<String, Object> testDecisionInfoMap = new HashMap<>(); testDecisionInfoMap.put(FLAG_KEY, flagKey); @@ -715,6 +717,8 @@ public void decisionNotification() { testDecisionInfoMap.put(VARIABLES, variables.toMap()); testDecisionInfoMap.put(RULE_KEY, ruleKey); testDecisionInfoMap.put(REASONS, reasons); + testDecisionInfoMap.put(EXPERIMENT_ID, experimentId); + testDecisionInfoMap.put(VARIATION_ID, variationId); Map<String, Object> attributes = Collections.singletonMap("gender", "f"); OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); From 52185b7b9f4540a94180ba9b705b76ad58f87af9 Mon Sep 17 00:00:00 2001 From: esrakartalOpt <102107327+esrakartalOpt@users.noreply.github.com> Date: Thu, 15 May 2025 10:06:57 -0500 Subject: [PATCH 144/147] =?UTF-8?q?Revert=20"[FSSDK-11448]=20Java=20Implem?= =?UTF-8?q?entation:=20Add=20Experiment=20ID=20and=20Variation=20ID?= =?UTF-8?q?=E2=80=A6"=20(#567)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 64868864d2480a0c7f1081b1086d0d149b4bb160. --- .../main/java/com/optimizely/ab/Optimizely.java | 6 ------ .../ab/notification/DecisionNotification.java | 16 ---------------- .../optimizely/ab/OptimizelyUserContextTest.java | 4 ---- 3 files changed, 26 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 6eead11c6..4f942ff69 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1303,8 +1303,6 @@ private OptimizelyDecision createOptimizelyDecision( ProjectConfig projectConfig ) { String userId = user.getUserId(); - String experimentId = null; - String variationId = null; Boolean flagEnabled = false; if (flagDecision.variation != null) { @@ -1338,8 +1336,6 @@ private OptimizelyDecision createOptimizelyDecision( Boolean decisionEventDispatched = false; - experimentId = flagDecision.experiment != null ? flagDecision.experiment.getId() : null; - variationId = flagDecision.variation != null ? flagDecision.variation.getId() : null; Map<String, Object> attributes = user.getAttributes(); Map<String, ?> copiedAttributes = new HashMap<>(attributes); @@ -1366,8 +1362,6 @@ private OptimizelyDecision createOptimizelyDecision( .withRuleKey(ruleKey) .withReasons(reasonsToReport) .withDecisionEventDispatched(decisionEventDispatched) - .withExperimentId(experimentId) - .withVariationId(variationId) .build(); notificationCenter.send(decisionNotification); diff --git a/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java b/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java index ab3fdc03d..d97e5bf40 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java @@ -364,8 +364,6 @@ public static class FlagDecisionNotificationBuilder { public final static String RULE_KEY = "ruleKey"; public final static String REASONS = "reasons"; public final static String DECISION_EVENT_DISPATCHED = "decisionEventDispatched"; - public final static String EXPERIMENT_ID = "experimentId"; - public final static String VARIATION_ID = "variationId"; private String flagKey; private Boolean enabled; @@ -376,8 +374,6 @@ public static class FlagDecisionNotificationBuilder { private String ruleKey; private List<String> reasons; private Boolean decisionEventDispatched; - private String experimentId; - private String variationId; private Map<String, Object> decisionInfo; @@ -426,16 +422,6 @@ public FlagDecisionNotificationBuilder withDecisionEventDispatched(Boolean dispa return this; } - public FlagDecisionNotificationBuilder withExperimentId(String experimentId) { - this.experimentId = experimentId; - return this; - } - - public FlagDecisionNotificationBuilder withVariationId(String variationId) { - this.variationId = variationId; - return this; - } - public DecisionNotification build() { if (flagKey == null) { throw new OptimizelyRuntimeException("flagKey not set"); @@ -453,8 +439,6 @@ public DecisionNotification build() { put(RULE_KEY, ruleKey); put(REASONS, reasons); put(DECISION_EVENT_DISPATCHED, decisionEventDispatched); - put(EXPERIMENT_ID, experimentId); - put(VARIATION_ID, variationId); }}; return new DecisionNotification( diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java index bb2d36192..34cf61543 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -707,8 +707,6 @@ public void decisionNotification() { OptimizelyJSON variables = optimizely.getAllFeatureVariables(flagKey, userId); String ruleKey = "exp_no_audience"; List<String> reasons = Collections.emptyList(); - String experimentId = "10420810910"; - String variationId = "10418551353"; final Map<String, Object> testDecisionInfoMap = new HashMap<>(); testDecisionInfoMap.put(FLAG_KEY, flagKey); @@ -717,8 +715,6 @@ public void decisionNotification() { testDecisionInfoMap.put(VARIABLES, variables.toMap()); testDecisionInfoMap.put(RULE_KEY, ruleKey); testDecisionInfoMap.put(REASONS, reasons); - testDecisionInfoMap.put(EXPERIMENT_ID, experimentId); - testDecisionInfoMap.put(VARIATION_ID, variationId); Map<String, Object> attributes = Collections.singletonMap("gender", "f"); OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); From 81e46b8742c7500260adbd0fc2707fcb31e1ec46 Mon Sep 17 00:00:00 2001 From: esrakartalOpt <102107327+esrakartalOpt@users.noreply.github.com> Date: Wed, 28 May 2025 05:51:52 -0500 Subject: [PATCH 145/147] [FSSDK-11448] Java Implementation: Add Experiment ID and Variation ID to Decision Notification (#569) --- .../main/java/com/optimizely/ab/Optimizely.java | 6 ++++++ .../ab/notification/DecisionNotification.java | 16 ++++++++++++++++ .../optimizely/ab/OptimizelyUserContextTest.java | 4 ++++ 3 files changed, 26 insertions(+) diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 4f942ff69..6eead11c6 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1303,6 +1303,8 @@ private OptimizelyDecision createOptimizelyDecision( ProjectConfig projectConfig ) { String userId = user.getUserId(); + String experimentId = null; + String variationId = null; Boolean flagEnabled = false; if (flagDecision.variation != null) { @@ -1336,6 +1338,8 @@ private OptimizelyDecision createOptimizelyDecision( Boolean decisionEventDispatched = false; + experimentId = flagDecision.experiment != null ? flagDecision.experiment.getId() : null; + variationId = flagDecision.variation != null ? flagDecision.variation.getId() : null; Map<String, Object> attributes = user.getAttributes(); Map<String, ?> copiedAttributes = new HashMap<>(attributes); @@ -1362,6 +1366,8 @@ private OptimizelyDecision createOptimizelyDecision( .withRuleKey(ruleKey) .withReasons(reasonsToReport) .withDecisionEventDispatched(decisionEventDispatched) + .withExperimentId(experimentId) + .withVariationId(variationId) .build(); notificationCenter.send(decisionNotification); diff --git a/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java b/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java index d97e5bf40..ab3fdc03d 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java @@ -364,6 +364,8 @@ public static class FlagDecisionNotificationBuilder { public final static String RULE_KEY = "ruleKey"; public final static String REASONS = "reasons"; public final static String DECISION_EVENT_DISPATCHED = "decisionEventDispatched"; + public final static String EXPERIMENT_ID = "experimentId"; + public final static String VARIATION_ID = "variationId"; private String flagKey; private Boolean enabled; @@ -374,6 +376,8 @@ public static class FlagDecisionNotificationBuilder { private String ruleKey; private List<String> reasons; private Boolean decisionEventDispatched; + private String experimentId; + private String variationId; private Map<String, Object> decisionInfo; @@ -422,6 +426,16 @@ public FlagDecisionNotificationBuilder withDecisionEventDispatched(Boolean dispa return this; } + public FlagDecisionNotificationBuilder withExperimentId(String experimentId) { + this.experimentId = experimentId; + return this; + } + + public FlagDecisionNotificationBuilder withVariationId(String variationId) { + this.variationId = variationId; + return this; + } + public DecisionNotification build() { if (flagKey == null) { throw new OptimizelyRuntimeException("flagKey not set"); @@ -439,6 +453,8 @@ public DecisionNotification build() { put(RULE_KEY, ruleKey); put(REASONS, reasons); put(DECISION_EVENT_DISPATCHED, decisionEventDispatched); + put(EXPERIMENT_ID, experimentId); + put(VARIATION_ID, variationId); }}; return new DecisionNotification( diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java index 34cf61543..bb2d36192 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -707,6 +707,8 @@ public void decisionNotification() { OptimizelyJSON variables = optimizely.getAllFeatureVariables(flagKey, userId); String ruleKey = "exp_no_audience"; List<String> reasons = Collections.emptyList(); + String experimentId = "10420810910"; + String variationId = "10418551353"; final Map<String, Object> testDecisionInfoMap = new HashMap<>(); testDecisionInfoMap.put(FLAG_KEY, flagKey); @@ -715,6 +717,8 @@ public void decisionNotification() { testDecisionInfoMap.put(VARIABLES, variables.toMap()); testDecisionInfoMap.put(RULE_KEY, ruleKey); testDecisionInfoMap.put(REASONS, reasons); + testDecisionInfoMap.put(EXPERIMENT_ID, experimentId); + testDecisionInfoMap.put(VARIATION_ID, variationId); Map<String, Object> attributes = Collections.singletonMap("gender", "f"); OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); From 8f7508543b314c7915d4b62cc13176ba89351950 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Wed, 28 May 2025 18:32:57 +0600 Subject: [PATCH 146/147] Update for release 4.2.2 (#570) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0db74cd39..565bfcd5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Optimizely Java X SDK Changelog +## [4.2.2] +May 28th, 2025 + +### Fixes +- Added experimentId and variationId to decision notification ([#569](https://github.com/optimizely/java-sdk/pull/569)). + ## [4.2.1] Feb 19th, 2025 From 746e81530a9224fabcd7f610d81800358e6e34c9 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Wed, 28 May 2025 21:05:07 +0600 Subject: [PATCH 147/147] [FSSDK-11465] chore: update github actions (#571) * Update build.yml * Update integration_test.yml * Update build.yml * Update java.yml * Update java.yml * Update build.yml * Update java.yml * Update build.yml * Update build.yml --- .github/workflows/build.yml | 3 +-- .github/workflows/integration_test.yml | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2965e4f9e..1cb2193c8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,13 +22,12 @@ jobs: run_build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: set up JDK 8 uses: actions/setup-java@v2 with: java-version: '8' distribution: 'temurin' - cache: gradle - name: Grant execute permission for gradlew run: chmod +x gradlew - name: ${{ inputs.action }} diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 35fb78590..76fef5ad3 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -13,7 +13,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: # You should create a personal access token and store it in your repository token: ${{ secrets.CI_USER_TOKEN }}