Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: get all feature flags related to a distinct_id (4/4) #57

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
Prev Previous commit
Next Next commit
implement feature flag poller
miguelhrocha committed Jun 2, 2024
commit 75eb6ebb92c654d6df1fe4e23e2e8d291cb464fa
502 changes: 502 additions & 0 deletions posthog/src/main/java/com/posthog/java/FeatureFlagPoller.java

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions posthog/src/main/java/com/posthog/java/Getter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.posthog.java;

import org.json.JSONObject;

import java.util.Map;

interface Getter {

JSONObject get(String route, Map<String, String> headers);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.posthog.java;

public class InconclusiveMatchException extends Exception {
public InconclusiveMatchException(String message) {
super(message);
}
}
158 changes: 158 additions & 0 deletions posthog/src/main/java/com/posthog/java/flags/FeatureFlagParser.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package com.posthog.java.flags;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;

public class FeatureFlagParser {

public static FeatureFlags parse(JSONObject responseRaw) {
final List<FeatureFlag> flags = StreamSupport.stream(responseRaw.optJSONArray("flags").spliterator(), false)
.map(JSONObject.class::cast)
.map(FeatureFlagParser::parseFeatureFlag)
.collect(Collectors.toList());

return new FeatureFlags.Builder()
.flags(flags)
.groupTypeMapping(parseGroupTypeMapping(responseRaw.optJSONObject("group_type_mapping")))
.cohorts(parseCohorts(responseRaw.optJSONObject("cohorts")))
.build();
}

private static FeatureFlag parseFeatureFlag(JSONObject featureFlagRaw) throws JSONException {
if (featureFlagRaw == null)
return null;

final String key = featureFlagRaw.getString("key");
final int id = featureFlagRaw.getInt("id");
final int teamId = featureFlagRaw.getInt("team_id");

return new FeatureFlag.Builder(key, id, teamId)
.isSimpleFlag(featureFlagRaw.optBoolean("is_simple_flag"))
.rolloutPercentage(featureFlagRaw.optInt("rollout_percentage"))
.active(featureFlagRaw.optBoolean("active"))
.filter(parseFilter(featureFlagRaw.optJSONObject("filters")))
.ensureExperienceContinuity(featureFlagRaw.optBoolean("ensure_experience_continuity"))
.build();
}

private static FeatureFlagFilter parseFilter(JSONObject filterRaw) throws JSONException {
if (filterRaw == null)
return null;

return new FeatureFlagFilter.Builder()
.aggregationGroupTypeIndex(filterRaw.optInt("aggregation_group_type_index"))
.groups(parseFeatureFlagConditions(filterRaw.optJSONArray("groups")))
.multivariate(parseVariants(filterRaw.optJSONObject("multivariate")))
.payloads(filterRaw.optJSONObject("payloads").toMap())
.build();
}

private static List<FeatureFlagCondition> parseFeatureFlagConditions(JSONArray conditionsRaw) throws JSONException {
if (conditionsRaw == null)
return new ArrayList<>();

return StreamSupport.stream(conditionsRaw.spliterator(), false)
.map(JSONObject.class::cast)
.map(conditionRaw -> {
final List<FeatureFlagProperty> properties = StreamSupport.stream(conditionRaw.getJSONArray("properties").spliterator(), false)
.map(JSONObject.class::cast)
.map(FeatureFlagParser::parseFlagProperty)
.collect(Collectors.toList());
return parseFeatureFlagCondition(properties, conditionRaw);
})
.collect(Collectors.toList());
}

private static FeatureFlagProperty parseFlagProperty(JSONObject flagPropertyRaw) throws JSONException {
if (flagPropertyRaw == null)
return null;

final List<String> value = flagPropertyRaw.optJSONArray("value") != null
? StreamSupport.stream(flagPropertyRaw.getJSONArray("value").spliterator(), false)
.map(Object::toString)
.collect(Collectors.toList())
: Collections.singletonList(flagPropertyRaw.optString("value"));

return new FeatureFlagProperty.Builder(flagPropertyRaw.optString("key"))
.negation(flagPropertyRaw.optBoolean("negation", false))
.value(value)
.operator(flagPropertyRaw.optString("operator"))
.type(flagPropertyRaw.optString("type"))
.build();
}

private static FeatureFlagCondition parseFeatureFlagCondition(final List<FeatureFlagProperty> properties, JSONObject conditionRaw) throws JSONException {
if (conditionRaw == null)
return null;

return new FeatureFlagCondition.Builder()
.properties(properties)
.rolloutPercentage(conditionRaw.optInt("rollout_percentage"))
.variant(conditionRaw.optString("variant"))
.build();
}

private static FeatureFlagVariants parseVariants(JSONObject variantsRaw) throws JSONException {
if (variantsRaw == null)
return null;

return StreamSupport.stream(variantsRaw.getJSONArray("variants").spliterator(), false)
.map(JSONObject.class::cast)
.map(FeatureFlagParser::parseFlagVariant)
.collect(Collectors.collectingAndThen(Collectors.toList(), variants -> new FeatureFlagVariants.Builder().variants(variants).build()));
}

private static FeatureFlagVariant parseFlagVariant(JSONObject flagVariantRaw) throws JSONException {
if (flagVariantRaw == null)
return null;

return new FeatureFlagVariant.Builder(flagVariantRaw.getString("key"), flagVariantRaw.getString("name"))
.rolloutPercentage(flagVariantRaw.optInt("rollout_percentage"))
.build();
}

private static Map<String, FeatureFlagPropertyGroup> parseCohorts(JSONObject cohortsRaw) throws JSONException {
if (cohortsRaw == null)
return new HashMap<>();

return cohortsRaw.keySet()
.stream()
.collect(Collectors.toMap(key -> key, key -> parsePropertyGroup(cohortsRaw.getJSONObject(key))));
}

private static FeatureFlagPropertyGroup parsePropertyGroup(JSONObject propertyGroupRaw) throws JSONException {
final List<Object> values = new ArrayList<>();
final JSONArray valuesJson = propertyGroupRaw.getJSONArray("values");

for (int i = 0; i < valuesJson.length(); i++) {
Object value = valuesJson.get(i);
if (value instanceof JSONObject) {
final JSONObject possibleChild = (JSONObject) value;
if (possibleChild.has("type")) {
values.add(parsePropertyGroup(possibleChild));
}
} else {
values.add(value);
}
}

return new FeatureFlagPropertyGroup.Builder()
.type(propertyGroupRaw.optString("type"))
.values(values)
.build();
}

private static Map<String, String> parseGroupTypeMapping(JSONObject groupTypeMappingRaw) throws JSONException {
if (groupTypeMappingRaw == null)
return new HashMap<>();

return groupTypeMappingRaw.keySet()
.stream()
.collect(Collectors.toMap(key -> key, groupTypeMappingRaw::getString));
}
}
30 changes: 30 additions & 0 deletions posthog/src/main/java/com/posthog/java/flags/hash/Hasher.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.posthog.java.flags.hash;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class Hasher {
private static final long LONG_SCALE = 0xfffffffffffffffL;

public static double hash(String key, String distinctId, String salt) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-1");
digest.update((key + "." + distinctId + salt).getBytes(StandardCharsets.UTF_8));
byte[] hash = digest.digest();
String hexString = bytesToHex(hash).substring(0, 15);
long value = Long.parseLong(hexString, 16);
return (double) value / LONG_SCALE;
} catch (NoSuchAlgorithmException | NumberFormatException e) {
throw new RuntimeException("Hashing error: " + e.getMessage(), e);
}
}

