Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/main' into issues/72
Browse files Browse the repository at this point in the history
  • Loading branch information
Bohdan-Kim committed Jul 4, 2024
2 parents 819e5e1 + 331c2a2 commit e35a7de
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 71 deletions.
91 changes: 60 additions & 31 deletions lib/src/main/java/growthbook/sdk/java/ConditionEvaluator.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,39 +39,67 @@ public Boolean evaluateCondition(String attributesJsonString, String conditionJs
JsonElement attributesJson = jsonUtils.gson.fromJson(attributesJsonString, JsonElement.class);
JsonObject conditionJson = jsonUtils.gson.fromJson(conditionJsonString, JsonObject.class);

if (conditionJson.has("$or")) {
JsonArray targetItems = conditionJson.get("$or").getAsJsonArray();
return evalOr(attributesJson, targetItems);
}

if (conditionJson.has("$nor")) {
JsonArray targetItems = conditionJson.get("$nor").getAsJsonArray();
return !evalOr(attributesJson, targetItems);
}

if (conditionJson.has("$and")) {
JsonArray targetItems = conditionJson.get("$and").getAsJsonArray();
return evalAnd(attributesJson, targetItems);
}

if (conditionJson.has("$not")) {
JsonElement targetItem = conditionJson.get("$not");
return !evaluateCondition(attributesJsonString, targetItem.toString());
}

Set<Map.Entry<String, JsonElement>> conditionEntries = conditionJson.entrySet();
for (Map.Entry<String, JsonElement> entry : conditionEntries) {
JsonElement element = (JsonElement) getPath(attributesJson, entry.getKey());
if (entry.getValue() != null) {
if (!evalConditionValue(entry.getValue(), element)) {
return false;
}
// Loop through the conditionObj key/value pairs
for (Map.Entry<String, JsonElement> entry : conditionJson.entrySet()) {
String key = entry.getKey();
JsonElement value = entry.getValue();

switch (key) {
case "$or":
// If conditionObj has a key $or, return evalOr(attributes, condition["$or"])
JsonArray orTargetItems = value.getAsJsonArray();
if (orTargetItems != null) {
if (!evalOr(attributesJson, orTargetItems)) {
return false;
}
}
break;
case "$nor":
// If conditionObj has a key $nor, return !evalOr(attributes, condition["$nor"])
JsonArray norTargetItems = value.getAsJsonArray();
if (norTargetItems != null) {
if (evalOr(attributesJson, norTargetItems)) {
return false;
}
}
break;
case "$and":
// If conditionObj has a key $and, return !evalAnd(attributes, condition["$and"])
JsonArray andTargetItems = value.getAsJsonArray();
if (andTargetItems != null) {
if (!evalAnd(attributesJson, andTargetItems)) {
return false;
}
}
break;
case "$not":
// If conditionObj has a key $not, return !evalCondition(attributes, condition["$not"])
if (value != null) {
if (evaluateCondition(attributesJsonString, value.toString())) {
return false;
}
}
break;
default:
JsonElement element = (JsonElement) getPath(attributesJson, key);
// If evalConditionValue(value, getPath(attributes, key)) is false,
// break out of loop and return false
if (!evalConditionValue(value, element)) {
return false;
}
break;
}
}

// If none of the entries failed their checks, `evalCondition` returns true
return true;
} catch (RuntimeException e) {
log.error(e.getMessage(), e);
} catch (com.google.gson.JsonSyntaxException jsonSyntaxException) {
log.error(jsonSyntaxException.getMessage(), jsonSyntaxException);
return false;
} catch (java.util.regex.PatternSyntaxException patternSyntaxException) {
log.error(patternSyntaxException.getMessage(), patternSyntaxException);
return false;
} catch (Exception exception) { // for the case if something was missed
log.error(exception.getMessage(), exception);
return false;
}
}
Expand Down Expand Up @@ -581,7 +609,8 @@ Boolean evalAnd(JsonElement attributes, JsonArray conditions) {
}

private Boolean isIn(JsonElement actual, JsonArray expected) {
Type listType = new TypeToken<ArrayList<Object>>() {}.getType();
Type listType = new TypeToken<ArrayList<Object>>() {
}.getType();
ArrayList<JsonElement> expectedAsList = jsonUtils.gson.fromJson(expected, listType);

if (!actual.isJsonArray()) return expectedAsList.contains(actual);
Expand Down
65 changes: 33 additions & 32 deletions lib/src/main/java/growthbook/sdk/java/GBContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public class GBContext {
*
* @param attributesJson User attributes as JSON string
* @param featuresJson Features response as JSON string, or the encrypted payload. Encrypted payload requires `encryptionKey`
* @param features Features response as JSON Object, either set this or `featuresJson`
* @param encryptionKey Optional encryption key. If this is not null, featuresJson should be an encrypted payload.
* @param enabled Whether globally all experiments are enabled (default: true)
* @param isQaMode If true, random assignment is disabled and only explicitly forced variations are used.
Expand All @@ -46,6 +47,7 @@ public class GBContext {
public GBContext(
@Nullable String attributesJson,
@Nullable String featuresJson,
@Nullable JsonObject features,
@Nullable String encryptionKey,
@Nullable Boolean enabled,
Boolean isQaMode,
Expand All @@ -59,22 +61,13 @@ public GBContext(
@Nullable List<String> stickyBucketIdentifierAttributes
) {
this.encryptionKey = encryptionKey;

this.attributesJson = attributesJson == null ? "{}" : attributesJson;

// Features start as empty JSON
this.featuresJson = "{}";
if (encryptionKey != null && featuresJson != null) {
// Attempt to decrypt payload
try {
String decrypted = DecryptionUtils.decrypt(featuresJson, encryptionKey);
this.featuresJson = decrypted.trim();
} catch (DecryptionUtils.DecryptionException e) {
log.error(e.getMessage(), e);
}
} else if (featuresJson != null) {
// Use features
this.featuresJson = featuresJson;
if (featuresJson != null) {
this.features = transformEncryptedFeatures(featuresJson, encryptionKey);
}
if (features != null) {
this.features = features;
}

this.enabled = enabled == null ? true : enabled;
Expand All @@ -94,13 +87,8 @@ public GBContext(
* Feature definitions - To be pulled from API / Cache
*/
@Nullable
@Getter(AccessLevel.PACKAGE)
private JsonObject features;

private void setFeatures(@Nullable JsonObject features) {
this.features = features;
}

/**
* Switch to globally disable all experiments. Default true.
*/
Expand Down Expand Up @@ -167,12 +155,6 @@ private void setAttributes(@Nullable JsonObject attributes) {
this.attributes = attributes;
}

/**
* Feature definitions (usually pulled from an API or cache)
*/
@Nullable
private String featuresJson;

/**
* Optional encryption key. If this is not null, featuresJson should be an encrypted payload.
*/
Expand All @@ -186,7 +168,6 @@ private void setAttributes(@Nullable JsonObject attributes) {
*/

public void setFeaturesJson(String featuresJson) {
this.featuresJson = featuresJson;
if (featuresJson != null) {
this.setFeatures(GBContext.transformFeatures(featuresJson));
}
Expand Down Expand Up @@ -235,19 +216,14 @@ static class CustomGBContextBuilder extends GBContextBuilder {
@Override
public GBContext build() {
GBContext context = super.build();

if (context.featuresJson != null) {
context.setFeatures(GBContext.transformFeatures(context.featuresJson));
}

context.setAttributesJson(context.attributesJson);

return context;
}
}

@Nullable
private static JsonObject transformFeatures(String featuresJsonString) {
public static JsonObject transformFeatures(String featuresJsonString) {
try {
return GrowthBookJsonUtils.getInstance().gson.fromJson(featuresJsonString, JsonObject.class);
} catch (Exception e) {
Expand All @@ -256,6 +232,31 @@ private static JsonObject transformFeatures(String featuresJsonString) {
}
}

public static JsonObject transformEncryptedFeatures(
@Nullable String featuresJson,
@Nullable String encryptionKey
) {
// Features start as empty JSON
JsonObject jsonObject = new JsonObject();

if (encryptionKey != null && featuresJson != null) {
// Attempt to decrypt payload
try {
String decrypted = DecryptionUtils.decrypt(featuresJson, encryptionKey);
String featuresJsonDecrypted = decrypted.trim();
jsonObject = GBContext.transformFeatures(featuresJsonDecrypted);

} catch (DecryptionUtils.DecryptionException e) {
log.error(e.getMessage(), e);

}
} else if (featuresJson != null) {
jsonObject = GBContext.transformFeatures(featuresJson);
}

return jsonObject;
}

private static JsonObject transformAttributes(@Nullable String attributesJsonString) {
try {
if (attributesJsonString == null) {
Expand Down
2 changes: 1 addition & 1 deletion lib/src/main/java/growthbook/sdk/java/Version.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
public class Version {
private Version() {}

static final String SDK_VERSION = "0.9.5";
static final String SDK_VERSION = "0.9.7";
}
12 changes: 7 additions & 5 deletions lib/src/test/java/growthbook/sdk/java/GBContextTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ void canBeConstructed() {
sampleUserAttributes,
featuresJson,
null,
null,
isEnabled,
isQaMode,
url,
Expand Down Expand Up @@ -125,6 +126,7 @@ void supportsEncryptedFeaturesUsingConstructor() {
GBContext subject = new GBContext(
sampleUserAttributes,
encryptedFeaturesJson,
null,
encryptionKey,
isEnabled,
isQaMode,
Expand All @@ -140,7 +142,7 @@ void supportsEncryptedFeaturesUsingConstructor() {
String expectedFeaturesJson = "{\"greeting\":{\"defaultValue\":\"hello\",\"rules\":[{\"condition\":{\"country\":\"france\"},\"force\":\"bonjour\"},{\"condition\":{\"country\":\"mexico\"},\"force\":\"hola\"}]}}";

assertNotNull(subject);
assertEquals(expectedFeaturesJson.trim(), subject.getFeaturesJson().trim());
assertEquals(expectedFeaturesJson.trim(), subject.getFeatures().toString().trim());
}

@Test
Expand Down Expand Up @@ -171,8 +173,8 @@ void supportsEncryptedFeaturesUsingBuilder() {
String expectedFeaturesJson = "{\"greeting\":{\"defaultValue\":\"hello\",\"rules\":[{\"condition\":{\"country\":\"france\"},\"force\":\"bonjour\"},{\"condition\":{\"country\":\"mexico\"},\"force\":\"hola\"}]}}";

assertNotNull(subject);
assert subject.getFeaturesJson() != null;
assertEquals(expectedFeaturesJson.trim(), subject.getFeaturesJson().trim());
assert subject.getFeatures() != null;
assertEquals(expectedFeaturesJson.trim(), subject.getFeatures().toString().trim());
}

@Test
Expand All @@ -187,7 +189,7 @@ void whenEncryptionKeyInvalid_featuresStayEmpty() {
.encryptionKey(encryptionKey)
.build();

assertEquals("{}", subject.getFeaturesJson());
assertEquals("{}", subject.getFeatures().toString());
}

@Test
Expand All @@ -202,6 +204,6 @@ void whenEncryptedPayloadMalformed_featuresStayEmpty() {
.encryptionKey(encryptionKey)
.build();

assertEquals("{}", subject.getFeaturesJson());
assertEquals("{}", subject.getFeatures().toString());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ void getTestCases_returnsTestCasesAsJson() {
JsonObject testCases = TestCasesJsonHelper.getInstance().getTestCases();

assertNotNull(testCases);
assertEquals("0.6.0", testCases.get("specVersion").getAsString());
assertEquals("0.6.1", testCases.get("specVersion").getAsString());
}
}
55 changes: 54 additions & 1 deletion lib/src/test/resources/test-cases.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"specVersion": "0.6.0",
"specVersion": "0.6.1",
"evalCondition": [
[
"$not - pass",
Expand Down Expand Up @@ -2775,6 +2775,59 @@
"version": "1.2.3.4"
},
true
],
[
"$or pass but second condition fail",
{
"$or": [{ "foo": 1 }, { "bar": 1 }],
"baz": 2
},
{
"foo": 1,
"bar": 2,
"baz": 1
},
false
],
[
"$or and second condition both pass",
{
"$or": [{ "foo": 1 }, { "bar": 1 }],
"baz": 2
},
{
"foo": 1,
"bar": 2,
"baz": 2
},
true
],
[
"$and condition pass but $or fail",
{
"$and": [{ "foo": 1 }, { "bar": 1 }],
"$or": [{ "baz": 1 }, { "empty": 1 }]
},
{
"foo": 1,
"bar": 1,
"baz": 2
},
false
],
[
"$and and $or both pass",
{
"$and": [{ "foo": 1 }, { "bar": 1 }],
"$or": [{ "baz": 1 }, { "empty": 1 }]
},
{
"foo": 1,
"bar": 1,
"baz": 2,
"empty": 1
},
true
]
],
"hash": [
Expand Down

0 comments on commit e35a7de

Please sign in to comment.