diff --git a/build.gradle b/build.gradle index 0361d59b9..30f6c0d05 100644 --- a/build.gradle +++ b/build.gradle @@ -19,20 +19,21 @@ repositories { allprojects { group = 'com.launchdarkly' - version = "1.0.1" + version = "2.0.0-SNAPSHOT" sourceCompatibility = 1.7 targetCompatibility = 1.7 } dependencies { - compile "org.apache.httpcomponents:httpclient:4.3.6" - compile "org.apache.httpcomponents:httpclient-cache:4.3.6" - compile "commons-codec:commons-codec:1.5" - compile "com.google.code.gson:gson:2.2.4" + compile "org.apache.httpcomponents:httpclient:4.5.2" + compile "org.apache.httpcomponents:httpclient-cache:4.5.2" + compile "commons-codec:commons-codec:1.10" + compile "com.google.code.gson:gson:2.6.2" compile "com.google.guava:guava:19.0" - compile "org.slf4j:slf4j-api:1.7.7" + compile "joda-time:joda-time:2.9.3" + compile "org.slf4j:slf4j-api:1.7.21" compile group: "com.launchdarkly", name: "okhttp-eventsource", version: "0.2.1", changing: true - compile "redis.clients:jedis:2.8.0" + compile "redis.clients:jedis:2.8.1" testCompile "org.easymock:easymock:3.4" testCompile 'junit:junit:4.12' testRuntime "ch.qos.logback:logback-classic:1.1.7" @@ -56,7 +57,7 @@ buildscript { mavenLocal() } dependencies { - classpath 'org.ajoberstar:gradle-git:0.12.0' + classpath 'org.ajoberstar:gradle-git:1.5.0-rc.1' classpath 'com.github.jengelman.gradle.plugins:shadow:1.2.3' } } diff --git a/src/main/java/com/launchdarkly/client/Clause.java b/src/main/java/com/launchdarkly/client/Clause.java new file mode 100644 index 000000000..335549d25 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/Clause.java @@ -0,0 +1,84 @@ +package com.launchdarkly.client; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +class Clause { + private final static Logger logger = LoggerFactory.getLogger(Clause.class); + + private String attribute; + private Operator op; + private List values; //interpreted as an OR of values + private boolean negate; + + boolean matchesUser(LDUser user) { + JsonElement userValue = valueOf(user, attribute); + if (userValue == null) { + return false; + } + + if (userValue.isJsonArray()) { + JsonArray array = userValue.getAsJsonArray(); + for (JsonElement jsonElement : array) { + if (!jsonElement.isJsonPrimitive()) { + logger.error("Invalid custom attribute value in user object: " + jsonElement); + return false; + } + if (matchAny(jsonElement.getAsJsonPrimitive())) { + return maybeNegate(true); + } + } + return maybeNegate(false); + } else if (userValue.isJsonPrimitive()) { + return maybeNegate(matchAny(userValue.getAsJsonPrimitive())); + } + logger.warn("Got unexpected user attribute type: " + userValue.getClass().getName() + " for user key: " + + user.getKey() + " and attribute: " + attribute); + return false; + } + + private boolean matchAny(JsonPrimitive userValue) { + for (JsonPrimitive v : values) { + if (op.apply(userValue, v)) { + return true; + } + } + return false; + } + + private boolean maybeNegate(boolean b) { + if (negate) + return !b; + else + return b; + } + + static JsonElement valueOf(LDUser user, String attribute) { + switch (attribute) { + case "key": + return user.getKey(); + case "ip": + return user.getIp(); + case "country": + return user.getCountry(); + case "email": + return user.getEmail(); + case "firstName": + return user.getFirstName(); + case "lastName": + return user.getLastName(); + case "avatar": + return user.getAvatar(); + case "name": + return user.getName(); + case "anonymous": + return user.getAnonymous(); + } + return user.getCustom(attribute); + } +} diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java new file mode 100644 index 000000000..6488b5372 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -0,0 +1,193 @@ +package com.launchdarkly.client; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.reflect.TypeToken; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Type; +import java.util.*; + +class FeatureFlag { + private final static Logger logger = LoggerFactory.getLogger(FeatureFlag.class); + + private static final Gson gson = new Gson(); + private static final Type mapType = new TypeToken>() { + }.getType(); + + private final String key; + private final int version; + private final boolean on; + private final List prerequisites; + private final String salt; + private final List targets; + private final List rules; + private final Rule fallthrough; + private final Integer offVariation; //optional + private final List variations; + private final boolean deleted; + + static FeatureFlag fromJson(String json) { + return gson.fromJson(json, FeatureFlag.class); + } + + static Map fromJsonMap(String json) { + return gson.fromJson(json, mapType); + } + + FeatureFlag(String key, int version, boolean on, List prerequisites, String salt, List targets, List rules, Rule fallthrough, Integer offVariation, List variations, boolean deleted) { + this.key = key; + this.version = version; + this.on = on; + this.prerequisites = prerequisites; + this.salt = salt; + this.targets = targets; + this.rules = rules; + this.fallthrough = fallthrough; + this.offVariation = offVariation; + this.variations = variations; + this.deleted = deleted; + } + + Integer getOffVariation() { + return this.offVariation; + } + + JsonElement getOffVariationValue() { + if (offVariation != null && offVariation < variations.size()) { + return variations.get(offVariation); + } + return null; + } + + EvalResult evaluate(LDUser user, FeatureStore featureStore) { + if (user == null || user.getKey() == null) { + return null; + } + List prereqEvents = new ArrayList<>(); + Set visited = new HashSet<>(); + return evaluate(user, featureStore, prereqEvents, visited); + } + + private EvalResult evaluate(LDUser user, FeatureStore featureStore, List events, Set visited) { + for (Prerequisite prereq : prerequisites) { + visited.add(key); + if (visited.contains(prereq.getKey())) { + logger.error("Prerequisite cycle detected when evaluating feature flag: " + key); + return null; + } + FeatureFlag prereqFeatureFlag = featureStore.get(prereq.getKey()); + if (prereqFeatureFlag == null) { + logger.error("Could not retrieve prerequisite flag: " + prereq.getKey() + " when evaluating: " + key); + return null; + } + JsonElement prereqValue; + if (prereqFeatureFlag.isOn()) { + EvalResult prereqEvalResult = prereqFeatureFlag.evaluate(user, featureStore, events, visited); + if (prereqEvalResult == null) { + return null; + } + prereqValue = prereqEvalResult.value; + visited = prereqEvalResult.visitedFeatureKeys; + events = prereqEvalResult.prerequisiteEvents; + events.add(new FeatureRequestEvent(prereqFeatureFlag.getKey(), user, prereqValue, null)); + if (prereqValue == null || !prereqValue.equals(prereqFeatureFlag.getVariation(prereq.getVariation()))) { + return new EvalResult(null, events, visited); + } + } else { + return null; + } + } + return new EvalResult(getVariation(evaluateIndex(user)), events, visited); + } + + private Integer evaluateIndex(LDUser user) { + // Check to see if targets match + for (Target target : targets) { + for (String v : target.getValues()) { + if (v.equals(user.getKey().getAsString())) { + return target.getVariation(); + } + } + } + + // Now walk through the rules and see if any match + for (Rule rule : rules) { + if (rule.matchesUser(user)) { + return rule.variationIndexForUser(user, key, salt); + } + } + + // Walk through the fallthrough and see if it matches + return fallthrough.variationIndexForUser(user, key, salt); + } + + private JsonElement getVariation(Integer index) { + if (index == null || index >= variations.size()) { + return null; + } else { + return variations.get(index); + } + } + + int getVersion() { + return version; + } + + String getKey() { + return key; + } + + boolean isDeleted() { + return deleted; + } + + boolean isOn() { + return on; + } + + List getPrerequisites() { + return prerequisites; + } + + String getSalt() { + return salt; + } + + List getTargets() { + return targets; + } + + List getRules() { + return rules; + } + + Rule getFallthrough() { + return fallthrough; + } + + List getVariations() { + return variations; + } + + static class EvalResult { + private JsonElement value; + private List prerequisiteEvents; + private Set visitedFeatureKeys; + + private EvalResult(JsonElement value, List prerequisiteEvents, Set visitedFeatureKeys) { + this.value = value; + this.prerequisiteEvents = prerequisiteEvents; + this.visitedFeatureKeys = visitedFeatureKeys; + } + + JsonElement getValue() { + return value; + } + + List getPrerequisiteEvents() { + return prerequisiteEvents; + } + } +} diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java b/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java new file mode 100644 index 000000000..0369fd735 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java @@ -0,0 +1,94 @@ +package com.launchdarkly.client; + +import com.google.gson.JsonElement; + +import java.util.List; + +class FeatureFlagBuilder { + private String key; + private int version; + private boolean on; + private List prerequisites; + private String salt; + private List targets; + private List rules; + private Rule fallthrough; + private Integer offVariation; + private List variations; + private boolean deleted; + + FeatureFlagBuilder(String key) { + this.key = key; + } + + FeatureFlagBuilder(FeatureFlag f) { + if (f != null) { + this.key = f.getKey(); + this.version = f.getVersion(); + this.on = f.isOn(); + this.prerequisites = f.getPrerequisites(); + this.salt = f.getSalt(); + this.targets = f.getTargets(); + this.rules = f.getRules(); + this.fallthrough = f.getFallthrough(); + this.offVariation = f.getOffVariation(); + this.variations = f.getVariations(); + this.deleted = f.isDeleted(); + } + } + + + FeatureFlagBuilder version(int version) { + this.version = version; + return this; + } + + FeatureFlagBuilder on(boolean on) { + this.on = on; + return this; + } + + FeatureFlagBuilder prerequisites(List prerequisites) { + this.prerequisites = prerequisites; + return this; + } + + FeatureFlagBuilder salt(String salt) { + this.salt = salt; + return this; + } + + FeatureFlagBuilder targets(List targets) { + this.targets = targets; + return this; + } + + FeatureFlagBuilder rules(List rules) { + this.rules = rules; + return this; + } + + FeatureFlagBuilder fallthrough(Rule fallthrough) { + this.fallthrough = fallthrough; + return this; + } + + FeatureFlagBuilder offVariation(Integer offVariation) { + this.offVariation = offVariation; + return this; + } + + FeatureFlagBuilder variations(List variations) { + this.variations = variations; + return this; + } + + FeatureFlagBuilder deleted(boolean deleted) { + this.deleted = deleted; + return this; + } + + FeatureFlag build() { + return new FeatureFlag(key, version, on, prerequisites, salt, targets, rules, fallthrough, offVariation, variations, deleted); + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/FeatureRep.java b/src/main/java/com/launchdarkly/client/FeatureRep.java deleted file mode 100644 index 116a2ed9c..000000000 --- a/src/main/java/com/launchdarkly/client/FeatureRep.java +++ /dev/null @@ -1,183 +0,0 @@ -package com.launchdarkly.client; - -import org.apache.commons.codec.digest.DigestUtils; - -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -class FeatureRep { - String name; - String key; - String salt; - boolean on; - List> variations; - boolean deleted; - int version; - - private static final float long_scale = (float)0xFFFFFFFFFFFFFFFL; - - public FeatureRep() { - - } - - @Override - public String toString() { - return "FeatureRep{" + - "name='" + name + '\'' + - ", key='" + key + '\'' + - ", salt='" + salt + '\'' + - ", on=" + on + - ", variations=" + variations + - ", deleted=" + deleted + - ", version=" + version + - '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - FeatureRep that = (FeatureRep) o; - - if (on != that.on) return false; - if (deleted != that.deleted) return false; - if (version != that.version) return false; - if (!name.equals(that.name)) return false; - if (!key.equals(that.key)) return false; - if (!salt.equals(that.salt)) return false; - return variations.equals(that.variations); - - } - - @Override - public int hashCode() { - int result = name.hashCode(); - result = 31 * result + key.hashCode(); - result = 31 * result + salt.hashCode(); - result = 31 * result + (on ? 1 : 0); - result = 31 * result + variations.hashCode(); - result = 31 * result + (deleted ? 1 : 0); - result = 31 * result + version; - return result; - } - - FeatureRep(Builder b) { - this.name = b.name; - this.key = b.key; - this.salt = b.salt; - this.on = b.on; - this.deleted = b.deleted; - this.version = b.version; - this.variations = new ArrayList<>(b.variations); - } - - private Float paramForUser(LDUser user) { - String idHash; - String hash; - - if (user.getKey() != null) { - idHash = user.getKey().getAsString(); - } - else { - return null; - } - - if (user.getSecondary() != null) { - idHash += "." + user.getSecondary(); - } - - hash = DigestUtils.shaHex(key + "." + salt + "." + idHash).substring(0,15); - - long longVal = Long.parseLong(hash, 16); - - float result = (float) longVal / long_scale; - - return result; - } - - public E evaluate(LDUser user) { - if (!on || user == null) { - return null; - } - - Float param = paramForUser(user); - - if (param == null) { - return null; - } - else { - for (Variation variation: variations) { - if (variation.matchUser(user)) { - return variation.value; - } - } - - for (Variation variation : variations) { - if (variation.matchTarget(user)) { - return variation.value; - } - } - - float sum = 0.0f; - for (Variation variation : variations) { - sum += ((float)variation.weight) / 100.0; - - if (param < sum) { - return variation.value; - } - - } - } - return null; - } - - static class Builder { - private String name; - private String key; - private boolean on; - private String salt; - private boolean deleted; - private int version; - private List> variations; - - Builder(String name, String key) { - this.on = true; - this.name = name; - this.key = key; - this.salt = UUID.randomUUID().toString(); - this.variations = new ArrayList<>(); - } - - Builder salt(String s) { - this.salt = s; - return this; - } - - Builder on(boolean b) { - this.on = b; - return this; - } - - Builder variation(Variation v) { - variations.add(v); - return this; - } - - Builder deleted(boolean d) { - this.deleted = d; - return this; - } - - Builder version(int v) { - this.version = v; - return this; - } - - FeatureRep build() { - return new FeatureRep<>(this); - } - - } -} diff --git a/src/main/java/com/launchdarkly/client/FeatureRequestEvent.java b/src/main/java/com/launchdarkly/client/FeatureRequestEvent.java index ad4f293cb..f7dcbdc37 100644 --- a/src/main/java/com/launchdarkly/client/FeatureRequestEvent.java +++ b/src/main/java/com/launchdarkly/client/FeatureRequestEvent.java @@ -1,13 +1,14 @@ package com.launchdarkly.client; +import com.google.gson.JsonElement; import com.google.gson.annotations.SerializedName; -class FeatureRequestEvent extends Event { - E value; +class FeatureRequestEvent extends Event { + JsonElement value; @SerializedName("default") - E defaultVal; + JsonElement defaultVal; - FeatureRequestEvent(String key, LDUser user, E value, E defaultVal) { + FeatureRequestEvent(String key, LDUser user, JsonElement value, JsonElement defaultVal) { super("feature", key, user); this.value = value; this.defaultVal = defaultVal; diff --git a/src/main/java/com/launchdarkly/client/FeatureRequestor.java b/src/main/java/com/launchdarkly/client/FeatureRequestor.java index 2dc949b50..b2b937463 100644 --- a/src/main/java/com/launchdarkly/client/FeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/FeatureRequestor.java @@ -1,7 +1,5 @@ package com.launchdarkly.client; -import com.google.gson.Gson; -import com.google.gson.reflect.TypeToken; import org.apache.http.HttpStatus; import org.apache.http.client.cache.CacheResponseStatus; import org.apache.http.client.cache.HttpCacheContext; @@ -17,7 +15,6 @@ import org.slf4j.LoggerFactory; import java.io.IOException; -import java.lang.reflect.Type; import java.util.Map; class FeatureRequestor { @@ -58,11 +55,10 @@ protected CloseableHttpClient createClient() { return client; } - Map> makeAllRequest(boolean latest) throws IOException { - Gson gson = new Gson(); + Map makeAllRequest(boolean latest) throws IOException { HttpCacheContext context = HttpCacheContext.create(); - String resource = latest ? "/api/eval/latest-features" : "/api/eval/features"; + String resource = latest ? "/sdk/latest-flags" : "/sdk/flags"; HttpGet request = config.getRequest(apiKey, resource); @@ -75,13 +71,10 @@ Map> makeAllRequest(boolean latest) throws IOException { handleResponseStatus(response.getStatusLine().getStatusCode(), null); - Type type = new TypeToken>>() {}.getType(); - String json = EntityUtils.toString(response.getEntity()); logger.debug("Got response: " + response.toString()); logger.debug("Got Response body: " + json); - Map> result = gson.fromJson(json, type); - return result; + return FeatureFlag.fromJsonMap(json); } finally { try { @@ -126,13 +119,12 @@ void handleResponseStatus(int status, String featureKey) throws IOException { } else { logger.error("Unexpected status code: " + status); } - throw new IOException("Failed to fetch flag"); + throw new IOException("Failed to fetch flags"); } } - FeatureRep makeRequest(String featureKey, boolean latest) throws IOException { - Gson gson = new Gson(); + FeatureFlag makeRequest(String featureKey, boolean latest) throws IOException { HttpCacheContext context = HttpCacheContext.create(); String resource = latest ? "/api/eval/latest-features/" : "/api/eval/features/"; @@ -147,10 +139,7 @@ FeatureRep makeRequest(String featureKey, boolean latest) throws IOExcept handleResponseStatus(response.getStatusLine().getStatusCode(), featureKey); - Type type = new TypeToken>() {}.getType(); - - FeatureRep result = gson.fromJson(EntityUtils.toString(response.getEntity()), type); - return result; + return FeatureFlag.fromJson(EntityUtils.toString(response.getEntity())); } finally { try { diff --git a/src/main/java/com/launchdarkly/client/FeatureStore.java b/src/main/java/com/launchdarkly/client/FeatureStore.java index ff8f90e4d..5f261afb6 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStore.java +++ b/src/main/java/com/launchdarkly/client/FeatureStore.java @@ -4,7 +4,7 @@ import java.util.Map; /** - * A thread-safe, versioned store for {@link com.launchdarkly.client.FeatureRep} objects. + * A thread-safe, versioned store for {@link FeatureFlag} objects. * Implementations should permit concurrent access and updates. * * Delete and upsert requests are versioned-- if the version number in the request is less than @@ -17,16 +17,16 @@ public interface FeatureStore extends Closeable { /** * - * Returns the {@link com.launchdarkly.client.FeatureRep} to which the specified key is mapped, or - * null if the key is not associated or the associated {@link com.launchdarkly.client.FeatureRep} has + * Returns the {@link FeatureFlag} to which the specified key is mapped, or + * null if the key is not associated or the associated {@link FeatureFlag} has * been deleted. * - * @param key the key whose associated {@link com.launchdarkly.client.FeatureRep} is to be returned - * @return the {@link com.launchdarkly.client.FeatureRep} to which the specified key is mapped, or - * null if the key is not associated or the associated {@link com.launchdarkly.client.FeatureRep} has + * @param key the key whose associated {@link FeatureFlag} is to be returned + * @return the {@link FeatureFlag} to which the specified key is mapped, or + * null if the key is not associated or the associated {@link FeatureFlag} has * been deleted. */ - FeatureRep get(String key); + FeatureFlag get(String key); /** * Returns a {@link java.util.Map} of all associated features. @@ -34,7 +34,7 @@ public interface FeatureStore extends Closeable { * * @return a map of all associated features. */ - Map> all(); + Map all(); /** * Initializes (or re-initializes) the store with the specified set of features. Any existing entries @@ -45,7 +45,7 @@ public interface FeatureStore extends Closeable { * * @param features the features to set the store */ - void init(Map> features); + void init(Map features); /** * @@ -64,7 +64,7 @@ public interface FeatureStore extends Closeable { * @param key * @param feature */ - void upsert(String key, FeatureRep feature); + void upsert(String key, FeatureFlag feature); /** * Returns true if this store has been initialized diff --git a/src/main/java/com/launchdarkly/client/IdentifyEvent.java b/src/main/java/com/launchdarkly/client/IdentifyEvent.java index 3ee869ccd..6ccfa70b5 100644 --- a/src/main/java/com/launchdarkly/client/IdentifyEvent.java +++ b/src/main/java/com/launchdarkly/client/IdentifyEvent.java @@ -3,6 +3,6 @@ class IdentifyEvent extends Event { IdentifyEvent(LDUser user) { - super("identify", user.getKey().getAsString(), user); + super("identify", user.getKeyAsString(), user); } } diff --git a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java b/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java index a4837955f..6ce864c69 100644 --- a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java @@ -6,37 +6,35 @@ import java.util.concurrent.locks.ReentrantReadWriteLock; /** - * A thread-safe, versioned store for {@link com.launchdarkly.client.FeatureRep} objects based on a + * A thread-safe, versioned store for {@link FeatureFlag} objects based on a * {@link HashMap} - * */ public class InMemoryFeatureStore implements FeatureStore { private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); - private final Map> features = new HashMap<>(); + private final Map features = new HashMap<>(); private volatile boolean initialized = false; /** - * - * Returns the {@link com.launchdarkly.client.FeatureRep} to which the specified key is mapped, or - * null if the key is not associated or the associated {@link com.launchdarkly.client.FeatureRep} has + * Returns the {@link FeatureFlag} to which the specified key is mapped, or + * null if the key is not associated or the associated {@link FeatureFlag} has * been deleted. * - * @param key the key whose associated {@link com.launchdarkly.client.FeatureRep} is to be returned - * @return the {@link com.launchdarkly.client.FeatureRep} to which the specified key is mapped, or - * null if the key is not associated or the associated {@link com.launchdarkly.client.FeatureRep} has + * @param key the key whose associated {@link FeatureFlag} is to be returned + * @return the {@link FeatureFlag} to which the specified key is mapped, or + * null if the key is not associated or the associated {@link FeatureFlag} has * been deleted. */ @Override - public FeatureRep get(String key) { + public FeatureFlag get(String key) { try { lock.readLock().lock(); - FeatureRep rep = features.get(key); - if (rep == null || rep.deleted) { + FeatureFlag featureFlag = features.get(key); + if (featureFlag == null || featureFlag.isDeleted()) { return null; } - return rep; + return featureFlag; } finally { lock.readLock().unlock(); } @@ -45,17 +43,16 @@ public FeatureRep get(String key) { /** * Returns a {@link java.util.Map} of all associated features. * - * * @return a map of all associated features. */ @Override - public Map> all() { + public Map all() { try { lock.readLock().lock(); - Map> fs = new HashMap<>(); + Map fs = new HashMap<>(); - for (Map.Entry> entry : features.entrySet()) { - if (!entry.getValue().deleted) { + for (Map.Entry entry : features.entrySet()) { + if (!entry.getValue().isDeleted()) { fs.put(entry.getKey(), entry.getValue()); } } @@ -73,7 +70,7 @@ public Map> all() { * @param features the features to set the store */ @Override - public void init(Map> features) { + public void init(Map features) { try { lock.writeLock().lock(); this.features.clear(); @@ -85,25 +82,27 @@ public void init(Map> features) { } /** - * * Deletes the feature associated with the specified key, if it exists and its version * is less than or equal to the specified version. * - * @param key the key of the feature to be deleted + * @param key the key of the feature to be deleted * @param version the version for the delete operation */ @Override public void delete(String key, int version) { try { lock.writeLock().lock(); - FeatureRep f = features.get(key); - if (f != null && f.version < version) { - f.deleted = true; - f.version = version; - features.put(key, f); - } - else if (f == null) { - f = new FeatureRep.Builder(key, key).deleted(true).version(version).build(); + FeatureFlag f = features.get(key); + if (f != null && f.getVersion() < version) { + FeatureFlagBuilder newBuilder = new FeatureFlagBuilder(f); + newBuilder.on(false); + newBuilder.version(version); + features.put(key, newBuilder.build()); + } else if (f == null) { + f = new FeatureFlagBuilder(key) + .deleted(true) + .version(version) + .build(); features.put(key, f); } } finally { @@ -119,16 +118,15 @@ else if (f == null) { * @param feature */ @Override - public void upsert(String key, FeatureRep feature) { + public void upsert(String key, FeatureFlag feature) { try { lock.writeLock().lock(); - FeatureRep old = features.get(key); + FeatureFlag old = features.get(key); - if (old == null || old.version < feature.version) { + if (old == null || old.getVersion() < feature.getVersion()) { features.put(key, feature); } - } - finally { + } finally { lock.writeLock().unlock(); } } @@ -145,11 +143,11 @@ public boolean initialized() { /** * Does nothing; this class does not have any resources to release + * * @throws IOException */ @Override - public void close() throws IOException - { + public void close() throws IOException { return; } } diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 97d7d5dce..940b53093 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -3,6 +3,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; import org.apache.http.annotation.ThreadSafe; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,7 +36,7 @@ public class LDClient implements Closeable { * Creates a new client instance that connects to LaunchDarkly with the default configuration. In most * cases, you should use this constructor. * - * @param apiKey the API key for your account + * @param apiKey the API key for your account */ public LDClient(String apiKey) { this(apiKey, LDConfig.DEFAULT); @@ -45,8 +46,8 @@ public LDClient(String apiKey) { * Creates a new client to connect to LaunchDarkly with a custom configuration. This constructor * can be used to configure advanced client features, such as customizing the LaunchDarkly base URL. * - * @param apiKey the API key for your account - * @param config a client configuration object + * @param apiKey the API key for your account + * @param config a client configuration object */ public LDClient(String apiKey, LDConfig config) { this.config = config; @@ -114,8 +115,8 @@ protected PollingProcessor createPollingProcessor(LDConfig config) { * Tracks that a user performed an event. * * @param eventName the name of the event - * @param user the user that performed the event - * @param data a JSON object containing additional data associated with the event + * @param user the user that performed the event + * @param data a JSON object containing additional data associated with the event */ public void track(String eventName, LDUser user, JsonElement data) { if (isOffline()) { @@ -131,7 +132,7 @@ public void track(String eventName, LDUser user, JsonElement data) { * Tracks that a user performed an event. * * @param eventName the name of the event - * @param user the user that performed the event + * @param user the user that performed the event */ public void track(String eventName, LDUser user) { if (isOffline()) { @@ -142,6 +143,7 @@ public void track(String eventName, LDUser user) { /** * Register the user + * * @param user the user to register */ public void identify(LDUser user) { @@ -154,11 +156,11 @@ public void identify(LDUser user) { } } - private void sendFlagRequestEvent(String featureKey, LDUser user, boolean value, boolean defaultValue) { + private void sendFlagRequestEvent(String featureKey, LDUser user, JsonElement value, JsonElement defaultValue) { if (isOffline()) { return; } - boolean processed = eventProcessor.sendEvent(new FeatureRequestEvent<>(featureKey, user, value, defaultValue)); + boolean processed = eventProcessor.sendEvent(new FeatureRequestEvent(featureKey, user, value, defaultValue)); if (!processed) { logger.warn("Exceeded event queue capacity. Increase capacity to avoid dropping events."); } @@ -166,10 +168,10 @@ private void sendFlagRequestEvent(String featureKey, LDUser user, boolean value, } /** - * Calculates the value of a feature flag for a given user. + * Calculates the boolean value of a feature flag for a given user. * - * @param featureKey the unique featureKey for the feature flag - * @param user the end user requesting the flag + * @param featureKey the unique featureKey for the feature flag + * @param user the end user requesting the flag * @param defaultValue the default value of the flag * @return whether or not the flag should be enabled, or {@code defaultValue} if the flag is disabled in the LaunchDarkly control panel * @deprecated As of version 0.7.0, renamed to {@link #toggle(String, LDUser, boolean)} @@ -179,14 +181,15 @@ public boolean getFlag(String featureKey, LDUser user, boolean defaultValue) { } /** - * Returns a map from feature flag keys to boolean feature flag values for a given user. The map will contain {@code null} - * entries for any flags that are off. If the client is offline or has not been initialized, a {@code null} map will be returned. + * Returns a map from feature flag keys to Boolean feature flag values for a given user. The map will contain {@code null} + * entries for any flags that are off or for any feature flags with non-boolean variations. If the client is offline or + * has not been initialized, a {@code null} map will be returned. * This method will not send analytics events back to LaunchDarkly. - * + *

* The most common use case for this method is to bootstrap a set of client-side feature flags from a back-end service. * * @param user the end user requesting the feature flags - * @return a map from feature flag keys to boolean feature flag values for the specified user + * @return a map from feature flag keys to JsonElement values for the specified user */ public Map allFlags(LDUser user) { if (isOffline()) { @@ -197,66 +200,144 @@ public Map allFlags(LDUser user) { return null; } - Map> flags = this.config.featureStore.all(); + Map flags = this.config.featureStore.all(); Map result = new HashMap<>(); - for (String key: flags.keySet()) { - result.put(key, evaluate(key, user, null)); - } + for (String key : flags.keySet()) { + JsonElement evalResult = evaluate(key, user, null); + if (evalResult.isJsonPrimitive() && evalResult.getAsJsonPrimitive().isBoolean()) { + result.put(key, evalResult.getAsBoolean()); + } + } return result; } /** * Calculates the value of a feature flag for a given user. * - * @param featureKey the unique featureKey for the feature flag - * @param user the end user requesting the flag + * @param featureKey the unique featureKey for the feature flag + * @param user the end user requesting the flag * @param defaultValue the default value of the flag * @return whether or not the flag should be enabled, or {@code defaultValue} if the flag is disabled in the LaunchDarkly control panel */ public boolean toggle(String featureKey, LDUser user, boolean defaultValue) { + JsonElement value = jsonVariation(featureKey, user, new JsonPrimitive(defaultValue)); + if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isBoolean()) { + return value.getAsJsonPrimitive().getAsBoolean(); + } + return false; + } + + /** + * Calculates the integer value of a feature flag for a given user. + * + * @param featureKey the unique featureKey for the feature flag + * @param user the end user requesting the flag + * @param defaultValue the default value of the flag + * @return the variation for the given user, or {@code defaultValue} if the flag is disabled in the LaunchDarkly control panel + */ + public Integer intVariation(String featureKey, LDUser user, int defaultValue) { + JsonElement value = jsonVariation(featureKey, user, new JsonPrimitive(defaultValue)); + if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isNumber()) { + return value.getAsJsonPrimitive().getAsInt(); + } + return null; + } + + /** + * Calculates the floating point numeric value of a feature flag for a given user. + * + * @param featureKey the unique featureKey for the feature flag + * @param user the end user requesting the flag + * @param defaultValue the default value of the flag + * @return the variation for the given user, or {@code defaultValue} if the flag is disabled in the LaunchDarkly control panel + */ + public Double doubleVariation(String featureKey, LDUser user, Double defaultValue) { + JsonElement value = jsonVariation(featureKey, user, new JsonPrimitive(defaultValue)); + if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isNumber()) { + return value.getAsJsonPrimitive().getAsDouble(); + } + return null; + } + + /** + * Calculates the String value of a feature flag for a given user. + * + * @param featureKey the unique featureKey for the feature flag + * @param user the end user requesting the flag + * @param defaultValue the default value of the flag + * @return the variation for the given user, or {@code defaultValue} if the flag is disabled in the LaunchDarkly control panel + */ + public String stringVariation(String featureKey, LDUser user, String defaultValue) { + JsonElement value = jsonVariation(featureKey, user, new JsonPrimitive(defaultValue)); + if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) { + return value.getAsJsonPrimitive().getAsString(); + } + return null; + } + + /** + * Calculates the {@link JsonElement} value of a feature flag for a given user. + * + * @param featureKey the unique featureKey for the feature flag + * @param user the end user requesting the flag + * @param defaultValue the default value of the flag + * @return the variation for the given user, or {@code defaultValue} if the flag is disabled in the LaunchDarkly control panel + */ + public JsonElement jsonVariation(String featureKey, LDUser user, JsonElement defaultValue) { if (isOffline()) { return defaultValue; } - boolean value = evaluate(featureKey, user, defaultValue); + JsonElement value = evaluate(featureKey, user, defaultValue); sendFlagRequestEvent(featureKey, user, value, defaultValue); return value; } - private Boolean evaluate(String featureKey, LDUser user, Boolean defaultValue) { + + private JsonElement evaluate(String featureKey, LDUser user, JsonElement defaultValue) { if (!initialized()) { return defaultValue; } - try { - FeatureRep result = (FeatureRep) config.featureStore.get(featureKey); - if (result != null) { + FeatureFlag featureFlag = config.featureStore.get(featureKey); + if (featureFlag != null) { if (config.stream && config.debugStreaming) { - FeatureRep pollingResult = requestor.makeRequest(featureKey, true); - if (!result.equals(pollingResult)) { - logger.warn("Mismatch between streaming and polling feature! Streaming: {} Polling: {}", result, pollingResult); + FeatureFlag pollingResult = requestor.makeRequest(featureKey, true); + if (!featureFlag.equals(pollingResult)) { + logger.warn("Mismatch between streaming and polling feature! Streaming: {} Polling: {}", featureFlag, pollingResult); } } } else { logger.warn("Unknown feature flag " + featureKey + "; returning default value: "); return defaultValue; } - - Boolean val = result.evaluate(user); - if (val == null) { - return defaultValue; + if (featureFlag.isOn()) { + FeatureFlag.EvalResult evalResult = featureFlag.evaluate(user, config.featureStore); + if (evalResult != null) { + if (!isOffline()) { + for (FeatureRequestEvent event : evalResult.getPrerequisiteEvents()) { + eventProcessor.sendEvent(event); + } + } + if (evalResult.getValue() == null) { + return defaultValue; + } else { + return evalResult.getValue(); + } + } } else { - return val; + JsonElement offVariation = featureFlag.getOffVariationValue(); + if (offVariation != null) { + return offVariation; + } } } catch (Exception e) { logger.error("Encountered exception in LaunchDarkly client", e); - return defaultValue; } + return defaultValue; } - - /** * Closes the LaunchDarkly client event processing thread and flushes all pending events. This should only * be called on application shutdown. diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index b857624ff..c10404f9d 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -24,7 +24,7 @@ * launch a feature to the top 10% of users on a site. */ public class LDUser { - private JsonPrimitive key; + private final JsonPrimitive key; private JsonPrimitive secondary; private JsonPrimitive ip; private JsonPrimitive email; @@ -37,12 +37,10 @@ public class LDUser { private Map custom; private static final Logger logger = LoggerFactory.getLogger(LDUser.class); - - LDUser() { - - } - protected LDUser(Builder builder) { + if (builder.key == null || builder.key.equals("")) { + logger.warn("User was created with null/empty key"); + } this.key = builder.key == null ? null : new JsonPrimitive(builder.key); this.ip = builder.ip == null ? null : new JsonPrimitive(builder.ip); this.country = builder.country == null ? null : new JsonPrimitive(builder.country.getAlpha2()); @@ -70,6 +68,14 @@ JsonPrimitive getKey() { return key; } + String getKeyAsString() { + if (key == null) { + return ""; + } else { + return key.getAsString(); + } + } + JsonPrimitive getIp() { return ip; } @@ -107,11 +113,13 @@ JsonPrimitive getAnonymous() { } JsonElement getCustom(String key) { - return custom.get(key); + if (custom != null) { + return custom.get(key); + } + return null; } - /** - * A builder that helps construct {@link com.launchdarkly.client.LDUser} objects. Builder + * A builder that helps construct {@link LDUser} objects. Builder * calls can be chained, enabling the following pattern: *

*

@@ -279,7 +287,6 @@ public Builder email(String email) {
      * @param k the key for the custom attribute.
      * @param v the value for the custom attribute
      * @return the builder
-     * @see
      */
     public Builder custom(String k, String v) {
       checkCustomAttribute(k);
diff --git a/src/main/java/com/launchdarkly/client/Operator.java b/src/main/java/com/launchdarkly/client/Operator.java
new file mode 100644
index 000000000..d2112b968
--- /dev/null
+++ b/src/main/java/com/launchdarkly/client/Operator.java
@@ -0,0 +1,101 @@
+package com.launchdarkly.client;
+
+import com.google.gson.JsonPrimitive;
+import org.joda.time.DateTime;
+
+import java.util.regex.Pattern;
+
+/**
+ * Operator value that can be applied to {@link JsonPrimitive} objects. Incompatible types or other errors
+ * will always yield false. This enum can be directly deserialized from JSON, avoiding the need for a mapping
+ * of strings to operators.
+ */
+enum Operator {
+  in {
+    @Override
+    public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) {
+      if (uValue.isString() && cValue.isString()) {
+        if (uValue.getAsString().equals(cValue.getAsString()))
+          return true;
+      }
+      if (uValue.isNumber() && cValue.isNumber()) {
+        return uValue.getAsDouble() == cValue.getAsDouble();
+      }
+      DateTime uDateTime = Util.jsonPrimitiveToDateTime(uValue);
+      if (uDateTime != null) {
+        DateTime cDateTime = Util.jsonPrimitiveToDateTime(cValue);
+        if (cDateTime != null) {
+          return uDateTime.getMillis() == cDateTime.getMillis();
+        }
+      }
+      return false;
+    }
+  },
+  endsWith {
+    @Override
+    public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) {
+      return uValue.isString() && cValue.isString() && uValue.getAsString().endsWith(cValue.getAsString());
+    }
+  },
+  startsWith {
+    @Override
+    public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) {
+      return uValue.isString() && cValue.isString() && uValue.getAsString().startsWith(cValue.getAsString());
+    }
+  },
+  matches {
+    public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) {
+      return uValue.isString() && cValue.isString() && Pattern.matches(cValue.getAsString(), uValue.getAsString());
+    }
+  },
+  contains {
+    public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) {
+      return uValue.isString() && cValue.isString() && uValue.getAsString().contains(cValue.getAsString());
+    }
+  },
+  lessThan {
+    public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) {
+      return uValue.isNumber() && cValue.isNumber() && uValue.getAsDouble() < cValue.getAsDouble();
+    }
+  },
+  lessThanOrEqual {
+    public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) {
+      return uValue.isNumber() && cValue.isNumber() && uValue.getAsDouble() <= cValue.getAsDouble();
+    }
+  },
+  greaterThan {
+    public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) {
+      return uValue.isNumber() && cValue.isNumber() && uValue.getAsDouble() > cValue.getAsDouble();
+    }
+  },
+  greaterThanOrEqual {
+    public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) {
+      return uValue.isNumber() && cValue.isNumber() && uValue.getAsDouble() >= cValue.getAsDouble();
+    }
+  },
+  before {
+    public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) {
+      DateTime uDateTime = Util.jsonPrimitiveToDateTime(uValue);
+      if (uDateTime != null) {
+        DateTime cDateTime = Util.jsonPrimitiveToDateTime(cValue);
+        if (cDateTime != null) {
+          return uDateTime.isBefore(cDateTime);
+        }
+      }
+      return false;
+    }
+  },
+  after {
+    public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) {
+      DateTime uDateTime = Util.jsonPrimitiveToDateTime(uValue);
+      if (uDateTime != null) {
+        DateTime cDateTime = Util.jsonPrimitiveToDateTime(cValue);
+        if (cDateTime != null) {
+          return uDateTime.isAfter(cDateTime);
+        }
+      }
+      return false;
+    }
+  };
+  abstract boolean apply(JsonPrimitive uValue, JsonPrimitive cValue);
+}
diff --git a/src/main/java/com/launchdarkly/client/Prerequisite.java b/src/main/java/com/launchdarkly/client/Prerequisite.java
new file mode 100644
index 000000000..99004a7e1
--- /dev/null
+++ b/src/main/java/com/launchdarkly/client/Prerequisite.java
@@ -0,0 +1,14 @@
+package com.launchdarkly.client;
+
+class Prerequisite {
+  private String key;
+  private int variation;
+
+  String getKey() {
+    return key;
+  }
+
+  int getVariation() {
+    return variation;
+  }
+}
diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java
index b9264b0d5..87b44e805 100644
--- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java
+++ b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java
@@ -25,7 +25,7 @@
 import java.util.concurrent.TimeUnit;
 
 /**
- * A thread-safe, versioned store for {@link com.launchdarkly.client.FeatureRep} objects backed by Redis. Also
+ * A thread-safe, versioned store for {@link FeatureFlag} objects backed by Redis. Also
  * supports an optional in-memory cache configuration that can be used to improve performance.
  *
  */