private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
74 changes: 74 additions & 0 deletions posthog/src/test/java/com/posthog/java/FeatureFlagPollerTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.posthog.java;

import com.posthog.java.TestGetter;
import com.posthog.java.flags.FeatureFlag;
import com.posthog.java.flags.FeatureFlagConfig;
import org.junit.Before;
import org.junit.Test;

import java.util.List;
import java.util.Optional;

import static org.junit.Assert.*;

public class FeatureFlagPollerTest {

private FeatureFlagPoller sut;

@Before
public void setUp() {
TestGetter testGetter = new TestGetter();
sut = new FeatureFlagPoller.Builder("", "", testGetter)
.build();

sut.poll();
}

@Test
public void shouldRetrieveAllFlags() {
final List<FeatureFlag> flags = sut.getFeatureFlags();
assertEquals(1, flags.size());
assertEquals("java-feature-flag", flags.get(0).getKey());
assertEquals(1000, flags.get(0).getId());
assertEquals(20000, flags.get(0).getTeamId());
}

@Test
public void shouldReturnTrueWhenFeatureFlagIsEnabledForUser() {
FeatureFlagConfig config = new FeatureFlagConfig.Builder("java-feature-flag", "id-1")
.build();

final boolean enabled = sut.isFeatureFlagEnabled(config);
assertTrue(enabled);
}

@Test
public void shouldReturnFalseWhenFeatureFlagIsDisabledForUser() {
FeatureFlagConfig config = new FeatureFlagConfig.Builder("java-feature-flag", "some-id")
.build();

final boolean enabled = sut.isFeatureFlagEnabled(config);
assertFalse(enabled);
}

@Test
public void shouldReturnFeatureFlagVariant() {
FeatureFlagConfig config = new FeatureFlagConfig.Builder("java-feature-flag", "id-1")
.build();

final Optional<String> variant = sut.getFeatureFlagVariant(config);
assertTrue(variant.isPresent());
}

@Test
public void shouldBeAbleToReturnTheFullFeatureFlag() {
FeatureFlagConfig config = new FeatureFlagConfig.Builder("java-feature-flag", "id-1")
.build();

final Optional<FeatureFlag> flag = sut.getFeatureFlag(config);
assertTrue(flag.isPresent());
assertEquals("java-feature-flag", flag.get().getKey());
assertEquals(1000, flag.get().getId());
assertEquals(20000, flag.get().getTeamId());
}
}
90 changes: 90 additions & 0 deletions posthog/src/test/java/com/posthog/java/TestGetter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.posthog.java;