@@ -34,7 +34,7 @@ public class RedisFeatureStore implements FeatureStore {
   private static final String INIT_KEY = "$initialized$";
   private static final String CACHE_REFRESH_THREAD_POOL_NAME_FORMAT = "RedisFeatureStore-cache-refresher-pool-%d";
   private final JedisPool pool;
-  private LoadingCache> cache;
+  private LoadingCache cache;
   private LoadingCache initCache;
   private String prefix;
   private ListeningExecutorService executorService;
@@ -152,10 +152,10 @@ private void createCache(long cacheTimeSecs, boolean refreshStaleValues, boolean
     }
   }
 
-  private CacheLoader> createDefaultCacheLoader() {
-    return new CacheLoader>() {
+  private CacheLoader createDefaultCacheLoader() {
+    return new CacheLoader() {
       @Override
-      public FeatureRep load(String key) throws Exception {
+      public FeatureFlag load(String key) throws Exception {
         return getRedis(key);
       }
     };
@@ -164,14 +164,14 @@ public FeatureRep load(String key) throws Exception {
   /**
    * Configures the instance to use a "refresh after write" cache. This will not automatically evict stale values, allowing them to be returned if failures
    * occur when updating them. Optionally set the cache to refresh values asynchronously, which always returns the previously cached value immediately.
-   * @param cacheTimeSecs the length of time in seconds, after a {@link FeatureRep} value is created that it should be refreshed.
+   * @param cacheTimeSecs the length of time in seconds, after a {@link FeatureFlag} value is created that it should be refreshed.
    * @param asyncRefresh makes the refresh asynchronous or not.
    */
   private void createRefreshCache(long cacheTimeSecs, boolean asyncRefresh) {
     ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat(CACHE_REFRESH_THREAD_POOL_NAME_FORMAT).setDaemon(true).build();
     ExecutorService parentExecutor = Executors.newSingleThreadExecutor(threadFactory);
     executorService = MoreExecutors.listeningDecorator(parentExecutor);
-    CacheLoader> cacheLoader = createDefaultCacheLoader();
+    CacheLoader cacheLoader = createDefaultCacheLoader();
     if (asyncRefresh) {
       cacheLoader = CacheLoader.asyncReloading(cacheLoader, executorService);
     }
@@ -180,7 +180,7 @@ private void createRefreshCache(long cacheTimeSecs, boolean asyncRefresh) {
 
   /**
    * Configures the instance to use an "expire after write" cache. This will evict stale values and block while loading the latest from Redis.
-   * @param cacheTimeSecs the length of time in seconds, after a {@link FeatureRep} value is created that it should be automatically removed.
+   * @param cacheTimeSecs the length of time in seconds, after a {@link FeatureFlag} value is created that it should be automatically removed.
    */
   private void createExpiringCache(long cacheTimeSecs) {
     cache = CacheBuilder.newBuilder().expireAfterWrite(cacheTimeSecs, TimeUnit.SECONDS).build(createDefaultCacheLoader());
@@ -198,17 +198,17 @@ public Boolean load(String key) throws Exception {
   }
 
   /**
-   * Returns the {@link com.launchdarkly.client.FeatureRep} to which the specified key is mapped, or
-   * null if the key is not associated or the associated {@link com.launchdarkly.client.FeatureRep} has
+   * Returns the {@link FeatureFlag} to which the specified key is mapped, or
+   * null if the key is not associated or the associated {@link FeatureFlag} has
    * been deleted.
    *
-   * @param key the key whose associated {@link com.launchdarkly.client.FeatureRep} is to be returned
-   * @return the {@link com.launchdarkly.client.FeatureRep} to which the specified key is mapped, or
-   * null if the key is not associated or the associated {@link com.launchdarkly.client.FeatureRep} has
+   * @param key the key whose associated {@link FeatureFlag} is to be returned
+   * @return the {@link FeatureFlag} to which the specified key is mapped, or
+   * null if the key is not associated or the associated {@link FeatureFlag} has
    * been deleted.
    */
   @Override
-  public FeatureRep get(String key) {
+  public FeatureFlag get(String key) {
     if (cache != null) {
       return cache.getUnchecked(key);
     } else {
@@ -223,16 +223,16 @@ public FeatureRep get(String key) {
    * @return a map of all associated features.
    */
   @Override
-  public Map> all() {
+  public Map all() {
     try (Jedis jedis = pool.getResource()) {
       Map featuresJson = jedis.hgetAll(featuresKey());
-      Map> result = new HashMap<>();
+      Map result = new HashMap<>();
       Gson gson = new Gson();
 
-      Type type = new TypeToken>() {}.getType();
+      Type type = new TypeToken() {}.getType();
 
       for (Map.Entry entry : featuresJson.entrySet()) {
-        FeatureRep rep =  gson.fromJson(entry.getValue(), type);
+        FeatureFlag rep =  gson.fromJson(entry.getValue(), type);
         result.put(entry.getKey(), rep);
       }
       return result;
@@ -246,15 +246,15 @@ public Map> all() {
    * @param features the features to set the store
    */
   @Override
-  public void init(Map> features) {
+  public void init(Map features) {
     try (Jedis jedis = pool.getResource()) {
       Gson gson = new Gson();
       Transaction t = jedis.multi();
 
       t.del(featuresKey());
 
-      for (FeatureRep f: features.values()) {
-        t.hset(featuresKey(), f.key, gson.toJson(f));
+      for (FeatureFlag f: features.values()) {
+        t.hset(featuresKey(), f.getKey(), gson.toJson(f));
       }
 
       t.exec();
@@ -274,16 +274,16 @@ public void delete(String key, int version) {
       Gson gson = new Gson();
       jedis.watch(featuresKey());
 
-      FeatureRep feature = getRedis(key);
+      FeatureFlag feature = getRedis(key);
 
-      if (feature != null && feature.version >= version) {
+      if (feature != null && feature.getVersion() >= version) {
         return;
       }
 
-      feature.deleted = true;
-      feature.version = version;
-
-      jedis.hset(featuresKey(), key, gson.toJson(feature));
+      FeatureFlagBuilder newBuilder = new FeatureFlagBuilder(feature);
+      newBuilder.on(false);
+      newBuilder.version(version);
+      jedis.hset(featuresKey(), key, gson.toJson(newBuilder.build()));
 
       if (cache != null) {
         cache.invalidate(key);
@@ -299,14 +299,14 @@ public void delete(String key, int version) {
    * @param feature
    */
   @Override
-  public void upsert(String key, FeatureRep feature) {
+  public void upsert(String key, FeatureFlag feature) {
     try (Jedis jedis = pool.getResource()) {
       Gson gson = new Gson();
       jedis.watch(featuresKey());
 
-      FeatureRep f = getRedis(key);
+      FeatureFlag f = getRedis(key);
 
-      if (f != null && f.version >= feature.version) {
+      if (f != null && f.getVersion() >= feature.getVersion()) {
         return;
       }
 
@@ -373,7 +373,7 @@ private Boolean getInit() {
     }
   }
 
-  private FeatureRep getRedis(String key) {
+  private FeatureFlag getRedis(String key) {
     try (Jedis jedis = pool.getResource()){
       Gson gson = new Gson();
       String featureJson = jedis.hget(featuresKey(), key);
@@ -382,10 +382,10 @@ private FeatureRep getRedis(String key) {
         return null;
       }
 
-      Type type = new TypeToken>() {}.getType();
-      FeatureRep f = gson.fromJson(featureJson, type);
+      Type type = new TypeToken() {}.getType();
+      FeatureFlag f = gson.fromJson(featureJson, type);
 
-      return f.deleted ? null : f;
+      return f.isDeleted() ? null : f;
     }
   }
 
diff --git a/src/main/java/com/launchdarkly/client/Rule.java b/src/main/java/com/launchdarkly/client/Rule.java
new file mode 100644
index 000000000..f0dcd2610
--- /dev/null
+++ b/src/main/java/com/launchdarkly/client/Rule.java
@@ -0,0 +1,90 @@
+package com.launchdarkly.client;
+
+import com.google.gson.JsonElement;
+import org.apache.commons.codec.digest.DigestUtils;
+
+import java.util.List;
+
+import static com.launchdarkly.client.Clause.valueOf;
+
+/**
+ * Expresses a set of AND-ed matching conditions for a user, along with either the fixed variation or percent rollout
+ * to serve if the conditions match.
+ * Invariant: one of the variation or rollout must be non-nil.
+ */
+class Rule {
+  private static final float long_scale = (float) 0xFFFFFFFFFFFFFFFL;
+
+  private List clauses;
+  private Integer variation;
+  private Rollout rollout;
+
+  Rule(List clauses, Integer variation, Rollout rollout) {
+    this.clauses = clauses;
+    this.variation = variation;
+    this.rollout = rollout;
+  }
+
+  boolean matchesUser(LDUser user) {
+    for (Clause clause : clauses) {
+      if (!clause.matchesUser(user)) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  Integer variationIndexForUser(LDUser user, String key, String salt) {
+    if (variation != null) {
+      return variation;
+    } else if (rollout != null) {
+      String bucketBy = rollout.bucketBy == null ? "key" : rollout.bucketBy;
+      Float bucket = bucketUser(user, key, bucketBy, salt);
+      Float sum = 0F;
+      for (WeightedVariation wv : rollout.variations) {
+        sum += (float)wv.weight / 100000F;
+        if (bucket < sum) {
+          return wv.variation;
+        }
+      }
+    }
+    return null;
+  }
+
+  Float bucketUser(LDUser user, String key, String attr, String salt) {
+    JsonElement userValue = valueOf(user, attr);
+    String idHash;
+    if (userValue != null) {
+      if (userValue.isJsonPrimitive() && userValue.getAsJsonPrimitive().isString()) {
+        idHash = userValue.getAsString();
+        if (user.getSecondary() != null) {
+          idHash = idHash + "." + user.getSecondary().getAsString();
+        }
+        String hash = DigestUtils.sha1Hex(key + "." + salt + "." + idHash).substring(0, 15);
+        long longVal = Long.parseLong(hash, 16);
+        return (float) longVal / long_scale;
+      }
+    }
+    return null;
+  }
+
+  static class Rollout {
+    private List variations;
+    private String bucketBy;
+
+    public Rollout(List variations, String bucketBy) {
+      this.variations = variations;
+      this.bucketBy = bucketBy;
+    }
+  }
+
+  static class WeightedVariation {
+    private int variation;
+    private int weight;
+
+    public WeightedVariation(int variation, int weight) {
+      this.variation = variation;
+      this.weight = weight;
+    }
+  }
+}
diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java
index cb9d90aca..9c4fff23c 100644
--- a/src/main/java/com/launchdarkly/client/StreamProcessor.java
+++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java
@@ -1,7 +1,6 @@
 package com.launchdarkly.client;
 
 import com.google.gson.Gson;
-import com.google.gson.reflect.TypeToken;
 import com.launchdarkly.eventsource.EventHandler;
 import com.launchdarkly.eventsource.EventSource;
 import com.launchdarkly.eventsource.MessageEvent;
@@ -10,9 +9,7 @@
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
-import java.lang.reflect.Type;
 import java.net.URI;
-import java.util.Map;
 import java.util.concurrent.Future;
 import java.util.concurrent.atomic.AtomicBoolean;
 
@@ -60,9 +57,7 @@ public void onOpen() throws Exception {
       public void onMessage(String name, MessageEvent event) throws Exception {
         Gson gson = new Gson();
         if (name.equals(PUT)) {
-          Type type = new TypeToken>>(){}.getType();
-          Map> features = gson.fromJson(event.getData(), type);
-          store.init(features);
+          store.init(FeatureFlag.fromJsonMap(event.getData()));
           if (!initialized.getAndSet(true)) {
             initFuture.completed(null);
             logger.info("Initialized LaunchDarkly client.");
@@ -78,8 +73,7 @@ else if (name.equals(DELETE)) {
         }
         else if (name.equals(INDIRECT_PUT)) {
           try {
-            Map> features = requestor.makeAllRequest(true);
-            store.init(features);
+            store.init(requestor.makeAllRequest(true));
             if (!initialized.getAndSet(true)) {
               initFuture.completed(null);
               logger.info("Initialized LaunchDarkly client.");
@@ -91,7 +85,7 @@ else if (name.equals(INDIRECT_PUT)) {
         else if (name.equals(INDIRECT_PATCH)) {
           String key = event.getData();
           try {
-            FeatureRep feature = requestor.makeRequest(key, true);
+            FeatureFlag feature = requestor.makeRequest(key, true);
             store.upsert(key, feature);
           } catch (IOException e) {
             logger.error("Encountered exception in LaunchDarkly client", e);
@@ -131,13 +125,13 @@ public boolean initialized() {
     return initialized.get();
   }
 
-  FeatureRep getFeature(String key) {
+  FeatureFlag getFeature(String key) {
     return store.get(key);
   }
 
   private static final class FeaturePatchData {
     String path;
-    FeatureRep data;
+    FeatureFlag data;
 
     public FeaturePatchData() {
 
@@ -147,7 +141,7 @@ String key() {
       return path.substring(1);
     }
 
-    FeatureRep feature() {
+    FeatureFlag feature() {
       return data;
     }
 
diff --git a/src/main/java/com/launchdarkly/client/Target.java b/src/main/java/com/launchdarkly/client/Target.java
new file mode 100644
index 000000000..3cd7cab0d
--- /dev/null
+++ b/src/main/java/com/launchdarkly/client/Target.java
@@ -0,0 +1,16 @@
+package com.launchdarkly.client;
+
+import java.util.List;
+
+class Target {
+  private List values;
+  private int variation;
+
+  List getValues() {
+    return values;
+  }
+
+  int getVariation() {
+    return variation;
+  }
+}
diff --git a/src/main/java/com/launchdarkly/client/TestFeatureStore.java b/src/main/java/com/launchdarkly/client/TestFeatureStore.java
index b8107ce21..102991bc7 100644
--- a/src/main/java/com/launchdarkly/client/TestFeatureStore.java
+++ b/src/main/java/com/launchdarkly/client/TestFeatureStore.java
@@ -1,40 +1,54 @@
 package com.launchdarkly.client;
 
+import com.google.gson.JsonElement;
+import com.google.gson.JsonPrimitive;
+
+import java.util.Arrays;
+import java.util.List;
 import java.util.concurrent.atomic.AtomicInteger;
 
 /**
- * A decorated {@link InMemoryFeatureStore} which provides functionality to create (or override) "on" or "off" feature flags for all users.
- *
- * Using this store is useful for testing purposes when you want to have runtime support for turning specific features "on" or "off".
- *
+ * A decorated {@link InMemoryFeatureStore} which provides functionality to create (or override) true or false feature flags for all users.
+ * 

+ * Using this store is useful for testing purposes when you want to have runtime support for turning specific features on or off. */ public class TestFeatureStore extends InMemoryFeatureStore { + private static List TRUE_FALSE_VARIATIONS = Arrays.asList( + (JsonElement) (new JsonPrimitive(true)), + (JsonElement) (new JsonPrimitive(false)) + ); - private AtomicInteger version = new AtomicInteger(0); - - /** - * Turns a feature, identified by key, "on" for every user. If the feature rules already exist in the store then it will override it to be "on" for every {@link LDUser}. - * If the feature rule is not currently in the store, it will create one that is "on" for every {@link LDUser}. - * - * @param key the key of the feature flag to be "on". - */ - public void turnFeatureOn(String key) { - writeFeatureRep(key, new Variation.Builder<>(true, 100).build()); - } + private AtomicInteger version = new AtomicInteger(0); - /** - * Turns a feature, identified by key, "off" for every user. If the feature rules already exists in the store then it will override it to be "off" for every {@link LDUser}. - * If the feature rule is not currently in the store, it will create one that is "off" for every {@link LDUser}. - * - * @param key the key of the feature flag to be "off". - */ - public void turnFeatureOff(String key) { - writeFeatureRep(key, new Variation.Builder<>(false, 100).build()); - } + /** + * Turns a feature, identified by key, to evaluate to true for every user. If the feature rules already exist in the store then it will override it to be true for every {@link LDUser}. + * If the feature rule is not currently in the store, it will create one that is true for every {@link LDUser}. + * + * @param key the key of the feature flag to evaluate to true. + */ + public void setFeatureTrue(String key) { + FeatureFlag newFeature = new FeatureFlagBuilder(key) + .on(false) + .offVariation(0) + .variations(TRUE_FALSE_VARIATIONS) + .version(version.incrementAndGet()) + .build(); + upsert(key, newFeature); + } - private void writeFeatureRep(final String key, final Variation variation) { - FeatureRep newFeature = new FeatureRep.Builder(String.format("test-%s", key), key) - .variation(variation).version(version.incrementAndGet()).build(); - upsert(key, newFeature); - } + /** + * Turns a feature, identified by key, to evaluate to false for every user. If the feature rules already exist in the store then it will override it to be false for every {@link LDUser}. + * If the feature rule is not currently in the store, it will create one that is false for every {@link LDUser}. + * + * @param key the key of the feature flag to evaluate to false. + */ + public void setFeatureFalse(String key) { + FeatureFlag newFeature = new FeatureFlagBuilder(key) + .on(false) + .offVariation(1) + .variations(TRUE_FALSE_VARIATIONS) + .version(version.incrementAndGet()) + .build(); + upsert(key, newFeature); + } } diff --git a/src/main/java/com/launchdarkly/client/Util.java b/src/main/java/com/launchdarkly/client/Util.java new file mode 100644 index 000000000..61b891245 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/Util.java @@ -0,0 +1,27 @@ +package com.launchdarkly.client; + +import com.google.gson.JsonPrimitive; +import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; + +class Util { + /** + * Converts either a unix epoch millis number or RFC3339/ISO8601 timestamp as {@link JsonPrimitive} to a {@link DateTime} object. + * @param maybeDate wraps either a nubmer or a string that may contain a valid timestamp. + * @return null if input is not a valid format. + */ + protected static DateTime jsonPrimitiveToDateTime(JsonPrimitive maybeDate) { + if (maybeDate.isNumber()) { + long millis = maybeDate.getAsLong(); + return new DateTime(millis); + } else if (maybeDate.isString()) { + try { + return new DateTime(maybeDate.getAsString(), DateTimeZone.UTC); + } catch (Throwable t) { + return null; + } + } else { + return null; + } + } +} diff --git a/src/main/java/com/launchdarkly/client/Variation.java b/src/main/java/com/launchdarkly/client/Variation.java deleted file mode 100644 index 51a12c718..000000000 --- a/src/main/java/com/launchdarkly/client/Variation.java +++ /dev/null @@ -1,211 +0,0 @@ -package com.launchdarkly.client; - - -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.ArrayList; -import java.util.List; - -class Variation { - E value; - int weight; - TargetRule userTarget; - List targets; - private final static Logger logger = LoggerFactory.getLogger(Variation.class); - - public Variation() { - - } - - @Override - public String toString() { - return "Variation{" + - "value=" + value + - ", weight=" + weight + - ", userTarget=" + userTarget + - ", targets=" + targets + - '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - Variation variation = (Variation) o; - - if (weight != variation.weight) return false; - if (value != null ? !value.equals(variation.value) : variation.value != null) return false; - if (userTarget != null ? !userTarget.equals(variation.userTarget) : variation.userTarget != null) return false; - return targets != null ? targets.equals(variation.targets) : variation.targets == null; - } - - @Override - public int hashCode() { - int result = value != null ? value.hashCode() : 0; - result = 31 * result + weight; - result = 31 * result + (userTarget != null ? userTarget.hashCode() : 0); - result = 31 * result + (targets != null ? targets.hashCode() : 0); - return result; - } - - Variation(Builder b) { - this.value = b.value; - this.weight = b.weight; - this.userTarget = b.userTarget; - this.targets = new ArrayList<>(b.targets); - } - - public boolean matchUser(LDUser user) { - // If a userTarget rule is present, apply it - if (userTarget != null && userTarget.matchTarget(user)) { - return true; - } - return false; - } - - public boolean matchTarget(LDUser user) { - for (TargetRule target: targets) { - // If a userTarget rule is present, nested "key" rules - // are deprecated and should be ignored - if (userTarget != null && target.attribute.equals("key")) { - continue; - } - if (target.matchTarget(user)) { - return true; - } - } - return false; - } - - static class Builder { - E value; - int weight; - TargetRule userTarget; - List targets; - - Builder(E value, int weight) { - this.value = value; - this.weight = weight; - this.userTarget = new TargetRule("key", "in", new ArrayList()); - targets = new ArrayList<>(); - } - - Builder userTarget(TargetRule rule) { - this.userTarget = rule; - return this; - } - - Builder target(TargetRule rule) { - targets.add(rule); - return this; - } - - Variation build() { - return new Variation<>(this); - } - - } - - static class TargetRule { - String attribute; - String operator; - List values; - - private final static Logger logger = LoggerFactory.getLogger(TargetRule.class); - - public TargetRule() { - - } - - TargetRule(String attribute, String operator, List values) { - this.attribute = attribute; - this.operator = operator; - this.values = new ArrayList<>(values); - } - - TargetRule(String attribute, List values) { - this(attribute, "in", values); - } - - public boolean matchTarget(LDUser user) { - Object uValue = null; - if (attribute.equals("key")) { - if (user.getKey() != null) { - uValue = user.getKey(); - } - } - else if (attribute.equals("ip") && user.getIp() != null) { - if (user.getIp() != null) { - uValue = user.getIp(); - } - } - else if (attribute.equals("country")) { - if (user.getCountry() != null) { - uValue = user.getCountry(); - } - } - else if (attribute.equals("email")) { - if (user.getEmail() != null) { - uValue = user.getEmail(); - } - } - else if (attribute.equals("firstName")) { - if (user.getFirstName() != null ) { - uValue = user.getFirstName(); - } - } - else if (attribute.equals("lastName")) { - if (user.getLastName() != null) { - uValue = user.getLastName(); - } - } - else if (attribute.equals("avatar")) { - if (user.getAvatar() != null) { - uValue = user.getAvatar(); - } - } - else if (attribute.equals("name")) { - if (user.getName() != null) { - uValue = user.getName(); - } - } - else if (attribute.equals("anonymous")) { - if (user.getAnonymous() != null) { - uValue = user.getAnonymous(); - } - } - else { // Custom attribute - JsonElement custom = user.getCustom(attribute); - - if (custom != null) { - if (custom.isJsonArray()) { - JsonArray array = custom.getAsJsonArray(); - for (JsonElement elt: array) { - if (! elt.isJsonPrimitive()) { - logger.error("Invalid custom attribute value in user object: " + elt); - return false; - } - else if (values.contains(elt.getAsJsonPrimitive())) { - return true; - } - } - return false; - } - else if (custom.isJsonPrimitive()) { - return values.contains(custom.getAsJsonPrimitive()); - } - } - return false; - } - if (uValue == null) { - return false; - } - return values.contains((uValue)); - } - } -} diff --git a/src/test/java/com/launchdarkly/client/FeatureRepTest.java b/src/test/java/com/launchdarkly/client/FeatureRepTest.java deleted file mode 100644 index 94b55ff9b..000000000 --- a/src/test/java/com/launchdarkly/client/FeatureRepTest.java +++ /dev/null @@ -1,204 +0,0 @@ -package com.launchdarkly.client; - -import com.google.gson.JsonPrimitive; -import org.junit.Test; - -import java.util.Arrays; -import java.util.Collections; -import static org.junit.Assert.*; - -public class FeatureRepTest { - - private final Variation.TargetRule targetUserOn = new Variation.TargetRule("key", Collections.singletonList(new JsonPrimitive("targetOn@test.com"))); - - private final Variation.TargetRule targetGroupOn = new Variation.TargetRule("groups", Arrays.asList(new JsonPrimitive("google"), new JsonPrimitive("microsoft"))); - - // GSON will deserialize numbers as decimals - private final Variation.TargetRule targetFavoriteNumberOn = new Variation.TargetRule("favorite_number", Arrays.asList(new JsonPrimitive(42))); - - private final Variation.TargetRule targetLikesCatsOn = new Variation.TargetRule("likes_cats", Arrays.asList(new JsonPrimitive(true))); - - private final Variation.TargetRule targetUserOff = new Variation.TargetRule("key", Collections.singletonList(new JsonPrimitive("targetOff@test.com"))); - - private final Variation.TargetRule targetGroupOff = new Variation.TargetRule("groups", Arrays.asList(new JsonPrimitive("oracle"))); - - private final Variation.TargetRule targetFavoriteNumberOff = new Variation.TargetRule("favorite_number", Arrays.asList(new JsonPrimitive(33.0))); - - private final Variation.TargetRule targetLikesDogsOff = new Variation.TargetRule("likes_dogs", Arrays.asList(new JsonPrimitive(false))); - - private final Variation.TargetRule targetAnonymousOn = new Variation.TargetRule("anonymous", Collections.singletonList(new JsonPrimitive(true))); - - private final Variation trueVariation = new Variation.Builder<>(true, 80) - .target(targetUserOn) - .target(targetGroupOn) - .target(targetAnonymousOn) - .target(targetLikesCatsOn) - .target(targetFavoriteNumberOn) - .build(); - - private final Variation falseVariation = new Variation.Builder<>(false, 20) - .target(targetUserOff) - .target(targetGroupOff) - .target(targetFavoriteNumberOff) - .target(targetLikesDogsOff) - .build(); - - private final FeatureRep simpleFlag = new FeatureRep.Builder("Sample flag", "sample.flag") - .on(true) - .salt("feefifofum") - .variation(trueVariation) - .variation(falseVariation) - .build(); - - private final FeatureRep disabledFlag = new FeatureRep.Builder("Sample flag", "sample.flag") - .on(false) - .salt("feefifofum") - .variation(trueVariation) - .variation(falseVariation) - .build(); - - private Variation userRuleVariation = new Variation.Builder<>(false, 20) - .userTarget(targetUserOn) - .build(); - - private final FeatureRep userRuleFlag = new FeatureRep.Builder("User rule flag", "user.rule.flag") - .on(true) - .salt("feefifofum") - .variation(trueVariation) - .variation(userRuleVariation) - .build(); - - @Test - public void testUserRuleFlagForTargetUserOff() { - - // The trueVariation tries to enable this rule, but the userVariation (with false value) has a userRule - // that's able to override this. This doesn't represent a real LD response-- we'd never have feature reps - // that sometimes contain user rules and sometimes contain embedded 'key' rules - LDUser user = new LDUser.Builder("targetOn@test.com").build(); - Boolean b = userRuleFlag.evaluate(user); - - assertEquals(false, b); - } - - @Test - public void testFlagForTargetedUserOff() { - LDUser user = new LDUser.Builder("targetOff@test.com").build(); - - Boolean b = simpleFlag.evaluate(user); - - assertEquals(false, b); - } - - @Test - public void testFlagForTargetedUserOn() { - LDUser user = new LDUser.Builder("targetOn@test.com").build(); - - Boolean b = simpleFlag.evaluate(user); - - assertEquals(true, b); - } - - @Test - public void testFlagForTargetGroupOn() { - LDUser user = new LDUser.Builder("targetOther@test.com") - .customString("groups", Arrays.asList("google", "microsoft")) - .build(); - - Boolean b = simpleFlag.evaluate(user); - - assertEquals(true, b); - } - - @Test - public void testFlagForTargetNumericTestOn() { - LDUser user = new LDUser.Builder("targetOther@test.com") - .custom("favorite_number", 42.0) - .build(); - - Boolean b = simpleFlag.evaluate(user); - - assertEquals(true, b); - } - - @Test - public void testFlagForTargetNumericListTestOn() { - LDUser user = new LDUser.Builder("targetOther@test.com") - .customNumber("favorite_number", Arrays.asList(42, 32)) - .build(); - - Boolean b = simpleFlag.evaluate(user); - - assertEquals(true, b); - } - - @Test - public void testFlagForTargetBooleanTestOn() { - LDUser user = new LDUser.Builder("targetOther@test.com") - .custom("likes_cats", true) - .build(); - - Boolean b = simpleFlag.evaluate(user); - - assertEquals(true, b); - } - - @Test - public void testFlagForTargetGroupOff() { - LDUser user = new LDUser.Builder("targetOther@test.com") - .custom("groups", "oracle") - .build(); - - Boolean b = simpleFlag.evaluate(user); - - assertEquals(false, b); - } - - @Test - public void testFlagForTargetNumericTestOff() { - LDUser user = new LDUser.Builder("targetOther@test.com") - .custom("favorite_number", 33.0) - .build(); - - Boolean b = simpleFlag.evaluate(user); - - assertEquals(false, b); - } - - @Test - public void testFlagForTargetBooleanTestOff() { - LDUser user = new LDUser.Builder("targetOther@test.com") - .custom("likes_dogs", false) - .build(); - - Boolean b = simpleFlag.evaluate(user); - - assertEquals(false, b); - } - - @Test - public void testDisabledFlagAlwaysOff() { - LDUser user = new LDUser("targetOn@test.com"); - - Boolean b = disabledFlag.evaluate(user); - - assertEquals(null, b); - } - - @Test - public void testFlagWithCustomAttributeWorksWithLDUserDefaultCtor() { - LDUser user = new LDUser("randomUser@test.com"); - - Boolean b = simpleFlag.evaluate(user); - - assertNotNull(b); - } - - @Test - public void testFlagWithAnonymousOn() { - LDUser user = new LDUser.Builder("targetOff@test.com").anonymous(true).build(); - - Boolean b = simpleFlag.evaluate(user); - assertEquals(true, b); - } - -} diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index d0543c9ac..6e0ccc253 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -5,7 +5,6 @@ import org.junit.Test; import java.io.IOException; -import java.io.ObjectInput; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; @@ -49,7 +48,7 @@ public void testOffline() throws IOException { } @Test - public void testTestFeatureStoreFlagOn() throws IOException, InterruptedException, ExecutionException, TimeoutException { + public void testTestFeatureStoreSetFeatureTrue() throws IOException, InterruptedException, ExecutionException, TimeoutException { TestFeatureStore testFeatureStore = new TestFeatureStore(); LDConfig config = new LDConfig.Builder() .startWaitMillis(10L) @@ -64,14 +63,14 @@ public void testTestFeatureStoreFlagOn() throws IOException, InterruptedExceptio replayAll(); client = createMockClient(config); - testFeatureStore.turnFeatureOn("key"); - assertTrue("Test flag should be on, but was not.", client.toggle("key", new LDUser("user"), false)); + testFeatureStore.setFeatureTrue("key"); + assertTrue("Test flag should be true, but was not.", client.toggle("key", new LDUser("user"), false)); verifyAll(); } @Test - public void testTestFeatureStoreFlagOff() throws IOException, InterruptedException, ExecutionException, TimeoutException { + public void testTestFeatureStoreSetFalse() throws IOException, InterruptedException, ExecutionException, TimeoutException { TestFeatureStore testFeatureStore = new TestFeatureStore(); LDConfig config = new LDConfig.Builder() .startWaitMillis(10L) @@ -86,14 +85,14 @@ public void testTestFeatureStoreFlagOff() throws IOException, InterruptedExcepti replayAll(); client = createMockClient(config); - testFeatureStore.turnFeatureOff("key"); - assertFalse("Test flag should be off, but was on (the default).", client.toggle("key", new LDUser("user"), true)); + testFeatureStore.setFeatureFalse("key"); + assertFalse("Test flag should be false, but was on (the default).", client.toggle("key", new LDUser("user"), true)); verifyAll(); } @Test - public void testTestFeatureStoreFlagOnThenOff() throws IOException, InterruptedException, ExecutionException, TimeoutException { + public void testTestFeatureStoreFlagTrueThenFalse() throws IOException, InterruptedException, ExecutionException, TimeoutException { TestFeatureStore testFeatureStore = new TestFeatureStore(); LDConfig config = new LDConfig.Builder() .startWaitMillis(10L) @@ -109,11 +108,11 @@ public void testTestFeatureStoreFlagOnThenOff() throws IOException, InterruptedE client = createMockClient(config); - testFeatureStore.turnFeatureOn("key"); - assertTrue("Test flag should be on, but was not.", client.toggle("key", new LDUser("user"), false)); + testFeatureStore.setFeatureTrue("key"); + assertTrue("Test flag should be true, but was not.", client.toggle("key", new LDUser("user"), false)); - testFeatureStore.turnFeatureOff("key"); - assertFalse("Test flag should be off, but was on (the default).", client.toggle("key", new LDUser("user"), true)); + testFeatureStore.setFeatureFalse("key"); + assertFalse("Test flag should be false, but was on (the default).", client.toggle("key", new LDUser("user"), true)); verifyAll(); } diff --git a/src/test/java/com/launchdarkly/client/OperatorTest.java b/src/test/java/com/launchdarkly/client/OperatorTest.java new file mode 100644 index 000000000..3557ea23b --- /dev/null +++ b/src/test/java/com/launchdarkly/client/OperatorTest.java @@ -0,0 +1,27 @@ +package com.launchdarkly.client; + +import com.google.gson.JsonPrimitive; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class OperatorTest { + @Test + public void testNumberComparison() { + JsonPrimitive a = new JsonPrimitive(99); + JsonPrimitive b = new JsonPrimitive(99.0001); + + assertFalse(Operator.contains.apply(a, b)); + assertTrue(Operator.lessThan.apply(a, b)); + assertTrue(Operator.lessThanOrEqual.apply(a, b)); + assertFalse(Operator.greaterThan.apply(a, b)); + assertFalse(Operator.greaterThanOrEqual.apply(a, b)); + + assertFalse(Operator.contains.apply(b, a)); + assertFalse(Operator.lessThan.apply(b, a)); + assertFalse(Operator.lessThanOrEqual.apply(b, a)); + assertTrue(Operator.greaterThan.apply(b, a)); + assertTrue(Operator.greaterThanOrEqual.apply(b, a)); + } +} diff --git a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java index 1b5cc05e6..531ade6fb 100644 --- a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java @@ -1,20 +1,15 @@ package com.launchdarkly.client; import org.easymock.EasyMockSupport; -import org.junit.After; -import org.junit.Before; import org.junit.Test; import java.io.IOException; -import java.sql.Time; import java.util.HashMap; -import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import static org.easymock.EasyMock.expect; -import static org.easymock.EasyMock.verify; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -26,7 +21,7 @@ public void testConnectionOk() throws Exception { PollingProcessor pollingProcessor = new PollingProcessor(LDConfig.DEFAULT, requestor); expect(requestor.makeAllRequest(true)) - .andReturn(new HashMap>()) + .andReturn(new HashMap()) .once(); replayAll(); @@ -51,7 +46,7 @@ public void testConnectionProblem() throws Exception { try { initFuture.get(100L, TimeUnit.MILLISECONDS); fail("Expected Timeout, instead initFuture.get() returned."); - } catch (TimeoutException expected) { + } catch (TimeoutException ignored) { } assertFalse(initFuture.isDone()); assertFalse(pollingProcessor.initialized()); diff --git a/src/test/java/com/launchdarkly/client/UtilTest.java b/src/test/java/com/launchdarkly/client/UtilTest.java new file mode 100644 index 000000000..3be37ba6a --- /dev/null +++ b/src/test/java/com/launchdarkly/client/UtilTest.java @@ -0,0 +1,75 @@ +package com.launchdarkly.client; + +import com.google.gson.JsonPrimitive; +import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; +import org.junit.Assert; +import org.junit.Test; + +public class UtilTest { + @Test + public void testDateTimeConversionWithTimeZone() { + String validRFC3339String = "2016-04-16T17:09:12.759-07:00"; + String expected = "2016-04-17T00:09:12.759Z"; + + DateTime actual = Util.jsonPrimitiveToDateTime(new JsonPrimitive(validRFC3339String)); + Assert.assertEquals(expected, actual.toString()); + } + + @Test + public void testDateTimeConversionWithUtc() { + String validRFC3339String = "1970-01-01T00:00:01.001Z"; + + DateTime actual = Util.jsonPrimitiveToDateTime(new JsonPrimitive(validRFC3339String)); + Assert.assertEquals(validRFC3339String, actual.toString()); + } + + @Test + public void testDateTimeConversionWithNoTimeZone() { + String validRFC3339String = "2016-04-16T17:09:12.759"; + String expected = "2016-04-16T17:09:12.759Z"; + + DateTime actual = Util.jsonPrimitiveToDateTime(new JsonPrimitive(validRFC3339String)); + Assert.assertEquals(expected, actual.toString()); + } + + @Test + public void testDateTimeConversionTimestampWithNoMillis() { + String validRFC3339String = "2016-04-16T17:09:12"; + String expected = "2016-04-16T17:09:12.000Z"; + + DateTime actual = Util.jsonPrimitiveToDateTime(new JsonPrimitive(validRFC3339String)); + Assert.assertEquals(expected, actual.toString()); + } + + @Test + public void testDateTimeConversionAsUnixMillis() { + long unixMillis = 1000; + String expected = "1970-01-01T00:00:01.000Z"; + DateTime actual = Util.jsonPrimitiveToDateTime(new JsonPrimitive(unixMillis)); + Assert.assertEquals(expected, actual.withZone(DateTimeZone.UTC).toString()); + } + + @Test + public void testDateTimeConversionCompare() { + long aMillis = 1001; + String bStamp = "1970-01-01T00:00:01.001Z"; + DateTime a = Util.jsonPrimitiveToDateTime(new JsonPrimitive(aMillis)); + DateTime b = Util.jsonPrimitiveToDateTime(new JsonPrimitive(bStamp)); + Assert.assertTrue(a.getMillis() == b.getMillis()); + } + + @Test + public void testDateTimeConversionAsUnixMillisBeforeEpoch() { + long unixMillis = -1000; + DateTime actual = Util.jsonPrimitiveToDateTime(new JsonPrimitive(unixMillis)); + Assert.assertEquals(unixMillis, actual.getMillis()); + } + + @Test + public void testDateTimeConversionInvalidString() { + String invalidTimestamp = "May 3, 1980"; + DateTime actual = Util.jsonPrimitiveToDateTime(new JsonPrimitive(invalidTimestamp)); + Assert.assertNull(actual); + } +}