import org.json.JSONObject;

import java.util.Map;

public class TestGetter implements Getter {

@Override
public JSONObject get(String route, Map<String, String> headers) {
String jsonString = "{\n"
+ " \"flags\": [\n"
+ " {\n"
+ " \"id\": 1000,\n"
+ " \"team_id\": 20000,\n"
+ " \"name\": \"\",\n"
+ " \"key\": \"java-feature-flag\",\n"
+ " \"filters\": {\n"
+ " \"groups\": [\n"
+ " {\n"
+ " \"variant\": \"variant-2\",\n"
+ " \"properties\": [\n"
+ " {\n"
+ " \"key\": \"id\",\n"
+ " \"type\": \"cohort\",\n"
+ " \"value\": 17231\n"
+ " }\n"
+ " ],\n"
+ " \"rollout_percentage\": 39\n"
+ " },\n"
+ " {\n"
+ " \"variant\": null,\n"
+ " \"properties\": [\n"
+ " {\n"
+ " \"key\": \"distinct_id\",\n"
+ " \"type\": \"person\",\n"
+ " \"value\": [\n"
+ " \"id-1\"\n"
+ " ],\n"
+ " \"operator\": \"exact\"\n"
+ " }\n"
+ " ],\n"
+ " \"rollout_percentage\": 100\n"
+ " },\n"
+ " {\n"
+ " \"variant\": \"variant-2\",\n"
+ " \"properties\": [\n"
+ " {\n"
+ " \"key\": \"distinct_id\",\n"
+ " \"type\": \"person\",\n"
+ " \"value\": \"a-value\",\n"
+ " \"operator\": \"icontains\"\n"
+ " }\n"
+ " ],\n"
+ " \"rollout_percentage\": 41\n"
+ " }\n"
+ " ],\n"
+ " \"payloads\": {\n"
+ " \"variant-1\": \"{\\\"something\\\": 1}\",\n"
+ " \"variant-2\": \"1\"\n"
+ " },\n"
+ " \"multivariate\": {\n"
+ " \"variants\": [\n"
+ " {\n"
+ " \"key\": \"variant-1\",\n"
+ " \"name\": \"\",\n"
+ " \"rollout_percentage\": 100\n"
+ " },\n"
+ " {\n"
+ " \"key\": \"variant-2\",\n"
+ " \"name\": \"with description\",\n"
+ " \"rollout_percentage\": 0\n"
+ " }\n"
+ " ]\n"
+ " }\n"
+ " },\n"
+ " \"deleted\": false,\n"
+ " \"active\": true,\n"
+ " \"ensure_experience_continuity\": false\n"
+ " }\n"
+ " ],\n"
+ " \"group_type_mapping\": {},\n"
+ " \"cohorts\": {}\n"
+ "}";


return new JSONObject(jsonString);
}

}
Loading