diff --git a/gradle.properties b/gradle.properties index 95c8348c2..df9663c3f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=2.6.0 +version=3.0.0 ossrhUsername= ossrhPassword= diff --git a/src/main/java/com/launchdarkly/client/Clause.java b/src/main/java/com/launchdarkly/client/Clause.java index 53c315bb5..fa4055fb6 100644 --- a/src/main/java/com/launchdarkly/client/Clause.java +++ b/src/main/java/com/launchdarkly/client/Clause.java @@ -6,6 +6,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; + import java.util.List; class Clause { @@ -16,7 +18,17 @@ class Clause { private List values; //interpreted as an OR of values private boolean negate; - boolean matchesUser(LDUser user) { + public Clause() { + } + + public Clause(String attribute, Operator op, List values, boolean negate) { + this.attribute = attribute; + this.op = op; + this.values = values; + this.negate = negate; + } + + boolean matchesUserNoSegments(LDUser user) { JsonElement userValue = user.getValueForEvaluation(attribute); if (userValue == null) { return false; @@ -42,6 +54,26 @@ boolean matchesUser(LDUser user) { return false; } + boolean matchesUser(FeatureStore store, LDUser user) { + // In the case of a segment match operator, we check if the user is in any of the segments, + // and possibly negate + if (op == Operator.segmentMatch) { + for (JsonPrimitive j: values) { + if (j.isString()) { + Segment segment = store.get(SEGMENTS, j.getAsString()); + if (segment != null) { + if (segment.matchesUser(user)) { + return maybeNegate(true); + } + } + } + } + return maybeNegate(false); + } + + return matchesUserNoSegments(user); + } + private boolean matchAny(JsonPrimitive userValue) { for (JsonPrimitive v : values) { if (op.apply(userValue, v)) { @@ -59,4 +91,4 @@ private boolean maybeNegate(boolean b) { } -} +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index cbe3fd87d..a25aa3fe6 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -5,12 +5,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; + import java.lang.reflect.Type; import java.util.ArrayList; import java.util.List; import java.util.Map; -class FeatureFlag { +class FeatureFlag implements VersionedData { private final static Logger logger = LoggerFactory.getLogger(FeatureFlag.class); private static final Type mapType = new TypeToken>() { @@ -76,7 +78,7 @@ private JsonElement evaluate(LDUser user, FeatureStore featureStore, List= variations.size()) { } } - int getVersion() { + public int getVersion() { return version; } - String getKey() { + public String getKey() { return key; } - boolean isDeleted() { + public boolean isDeleted() { return deleted; } diff --git a/src/main/java/com/launchdarkly/client/FeatureRequestor.java b/src/main/java/com/launchdarkly/client/FeatureRequestor.java index b4f3ebce9..0ae25e78e 100644 --- a/src/main/java/com/launchdarkly/client/FeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/FeatureRequestor.java @@ -5,15 +5,31 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; + import java.io.IOException; +import java.util.HashMap; import java.util.Map; class FeatureRequestor { private static final Logger logger = LoggerFactory.getLogger(FeatureRequestor.class); private static final String GET_LATEST_FLAGS_PATH = "/sdk/latest-flags"; + private static final String GET_LATEST_SEGMENTS_PATH = "/sdk/latest-segments"; + private static final String GET_LATEST_ALL_PATH = "/sdk/latest-all"; private final String sdkKey; private final LDConfig config; + static class AllData { + final Map flags; + final Map segments; + + AllData(Map flags, Map segments) { + this.flags = flags; + this.segments = segments; + } + } + FeatureRequestor(String sdkKey, LDConfig config) { this.sdkKey = sdkKey; this.config = config; @@ -29,6 +45,28 @@ FeatureFlag getFlag(String featureKey) throws IOException, InvalidSDKKeyExceptio return FeatureFlag.fromJson(config, body); } + Map getAllSegments() throws IOException, InvalidSDKKeyException { + String body = get(GET_LATEST_SEGMENTS_PATH); + return Segment.fromJsonMap(config, body); + } + + Segment getSegment(String segmentKey) throws IOException, InvalidSDKKeyException { + String body = get(GET_LATEST_SEGMENTS_PATH + "/" + segmentKey); + return Segment.fromJson(config, body); + } + + AllData getAllData() throws IOException, InvalidSDKKeyException { + String body = get(GET_LATEST_ALL_PATH); + return config.gson.fromJson(body, AllData.class); + } + + static Map, Map> toVersionedDataMap(AllData allData) { + Map, Map> ret = new HashMap<>(); + ret.put(FEATURES, allData.flags); + ret.put(SEGMENTS, allData.segments); + return ret; + } + private String get(String path) throws IOException, InvalidSDKKeyException { Request request = config.getRequestBuilder(sdkKey) .url(config.baseURI.toString() + path) @@ -64,4 +102,4 @@ public static class InvalidSDKKeyException extends Exception { public InvalidSDKKeyException() { } } -} +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/FeatureStore.java b/src/main/java/com/launchdarkly/client/FeatureStore.java index 5f261afb6..62469896c 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStore.java +++ b/src/main/java/com/launchdarkly/client/FeatureStore.java @@ -4,70 +4,73 @@ import java.util.Map; /** - * A thread-safe, versioned store for {@link FeatureFlag} objects. - * Implementations should permit concurrent access and updates. - * + * A thread-safe, versioned store for feature flags and related objects received from the + * streaming API. Implementations should permit concurrent access and updates. + *

* Delete and upsert requests are versioned-- if the version number in the request is less than - * the currently stored version of the feature, the request should be ignored. - * + * the currently stored version of the object, the request should be ignored. + *

* These semantics support the primary use case for the store, which synchronizes a collection - * of features based on update messages that may be received out-of-order. - * + * of objects based on update messages that may be received out-of-order. + * @since 3.0.0 */ public interface FeatureStore extends Closeable { /** - * - * 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 + * Returns the object to which the specified key is mapped, or + * null if the key is not associated or the associated object has * been deleted. * - * @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 + * @param class of the object that will be returned + * @param kind the kind of object to get + * @param key the key whose associated object is to be returned + * @return the object to which the specified key is mapped, or + * null if the key is not associated or the associated object has * been deleted. */ - FeatureFlag get(String key); + T get(VersionedDataKind kind, String key); /** - * Returns a {@link java.util.Map} of all associated features. - * + * Returns a {@link java.util.Map} of all associated objects of a given kind. * - * @return a map of all associated features. + * @param class of the objects that will be returned in the map + * @param kind the kind of objects to get + * @return a map of all associated object. */ - Map all(); + Map all(VersionedDataKind kind); /** - * Initializes (or re-initializes) the store with the specified set of features. Any existing entries - * will be removed. Implementations can assume that this set of features is up to date-- there is no - * need to perform individual version comparisons between the existing features and the supplied + * Initializes (or re-initializes) the store with the specified set of objects. Any existing entries + * will be removed. Implementations can assume that this set of objects is up to date-- there is no + * need to perform individual version comparisons between the existing objects and the supplied * features. * - * - * @param features the features to set the store + * @param allData all objects to be stored */ - void init(Map features); + void init(Map, Map> allData); /** - * - * Deletes the feature associated with the specified key, if it exists and its version + * Deletes the object 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 class of the object to be deleted + * @param kind the kind of object to delete + * @param key the key of the object to be deleted * @param version the version for the delete operation */ - void delete(String key, int version); + void delete(VersionedDataKind kind, String key, int version); /** - * Update or insert the feature associated with the specified key, if its version - * is less than or equal to the version specified in the argument feature. + * Update or insert the object associated with the specified key, if its version + * is less than or equal to the version specified in the argument object. * - * @param key - * @param feature + * @param class of the object to be updated + * @param kind the kind of object to update + * @param item the object to update or insert */ - void upsert(String key, FeatureFlag feature); + void upsert(VersionedDataKind kind, T item); /** - * Returns true if this store has been initialized + * Returns true if this store has been initialized. * * @return true if this store has been initialized */ diff --git a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java b/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java index 3dd3aa32b..c6eee1963 100644 --- a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java @@ -9,61 +9,58 @@ import java.util.concurrent.locks.ReentrantReadWriteLock; /** - * A thread-safe, versioned store for {@link FeatureFlag} objects based on a - * {@link HashMap} + * A thread-safe, versioned store for {@link FeatureFlag} objects and related data based on a + * {@link HashMap}. This is the default implementation of {@link FeatureStore}. */ public class InMemoryFeatureStore implements FeatureStore { private static final Logger logger = LoggerFactory.getLogger(InMemoryFeatureStore.class); private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); - private final Map features = new HashMap<>(); + private final Map, Map> allData = new HashMap<>(); private volatile boolean initialized = false; - - /** - * 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 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 FeatureFlag get(String key) { + public T get(VersionedDataKind kind, String key) { try { lock.readLock().lock(); - FeatureFlag featureFlag = features.get(key); - if (featureFlag == null) { - logger.debug("[get] Key: " + key + " not found in feature store. Returning null"); + Map items = allData.get(kind); + if (items == null) { + logger.debug("[get] no objects exist for \"{}\". Returning null", kind.getNamespace()); + return null; + } + Object o = items.get(key); + if (o == null) { + logger.debug("[get] Key: {} not found in \"{}\". Returning null", key, kind.getNamespace()); + return null; + } + if (!kind.getItemClass().isInstance(o)) { + logger.warn("[get] Unexpected object class {} found for key: {} in \"{}\". Returning null", + o.getClass().getName(), key, kind.getNamespace()); return null; } - if (featureFlag.isDeleted()) { - logger.debug("[get] Key: " + key + " has been deleted. Returning null"); + T item = kind.getItemClass().cast(o); + if (item.isDeleted()) { + logger.debug("[get] Key: {} has been deleted. Returning null", key); return null; } - logger.debug("[get] Key: " + key + " with version: " + featureFlag.getVersion() + " found in feature store."); - return featureFlag; + logger.debug("[get] Key: {} with version: {} found in \"{}\".", key, item.getVersion(), kind.getNamespace()); + return item; } finally { lock.readLock().unlock(); } } - /** - * Returns a {@link java.util.Map} of all associated features. - * - * @return a map of all associated features. - */ @Override - public Map all() { + public Map all(VersionedDataKind kind) { try { lock.readLock().lock(); - Map fs = new HashMap<>(); - - for (Map.Entry entry : features.entrySet()) { - if (!entry.getValue().isDeleted()) { - fs.put(entry.getKey(), entry.getValue()); + Map fs = new HashMap<>(); + Map items = allData.get(kind); + if (items != null) { + for (Map.Entry entry : items.entrySet()) { + if (!entry.getValue().isDeleted()) { + fs.put(entry.getKey(), kind.getItemClass().cast(entry.getValue())); + } } } return fs; @@ -72,80 +69,58 @@ public Map all() { } } - - /** - * Initializes (or re-initializes) the store with the specified set of features. Any existing entries - * will be removed. - * - * @param features the features to set the store - */ + @SuppressWarnings("unchecked") @Override - public void init(Map features) { + public void init(Map, Map> allData) { try { lock.writeLock().lock(); - this.features.clear(); - this.features.putAll(features); + this.allData.clear(); + for (Map.Entry, Map> entry: allData.entrySet()) { + this.allData.put(entry.getKey(), (Map)entry.getValue()); + } initialized = true; } finally { lock.writeLock().unlock(); } } - /** - * 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 version the version for the delete operation - */ @Override - public void delete(String key, int version) { + public void delete(VersionedDataKind kind, String key, int version) { try { lock.writeLock().lock(); - FeatureFlag f = features.get(key); - if (f != null && f.getVersion() < version) { - FeatureFlagBuilder newBuilder = new FeatureFlagBuilder(f); - newBuilder.deleted(true); - 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); + Map items = allData.get(kind); + if (items == null) { + items = new HashMap<>(); + allData.put(kind, items); + } + VersionedData item = items.get(key); + if (item == null || item.getVersion() < version) { + items.put(key, kind.makeDeletedItem(key, version)); } } finally { lock.writeLock().unlock(); } } - /** - * Update or insert the feature associated with the specified key, if its version - * is less than or equal to the version specified in the argument feature. - * - * @param key - * @param feature - */ @Override - public void upsert(String key, FeatureFlag feature) { + public void upsert(VersionedDataKind kind, T item) { try { lock.writeLock().lock(); - FeatureFlag old = features.get(key); + Map items = (Map) allData.get(kind); + if (items == null) { + items = new HashMap<>(); + allData.put(kind, items); + } + VersionedData old = items.get(item.getKey()); - if (old == null || old.getVersion() < feature.getVersion()) { - features.put(key, feature); + if (old == null || old.getVersion() < item.getVersion()) { + items.put(item.getKey(), item); } } finally { lock.writeLock().unlock(); } } - /** - * Returns true if this store has been initialized - * - * @return true if this store has been initialized - */ @Override public boolean initialized() { return initialized; @@ -154,7 +129,7 @@ public boolean initialized() { /** * Does nothing; this class does not have any resources to release * - * @throws IOException + * @throws IOException will never happen */ @Override public void close() throws IOException { diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 311a5ac90..2562a1f37 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -10,6 +10,9 @@ import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; + +import static com.launchdarkly.client.VersionedDataKind.FEATURES; + import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.URL; @@ -120,15 +123,7 @@ protected StreamProcessor createStreamProcessor(String sdkKey, LDConfig config, protected PollingProcessor createPollingProcessor(LDConfig config) { return new PollingProcessor(config, requestor); } - - - /** - * 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 - */ + @Override public void track(String eventName, LDUser user, JsonElement data) { if (isOffline()) { @@ -140,12 +135,6 @@ public void track(String eventName, LDUser user, JsonElement data) { sendEvent(new CustomEvent(eventName, user, data)); } - /** - * Tracks that a user performed an event. - * - * @param eventName the name of the event - * @param user the user that performed the event - */ @Override public void track(String eventName, LDUser user) { if (isOffline()) { @@ -154,11 +143,6 @@ public void track(String eventName, LDUser user) { track(eventName, user, null); } - /** - * Registers the user. - * - * @param user the user to register - */ @Override public void identify(LDUser user) { if (user == null || user.getKey() == null) { @@ -187,17 +171,6 @@ private boolean sendEvent(Event event) { return true; } - /** - * Returns a map from feature flag keys to {@code JsonElement} feature flag values for a given user. - * If the result of a flag's evaluation would have returned the default variation, it will have a null entry - * in the map. If the client is offline, has not been initialized, or a null user or user with null/empty user key 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 {@code JsonElement} for the specified user - */ @Override public Map allFlags(LDUser user) { if (isOffline()) { @@ -218,7 +191,7 @@ public Map allFlags(LDUser user) { return null; } - Map flags = this.config.featureStore.all(); + Map flags = this.config.featureStore.all(FEATURES); Map result = new HashMap<>(); for (Map.Entry entry : flags.entrySet()) { @@ -233,80 +206,30 @@ public Map allFlags(LDUser user) { 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 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 - */ @Override public boolean boolVariation(String featureKey, LDUser user, boolean defaultValue) { JsonElement value = evaluate(featureKey, user, new JsonPrimitive(defaultValue), VariationType.Boolean); return value.getAsJsonPrimitive().getAsBoolean(); } - /** - * @deprecated use {@link #boolVariation(String, LDUser, boolean)} - */ - @Override - @Deprecated - public boolean toggle(String featureKey, LDUser user, boolean defaultValue) { - logger.warn("Deprecated method: Toggle() called. Use boolVariation() instead."); - return boolVariation(featureKey, user, defaultValue); - } - - /** - * 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 - */ @Override public Integer intVariation(String featureKey, LDUser user, int defaultValue) { JsonElement value = evaluate(featureKey, user, new JsonPrimitive(defaultValue), VariationType.Integer); return value.getAsJsonPrimitive().getAsInt(); } - /** - * 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 - */ @Override public Double doubleVariation(String featureKey, LDUser user, Double defaultValue) { JsonElement value = evaluate(featureKey, user, new JsonPrimitive(defaultValue), VariationType.Double); return value.getAsJsonPrimitive().getAsDouble(); } - /** - * 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 - */ @Override public String stringVariation(String featureKey, LDUser user, String defaultValue) { JsonElement value = evaluate(featureKey, user, new JsonPrimitive(defaultValue), VariationType.String); return value.getAsJsonPrimitive().getAsString(); } - /** - * 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 - */ @Override public JsonElement jsonVariation(String featureKey, LDUser user, JsonElement defaultValue) { JsonElement value = evaluate(featureKey, user, defaultValue, VariationType.Json); @@ -325,7 +248,7 @@ public boolean isFlagKnown(String featureKey) { } try { - if (config.featureStore.get(featureKey) != null) { + if (config.featureStore.get(FEATURES, featureKey) != null) { return true; } } catch (Exception e) { @@ -355,7 +278,7 @@ private JsonElement evaluate(String featureKey, LDUser user, JsonElement default } try { - FeatureFlag featureFlag = config.featureStore.get(featureKey); + FeatureFlag featureFlag = config.featureStore.get(FEATURES, featureKey); if (featureFlag == null) { logger.info("Unknown feature flag " + featureKey + "; returning default value"); sendFlagRequestEvent(featureKey, user, defaultValue, defaultValue, null); @@ -377,12 +300,6 @@ private JsonElement evaluate(String featureKey, LDUser user, JsonElement default return defaultValue; } - /** - * Closes the LaunchDarkly client event processing thread. This should only - * be called on application shutdown. - * - * @throws IOException - */ @Override public void close() throws IOException { logger.info("Closing LaunchDarkly Client"); @@ -404,27 +321,16 @@ public void close() throws IOException { } } - /** - * Flushes all pending events - */ @Override public void flush() { this.eventProcessor.flush(); } - /** - * @return whether the client is in offline mode - */ @Override public boolean isOffline() { return config.offline; } - /** - * For more info: https://github.com/launchdarkly/js-client#secure-mode - * @param user The User to be hashed along with the sdk key - * @return the hash, or null if the hash could not be calculated. - */ @Override public String secureModeHash(LDUser user) { if (user == null || user.getKey() == null) { @@ -450,7 +356,7 @@ public String version() { } private static String getClientVersion() { - Class clazz = LDConfig.class; + Class clazz = LDConfig.class; String className = clazz.getSimpleName() + ".class"; String classPath = clazz.getResource(className).toString(); if (!classPath.startsWith("jar")) { diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index 1a7458955..94ee3f060 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -6,39 +6,131 @@ import java.io.IOException; import java.util.Map; +/** + * This interface defines the public methods of {@link LDClient}. + */ public interface LDClientInterface extends Closeable { boolean initialized(); + /** + * 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 + */ 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 + */ void track(String eventName, LDUser user); + /** + * Registers the user. + * + * @param user the user to register + */ void identify(LDUser user); + /** + * Returns a map from feature flag keys to {@code JsonElement} feature flag values for a given user. + * If the result of a flag's evaluation would have returned the default variation, it will have a null entry + * in the map. If the client is offline, has not been initialized, or a null user or user with null/empty user key 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 {@code JsonElement} for the specified user + */ Map allFlags(LDUser user); + /** + * Calculates the value of a feature flag for a given user. + * + * @param featureKey the unique key 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 + */ boolean boolVariation(String featureKey, LDUser user, boolean defaultValue); - @Deprecated - boolean toggle(String featureKey, LDUser user, boolean defaultValue); - + /** + * Calculates the integer value of a feature flag for a given user. + * + * @param featureKey the unique key 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 + */ Integer intVariation(String featureKey, LDUser user, int defaultValue); + /** + * Calculates the floating point numeric value of a feature flag for a given user. + * + * @param featureKey the unique key 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 + */ Double doubleVariation(String featureKey, LDUser user, Double defaultValue); + /** + * Calculates the String value of a feature flag for a given user. + * + * @param featureKey the unique key 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 + */ String stringVariation(String featureKey, LDUser user, String defaultValue); + /** + * Calculates the {@link JsonElement} value of a feature flag for a given user. + * + * @param featureKey the unique key 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 + */ JsonElement jsonVariation(String featureKey, LDUser user, JsonElement defaultValue); + /** + * Returns true if the specified feature flag currently exists. + * @param featureKey the unique key for the feature flag + * @return true if the flag exists + */ boolean isFlagKnown(String featureKey); + /** + * Closes the LaunchDarkly client event processing thread. This should only + * be called on application shutdown. + * + * @throws IOException if an exception is thrown by one of the underlying network services + */ @Override void close() throws IOException; + /** + * Flushes all pending events. + */ void flush(); + /** + * Returns true if the client is in offline mode. + * @return whether the client is in offline mode + */ boolean isOffline(); + /** + * For more info: https://github.com/launchdarkly/js-client#secure-mode + * @param user the user to be hashed along with the SDK key + * @return the hash, or null if the hash could not be calculated + */ String secureModeHash(LDUser user); String version(); diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index b605ee2da..9703a0701 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -136,7 +136,6 @@ Request.Builder getRequestBuilder(String sdkKey) { /** * A builder that helps construct {@link com.launchdarkly.client.LDConfig} objects. Builder * calls can be chained, enabling the following pattern: - *

*

    * LDConfig config = new LDConfig.Builder()
    *      .connectTimeoutMillis(3)
@@ -175,9 +174,9 @@ public Builder() {
     }
 
     /**
-     * Set the base URL of the LaunchDarkly server for this configuration
+     * Set the base URL of the LaunchDarkly server for this configuration.
      *
-     * @param baseURI the base URL of the LaunchDarkly server for this configuration
+     * @param baseURI the base URL of the LaunchDarkly server for this configuration.
      * @return the builder
      */
     public Builder baseURI(URI baseURI) {
@@ -186,7 +185,7 @@ public Builder baseURI(URI baseURI) {
     }
 
     /**
-     * Set the events URL of the LaunchDarkly server for this configuration
+     * Set the base URL of the LaunchDarkly analytics event server for this configuration.
      *
      * @param eventsURI the events URL of the LaunchDarkly server for this configuration
      * @return the builder
@@ -197,7 +196,7 @@ public Builder eventsURI(URI eventsURI) {
     }
 
     /**
-     * Set the base URL of the LaunchDarkly streaming server for this configuration
+     * Set the base URL of the LaunchDarkly streaming server for this configuration.
      *
      * @param streamURI the base URL of the LaunchDarkly streaming server
      * @return the builder
@@ -207,6 +206,13 @@ public Builder streamURI(URI streamURI) {
       return this;
     }
 
+    /**
+     * Sets the implementation of {@link FeatureStore} to be used for holding feature flags and
+     * related data received from LaunchDarkly. The default is {@link InMemoryFeatureStore}, but
+     * you may use {@link RedisFeatureStore} or a custom implementation.
+     * @param store the feature store implementation
+     * @return the builder
+     */
     public Builder featureStore(FeatureStore store) {
       this.featureStore = store;
       return this;
@@ -227,7 +233,6 @@ public Builder stream(boolean stream) {
     /**
      * Set the connection timeout in seconds for the configuration. This is the time allowed for the underlying HTTP client to connect
      * to the LaunchDarkly server. The default is 2 seconds.
-     * 

*

Both this method and {@link #connectTimeoutMillis(int) connectTimeoutMillis} affect the same property internally.

* * @param connectTimeout the connection timeout in seconds @@ -241,7 +246,6 @@ public Builder connectTimeout(int connectTimeout) { /** * Set the socket timeout in seconds for the configuration. This is the number of seconds between successive packets that the * client will tolerate before flagging an error. The default is 10 seconds. - *

*

Both this method and {@link #socketTimeoutMillis(int) socketTimeoutMillis} affect the same property internally.

* * @param socketTimeout the socket timeout in seconds @@ -255,7 +259,6 @@ public Builder socketTimeout(int socketTimeout) { /** * Set the connection timeout in milliseconds for the configuration. This is the time allowed for the underlying HTTP client to connect * to the LaunchDarkly server. The default is 2000 ms. - *

*

Both this method and {@link #connectTimeout(int) connectTimeoutMillis} affect the same property internally.

* * @param connectTimeoutMillis the connection timeout in milliseconds @@ -269,7 +272,6 @@ public Builder connectTimeoutMillis(int connectTimeoutMillis) { /** * Set the socket timeout in milliseconds for the configuration. This is the number of milliseconds between successive packets that the * client will tolerate before flagging an error. The default is 10,000 milliseconds. - *

*

Both this method and {@link #socketTimeout(int) socketTimeoutMillis} affect the same property internally.

* * @param socketTimeoutMillis the socket timeout in milliseconds @@ -312,7 +314,7 @@ public Builder capacity(int capacity) { * a proxy will not be used, and {@link LDClient} will connect to LaunchDarkly directly. *

* - * @param host + * @param host the proxy hostname * @return the builder */ public Builder proxyHost(String host) { @@ -323,7 +325,7 @@ public Builder proxyHost(String host) { /** * Set the port to use for an HTTP proxy for making connections to LaunchDarkly. This is required for proxied HTTP connections. * - * @param port + * @param port the proxy port * @return the builder */ public Builder proxyPort(int port) { @@ -335,7 +337,7 @@ public Builder proxyPort(int port) { * Sets the username for the optional HTTP proxy. Only used when {@link LDConfig.Builder#proxyPassword(String)} * is also called. * - * @param username + * @param username the proxy username * @return the builder */ public Builder proxyUsername(String username) { @@ -347,30 +349,19 @@ public Builder proxyUsername(String username) { * Sets the password for the optional HTTP proxy. Only used when {@link LDConfig.Builder#proxyUsername(String)} * is also called. * - * @param password + * @param password the proxy password * @return the builder */ public Builder proxyPassword(String password) { this.proxyPassword = password; return this; } - + /** - * Deprecated. Only HTTP proxies are currently supported. + * Set whether this client should use the LaunchDarkly + * relay in daemon mode, versus subscribing to the streaming or polling API. * - * @param unused - * @return the builder - */ - @Deprecated - public Builder proxyScheme(String unused) { - return this; - } - - /** - * Set whether this client should subscribe to the streaming API, or whether the LaunchDarkly daemon is in use - * instead - * - * @param useLdd + * @param useLdd true to use the relay in daemon mode; false to use streaming or polling * @return the builder */ public Builder useLdd(boolean useLdd) { @@ -381,7 +372,7 @@ public Builder useLdd(boolean useLdd) { /** * Set whether this client is offline. * - * @param offline when set to true no calls to LaunchDarkly will be made. + * @param offline when set to true no calls to LaunchDarkly will be made * @return the builder */ public Builder offline(boolean offline) { @@ -390,10 +381,10 @@ public Builder offline(boolean offline) { } /** - * Set whether or not user attributes (other than the key) should be sent back to LaunchDarkly. If this is true, all + * Set whether or not user attributes (other than the key) should be hidden from LaunchDarkly. If this is true, all * user attribute values will be private, not just the attributes specified in {@link #privateAttributeNames(String...)}. By default, * this is false. - * @param allPrivate + * @param allPrivate true if all user attributes should be private * @return the builder */ public Builder allAttributesPrivate(boolean allPrivate) { @@ -406,7 +397,7 @@ public Builder allAttributesPrivate(boolean allPrivate) { * events. This differs from {@link offline} in that it only affects sending * analytics events, not streaming or polling for events from the server. * - * @param sendEvents when set to false, no events will be sent to LaunchDarkly. + * @param sendEvents when set to false, no events will be sent to LaunchDarkly * @return the builder */ public Builder sendEvents(boolean sendEvents) { @@ -418,7 +409,7 @@ public Builder sendEvents(boolean sendEvents) { * Set the polling interval (when streaming is disabled). Values less than the default of * 30000 will be set to the default. * - * @param pollingIntervalMillis rule update polling interval in milliseconds. + * @param pollingIntervalMillis rule update polling interval in milliseconds * @return the builder */ public Builder pollingIntervalMillis(long pollingIntervalMillis) { @@ -443,7 +434,6 @@ public Builder startWaitMillis(long startWaitMillis) { * Enable event sampling. When set to the default of zero, sampling is disabled and all events * are sent back to LaunchDarkly. When set to greater than zero, there is a 1 in * samplingInterval chance events will be will be sent. - *

*

Example: if you want 5% sampling rate, set samplingInterval to 20. * * @param samplingInterval the sampling interval. @@ -468,8 +458,7 @@ public Builder reconnectTimeMs(long reconnectTimeMs) { } /** - * - * Mark a set of attribute names private. Any users sent to LaunchDarkly with this configuration + * Marks a set of attribute names private. Any users sent to LaunchDarkly with this configuration * active will have attributes with these names removed. * * @param names a set of names that will be removed from user data set to LaunchDarkly @@ -508,7 +497,7 @@ public Request authenticate(Route route, Response response) throws IOException { } /** - * Build the configured {@link com.launchdarkly.client.LDConfig} object + * Builds the configured {@link com.launchdarkly.client.LDConfig} object. * * @return the {@link com.launchdarkly.client.LDConfig} configured by this builder */ diff --git a/src/main/java/com/launchdarkly/client/LDCountryCode.java b/src/main/java/com/launchdarkly/client/LDCountryCode.java index 2940e206e..b8aa9a758 100644 --- a/src/main/java/com/launchdarkly/client/LDCountryCode.java +++ b/src/main/java/com/launchdarkly/client/LDCountryCode.java @@ -2257,7 +2257,7 @@ public Assignment getAssignment() *

* * - * + * * * * diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index 95209dc90..6a68b64a4 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -171,7 +171,7 @@ public int hashCode() { return result; } - public static class UserAdapter extends TypeAdapter { + static class UserAdapter extends TypeAdapter { private final LDConfig config; public UserAdapter(LDConfig config) { @@ -290,7 +290,6 @@ public LDUser read(JsonReader in) throws IOException { /** * A builder that helps construct {@link LDUser} objects. Builder * calls can be chained, enabling the following pattern: - *

*

    * LDUser user = new LDUser.Builder("key")
    *      .country("US")
@@ -313,7 +312,7 @@ public static class Builder {
     private Set privateAttrNames;
 
     /**
-     * Create a builder with the specified key
+     * Creates a builder with the specified key.
      *
      * @param key the unique key for this user
      */
@@ -324,7 +323,7 @@ public Builder(String key) {
     }
 
     /**
-    * Create a builder with an existing user
+    * Creates a builder based on an existing user.
     *
     * @param user an existing {@code LDUser}
     */
@@ -349,7 +348,7 @@ public Builder(LDUser user) {
     }
     
     /**
-     * Set the IP for a user
+     * Sets the IP for a user.
      *
      * @param s the IP address for the user
      * @return the builder
@@ -360,7 +359,7 @@ public Builder ip(String s) {
     }
 
     /**
-     * Set the IP for a user, and ensures that the IP attribute is not sent back to LaunchDarkly
+     * Sets the IP for a user, and ensures that the IP attribute is not sent back to LaunchDarkly.
      *
      * @param s the IP address for the user
      * @return the builder
@@ -370,18 +369,34 @@ public Builder privateIp(String s) {
       return ip(s);
     }
 
+    /**
+     * Sets the secondary key for a user. This affects
+     * feature flag targeting
+     * as follows: if you have chosen to bucket users by a specific attribute, the secondary key (if set)
+     * is used to further distinguish between users who are otherwise identical according to that attribute.
+     * @param s the secondary key for the user
+     * @return the builder
+     */
     public Builder secondary(String s) {
       this.secondary = s;
       return this;
     }
 
+    /**
+     * Sets the secondary key for a user, and ensures that the secondary key attribute is not sent back to
+     * LaunchDarkly.
+     * @param s the secondary key for the user
+     * @return the builder
+     */
     public Builder privateSecondary(String s) {
       privateAttrNames.add("secondary");
       return secondary(s);
     }
 
     /**
-     * Set the country for a user. The country should be a valid ISO 3166-1
+     * Set the country for a user.
+     * 

+ * The country should be a valid ISO 3166-1 * alpha-2 or alpha-3 code. If it is not a valid ISO-3166-1 code, an attempt will be made to look up the country by its name. * If that fails, a warning will be logged, and the country will not be set. * @@ -415,9 +430,11 @@ public Builder country(String s) { } /** - * Set the country for a user. The country should be a valid ISO 3166-1 + * Set the country for a user, and ensures that the country attribute will not be sent back to LaunchDarkly. + *

+ * The country should be a valid ISO 3166-1 * alpha-2 or alpha-3 code. If it is not a valid ISO-3166-1 code, an attempt will be made to look up the country by its name. - * If that fails, a warning will be logged, and the country will not be set. The country attribute will not be sent back to LaunchDarkly. + * If that fails, a warning will be logged, and the country will not be set. * * @param s the country for the user * @return the builder @@ -439,7 +456,7 @@ public Builder country(LDCountryCode country) { } /** - * Set the country for a user. The country attribute will not be sent back to LaunchDarkly. + * Set the country for a user, and ensures that the country attribute will not be sent back to LaunchDarkly. * * @param country the country for the user * @return the builder @@ -462,7 +479,7 @@ public Builder firstName(String firstName) { /** - * Sets the user's first name. The first name attribute will not be sent back to LaunchDarkly. + * Sets the user's first name, and ensures that the first name attribute will not be sent back to LaunchDarkly. * * @param firstName the user's first name * @return the builder @@ -474,7 +491,7 @@ public Builder privateFirstName(String firstName) { /** - * Sets whether this user is anonymous + * Sets whether this user is anonymous. * * @param anonymous whether the user is anonymous * @return the builder @@ -485,7 +502,7 @@ public Builder anonymous(boolean anonymous) { } /** - * Sets the user's last name + * Sets the user's last name. * * @param lastName the user's last name * @return the builder @@ -496,7 +513,7 @@ public Builder lastName(String lastName) { } /** - * Sets the user's last name. The last name attribute will not be sent back to LaunchDarkly. + * Sets the user's last name, and ensures that the last name attribute will not be sent back to LaunchDarkly. * * @param lastName the user's last name * @return the builder @@ -508,7 +525,7 @@ public Builder privateLastName(String lastName) { /** - * Sets the user's full name + * Sets the user's full name. * * @param name the user's full name * @return the builder @@ -519,7 +536,7 @@ public Builder name(String name) { } /** - * Sets the user's full name. The name attribute will not be sent back to LaunchDarkly. + * Sets the user's full name, and ensures that the name attribute will not be sent back to LaunchDarkly. * * @param name the user's full name * @return the builder @@ -530,7 +547,7 @@ public Builder privateName(String name) { } /** - * Sets the user's avatar + * Sets the user's avatar. * * @param avatar the user's avatar * @return the builder @@ -541,7 +558,7 @@ public Builder avatar(String avatar) { } /** - * Sets the user's avatar. The avatar attribute will not be sent back to LaunchDarkly. + * Sets the user's avatar, and ensures that the avatar attribute will not be sent back to LaunchDarkly. * * @param avatar the user's avatar * @return the builder @@ -553,7 +570,7 @@ public Builder privateAvatar(String avatar) { /** - * Sets the user's e-mail address + * Sets the user's e-mail address. * * @param email the e-mail address * @return the builder @@ -564,7 +581,7 @@ public Builder email(String email) { } /** - * Sets the user's e-mail address. The e-mail address attribute will not be sent back to LaunchDarkly. + * Sets the user's e-mail address, and ensures that the e-mail address attribute will not be sent back to LaunchDarkly. * * @param email the e-mail address * @return the builder @@ -575,11 +592,11 @@ public Builder privateEmail(String email) { } /** - * Add a {@link java.lang.String}-valued custom attribute. When set to one of the - * - * built-in user attribute keys, this custom attribute will be ignored. + * Adds a {@link java.lang.String}-valued custom attribute. When set to one of the + * built-in + * user attribute keys, this custom attribute will be ignored. * - * @param k the key for the custom attribute. + * @param k the key for the custom attribute * @param v the value for the custom attribute * @return the builder */ @@ -592,11 +609,11 @@ public Builder custom(String k, String v) { } /** - * Add a {@link java.lang.Number}-valued custom attribute. When set to one of the - * - * built-in user attribute keys, this custom attribute will be ignored. + * Adds a {@link java.lang.Number}-valued custom attribute. When set to one of the + * built-in + * user attribute keys, this custom attribute will be ignored. * - * @param k the key for the custom attribute. When set to one of the built-in user attribute keys, this custom attribute will be ignored. + * @param k the key for the custom attribute * @param n the value for the custom attribute * @return the builder */ @@ -610,10 +627,10 @@ public Builder custom(String k, Number n) { /** * Add a {@link java.lang.Boolean}-valued custom attribute. When set to one of the - * - * built-in user attribute keys, this custom attribute will be ignored. + * built-in + * user attribute keys, this custom attribute will be ignored. * - * @param k the key for the custom attribute. When set to one of the built-in user attribute keys, this custom attribute will be ignored. + * @param k the key for the custom attribute * @param b the value for the custom attribute * @return the builder */ @@ -627,25 +644,10 @@ public Builder custom(String k, Boolean b) { /** * Add a list of {@link java.lang.String}-valued custom attributes. When set to one of the - * - * built-in user attribute keys, this custom attribute will be ignored. + * built-in + * user attribute keys, this custom attribute will be ignored. * - * @param k the key for the list. When set to one of the built-in user attribute keys, this custom attribute will be ignored. - * @param vs the values for the attribute - * @return the builder - * @deprecated As of version 0.16.0, renamed to {@link #customString(String, List) customString} - */ - public Builder custom(String k, List vs) { - checkCustomAttribute(k); - return this.customString(k, vs); - } - - /** - * Add a list of {@link java.lang.String}-valued custom attributes. When set to one of the - * - * built-in user attribute keys, this custom attribute will be ignored. - * - * @param k the key for the list. When set to one of the built-in user attribute keys, this custom attribute will be ignored. + * @param k the key for the list * @param vs the values for the attribute * @return the builder */ @@ -662,11 +664,11 @@ public Builder customString(String k, List vs) { } /** - * Add a list of {@link java.lang.Integer}-valued custom attributes. When set to one of the - * - * built-in user attribute keys, this custom attribute will be ignored. + * Add a list of {@link java.lang.Number}-valued custom attributes. When set to one of the + * built-in + * user attribute keys, this custom attribute will be ignored. * - * @param k the key for the list. When set to one of the built-in user attribute keys, this custom attribute will be ignored. + * @param k the key for the list * @param vs the values for the attribute * @return the builder */ @@ -682,14 +684,13 @@ public Builder customNumber(String k, List vs) { return this; } - /** - * Add a {@link java.lang.String}-valued custom attribute. When set to one of the - * - * built-in user attribute keys, this custom attribute will be ignored. The custom attribute value will not be sent - * back to LaunchDarkly in analytics events. + * Add a {@link java.lang.String}-valued custom attribute that will not be sent back to LaunchDarkly. + * When set to one of the + * built-in + * user attribute keys, this custom attribute will be ignored. * - * @param k the key for the custom attribute. + * @param k the key for the custom attribute * @param v the value for the custom attribute * @return the builder */ @@ -699,12 +700,12 @@ public Builder privateCustom(String k, String v) { } /** - * Add a {@link java.lang.Number}-valued custom attribute. When set to one of the - * - * built-in user attribute keys, this custom attribute will be ignored. The custom attribute value will not be sent - * back to LaunchDarkly in analytics events. + * Add a {@link java.lang.Number}-valued custom attribute that will not be sent back to LaunchDarkly. + * When set to one of the + * built-in + * user attribute keys, this custom attribute will be ignored. * - * @param k the key for the custom attribute. When set to one of the built-in user attribute keys, this custom attribute will be ignored. + * @param k the key for the custom attribute * @param n the value for the custom attribute * @return the builder */ @@ -714,12 +715,12 @@ public Builder privateCustom(String k, Number n) { } /** - * Add a {@link java.lang.Boolean}-valued custom attribute. When set to one of the - * - * built-in user attribute keys, this custom attribute will be ignored. The custom attribute value will not be sent - * back to LaunchDarkly in analytics events. + * Add a {@link java.lang.Boolean}-valued custom attribute that will not be sent back to LaunchDarkly. + * When set to one of the + * built-in + * user attribute keys, this custom attribute will be ignored. * - * @param k the key for the custom attribute. When set to one of the built-in user attribute keys, this custom attribute will be ignored. + * @param k the key for the custom attribute * @param b the value for the custom attribute * @return the builder */ @@ -728,22 +729,6 @@ public Builder privateCustom(String k, Boolean b) { return custom(k, b); } - /** - * Add a list of {@link java.lang.String}-valued custom attributes. When set to one of the - * - * built-in user attribute keys, this custom attribute will be ignored. The custom attribute value will not be sent - * back to LaunchDarkly in analytics events. - * - * @param k the key for the list. When set to one of the built-in user attribute keys, this custom attribute will be ignored. - * @param vs the values for the attribute - * @return the builder - * @deprecated As of version 0.16.0, renamed to {@link #customString(String, List) customString} - */ - public Builder privateCustom(String k, List vs) { - privateAttrNames.add(k); - return customString(k, vs); - } - /** * Add a list of {@link java.lang.String}-valued custom attributes. When set to one of the * @@ -774,7 +759,6 @@ public Builder privateCustomNumber(String k, List vs) { return customNumber(k, vs); } - private void checkCustomAttribute(String key) { for (UserAttribute a : UserAttribute.values()) { if (a.name().equals(key)) { @@ -785,7 +769,7 @@ private void checkCustomAttribute(String key) { } /** - * Build the configured {@link com.launchdarkly.client.LDUser} object + * Builds the configured {@link com.launchdarkly.client.LDUser} object. * * @return the {@link com.launchdarkly.client.LDUser} configured by this builder */ diff --git a/src/main/java/com/launchdarkly/client/Operator.java b/src/main/java/com/launchdarkly/client/Operator.java index bf78c9b04..c295ee990 100644 --- a/src/main/java/com/launchdarkly/client/Operator.java +++ b/src/main/java/com/launchdarkly/client/Operator.java @@ -90,7 +90,15 @@ public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) { public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) { return compareValues(ComparisonOp.GT, uValue, cValue, OperandType.semVer); } + }, + segmentMatch { + public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) { + // We shouldn't call apply() for this operator, because it is really implemented in + // Clause.matchesUser(). + return false; + } }; + abstract boolean apply(JsonPrimitive uValue, JsonPrimitive cValue); private static boolean compareValues(ComparisonOp op, JsonPrimitive uValue, JsonPrimitive cValue, OperandType asType) { diff --git a/src/main/java/com/launchdarkly/client/PollingProcessor.java b/src/main/java/com/launchdarkly/client/PollingProcessor.java index b81cce6fe..e3a1748df 100644 --- a/src/main/java/com/launchdarkly/client/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/client/PollingProcessor.java @@ -9,7 +9,7 @@ import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicBoolean; -public class PollingProcessor implements UpdateProcessor { +class PollingProcessor implements UpdateProcessor { private static final Logger logger = LoggerFactory.getLogger(PollingProcessor.class); private final FeatureRequestor requestor; @@ -49,7 +49,8 @@ public Future start() { @Override public void run() { try { - store.init(requestor.getAllFlags()); + FeatureRequestor.AllData allData = requestor.getAllData(); + store.init(FeatureRequestor.toVersionedDataMap(allData)); if (!initialized.getAndSet(true)) { logger.info("Initialized LaunchDarkly client."); initFuture.set(null); @@ -65,4 +66,4 @@ public void run() { return initFuture; } -} +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java index 4bf9affe3..90e91b3f3 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java @@ -1,5 +1,19 @@ package com.launchdarkly.client; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; + +import java.io.IOException; +import java.net.URI; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.google.common.base.Optional; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; @@ -9,26 +23,14 @@ import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.google.gson.Gson; -import com.google.gson.reflect.TypeToken; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; + import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; import redis.clients.jedis.JedisPoolConfig; import redis.clients.jedis.Transaction; -import java.io.IOException; -import java.lang.reflect.Type; -import java.net.URI; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.TimeUnit; - /** - * A thread-safe, versioned store for {@link FeatureFlag} objects backed by Redis. Also + * An implementation of {@link FeatureStore} backed by Redis. Also * supports an optional in-memory cache configuration that can be used to improve performance. */ public class RedisFeatureStore implements FeatureStore { @@ -37,73 +39,36 @@ 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 final LoadingCache initCache = createInitCache(); private String prefix; private ListeningExecutorService executorService; - /** - * Creates a new store instance that connects to Redis with the provided host, port, prefix, and cache timeout. Uses a default - * connection pool configuration. - * - * @param host the host for the Redis connection - * @param port the port for the Redis connection - * @param prefix a namespace prefix for all keys stored in Redis - * @param cacheTimeSecs an optional timeout for the in-memory cache. If set to 0, no in-memory caching will be performed - * @deprecated as of 1.1. Please use the {@link RedisFeatureStoreBuilder#build()} for a more flexible way of constructing a {@link RedisFeatureStore}. - */ - @Deprecated - public RedisFeatureStore(String host, int port, String prefix, long cacheTimeSecs) { - this(host, port, prefix, cacheTimeSecs, getPoolConfig()); - } - - /** - * Creates a new store instance that connects to Redis with the provided URI, prefix, and cache timeout. Uses a default - * connection pool configuration. - * - * @param uri the URI for the Redis connection - * @param prefix a namespace prefix for all keys stored in Redis - * @param cacheTimeSecs an optional timeout for the in-memory cache. If set to 0, no in-memory caching will be performed - * @deprecated as of 1.1. Please use the {@link RedisFeatureStoreBuilder#build()} for a more flexible way of constructing a {@link RedisFeatureStore}. - */ - @Deprecated - public RedisFeatureStore(URI uri, String prefix, long cacheTimeSecs) { - this(uri, prefix, cacheTimeSecs, getPoolConfig()); - } - - /** - * Creates a new store instance that connects to Redis with the provided host, port, prefix, cache timeout, and connection pool settings. - * - * @param host the host for the Redis connection - * @param port the port for the Redis connection - * @param prefix a namespace prefix for all keys stored in Redis - * @param cacheTimeSecs an optional timeout for the in-memory cache. If set to 0, no in-memory caching will be performed - * @param poolConfig an optional pool config for the Jedis connection pool - * @deprecated as of 1.1. Please use the {@link RedisFeatureStoreBuilder#build()} for a more flexible way of constructing a {@link RedisFeatureStore}. - */ - @Deprecated - public RedisFeatureStore(String host, int port, String prefix, long cacheTimeSecs, JedisPoolConfig poolConfig) { - pool = new JedisPool(poolConfig, host, port); - setPrefix(prefix); - createCache(cacheTimeSecs); - } - - /** - * Creates a new store instance that connects to Redis with the provided URI, prefix, cache timeout, and connection pool settings. - * - * @param uri the URI for the Redis connection - * @param prefix a namespace prefix for all keys stored in Redis - * @param cacheTimeSecs an optional timeout for the in-memory cache. If set to 0, no in-memory caching will be performed - * @param poolConfig an optional pool config for the Jedis connection pool - * @deprecated as of 1.1. Please use the {@link RedisFeatureStoreBuilder#build()} for a more flexible way of constructing a {@link RedisFeatureStore}. - */ - @Deprecated - public RedisFeatureStore(URI uri, String prefix, long cacheTimeSecs, JedisPoolConfig poolConfig) { - pool = new JedisPool(poolConfig, uri); - setPrefix(prefix); - createCache(cacheTimeSecs); + private static class CacheKey { + final VersionedDataKind kind; + final String key; + + public CacheKey(VersionedDataKind kind, String key) { + this.kind = kind; + this.key = key; + } + + @Override + public boolean equals(Object other) { + if (other instanceof CacheKey) { + CacheKey o = (CacheKey) other; + return o.kind.getNamespace().equals(this.kind.getNamespace()) && + o.key.equals(this.key); + } + return false; + } + + @Override + public int hashCode() { + return kind.getNamespace().hashCode() * 31 + key.hashCode(); + } } - + /** * Creates a new store instance that connects to Redis based on the provided {@link RedisFeatureStoreBuilder}. *

@@ -151,11 +116,11 @@ private void createCache(long cacheTimeSecs, boolean refreshStaleValues, boolean } } - private CacheLoader> createDefaultCacheLoader() { - return new CacheLoader>() { + private CacheLoader> createDefaultCacheLoader() { + return new CacheLoader>() { @Override - public Optional load(String key) throws Exception { - return Optional.fromNullable(getRedis(key)); + public Optional load(CacheKey key) throws Exception { + return Optional.fromNullable(getRedis(key.kind, key.key)); } }; } @@ -171,7 +136,7 @@ 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); } @@ -197,72 +162,50 @@ public Boolean load(String key) throws Exception { }); } - /** - * 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 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. - */ + @SuppressWarnings("unchecked") @Override - public FeatureFlag get(String key) { - FeatureFlag featureFlag; + public T get(VersionedDataKind kind, String key) { + T item; if (cache != null) { - featureFlag = cache.getUnchecked(key).orNull(); + item = (T) cache.getUnchecked(new CacheKey(kind, key)).orNull(); } else { - featureFlag = getRedis(key); + item = getRedis(kind, key); } - if (featureFlag != null) { - logger.debug("[get] Key: " + key + " with version: " + featureFlag.getVersion() + " found in feature store."); + if (item != null) { + logger.debug("[get] Key: {} with version: {} found in \"{}\".", key, item.getVersion(), kind.getNamespace()); } - return featureFlag; + return item; } - /** - * Returns a {@link java.util.Map} of all associated features. This implementation does not take advantage - * of the in-memory cache, so fetching all features will involve a fetch from Redis. - * - * @return a map of all associated features. - */ @Override - public Map all() { + public Map all(VersionedDataKind kind) { try (Jedis jedis = pool.getResource()) { - Map featuresJson = jedis.hgetAll(featuresKey()); - Map result = new HashMap<>(); + Map allJson = jedis.hgetAll(itemsKey(kind)); + Map result = new HashMap<>(); Gson gson = new Gson(); - Type type = new TypeToken() { - }.getType(); - - for (Map.Entry entry : featuresJson.entrySet()) { - FeatureFlag featureFlag = gson.fromJson(entry.getValue(), type); - if (!featureFlag.isDeleted()) { - result.put(entry.getKey(), featureFlag); + for (Map.Entry entry : allJson.entrySet()) { + T item = gson.fromJson(entry.getValue(), kind.getItemClass()); + if (!item.isDeleted()) { + result.put(entry.getKey(), item); } } return result; } } - /** - * Initializes (or re-initializes) the store with the specified set of features. Any existing entries - * will be removed. - * - * @param features the features to set the store - */ @Override - public void init(Map features) { + public void init(Map, Map> allData) { try (Jedis jedis = pool.getResource()) { Gson gson = new Gson(); Transaction t = jedis.multi(); - t.del(featuresKey()); - - for (FeatureFlag f : features.values()) { - t.hset(featuresKey(), f.getKey(), gson.toJson(f)); + for (Map.Entry, Map> entry: allData.entrySet()) { + String baseKey = itemsKey(entry.getKey()); + t.del(baseKey); + for (VersionedData item: entry.getValue().values()) { + t.hset(baseKey, item.getKey(), gson.toJson(item)); + } } t.exec(); @@ -271,36 +214,29 @@ public void init(Map features) { initCache.put(INIT_KEY, true); } - /** - * 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 version the version for the delete operation - */ @Override - public void delete(String key, int version) { + public void delete(VersionedDataKind kind, String key, int version) { Jedis jedis = null; try { Gson gson = new Gson(); jedis = pool.getResource(); - jedis.watch(featuresKey()); + String baseKey = itemsKey(kind); + jedis.watch(baseKey); - FeatureFlag feature = getRedis(key, jedis); + VersionedData item = getRedis(kind, key, jedis); - if (feature != null && feature.getVersion() >= version) { - logger.warn("Attempted to delete flag: " + key + " version: " + feature.getVersion() + - " with a version that is the same or older: " + version); + if (item != null && item.getVersion() >= version) { + logger.warn("Attempted to delete key: {} version: {}" + + " with a version that is the same or older: {} in \"{}\"", + key, item.getVersion(), version, kind.getNamespace()); return; } - FeatureFlagBuilder newBuilder = new FeatureFlagBuilder(feature); - newBuilder.deleted(true); - newBuilder.version(version); - jedis.hset(featuresKey(), key, gson.toJson(newBuilder.build())); + VersionedData deletedItem = kind.makeDeletedItem(key, version); + jedis.hset(baseKey, key, gson.toJson(deletedItem)); if (cache != null) { - cache.invalidate(key); + cache.invalidate(new CacheKey(kind, key)); } } finally { if (jedis != null) { @@ -310,33 +246,28 @@ public void delete(String key, int version) { } } - /** - * Update or insert the feature associated with the specified key, if its version - * is less than or equal to the version specified in the argument feature. - * - * @param key - * @param feature - */ @Override - public void upsert(String key, FeatureFlag feature) { + public void upsert(VersionedDataKind kind, T item) { Jedis jedis = null; try { jedis = pool.getResource(); Gson gson = new Gson(); - jedis.watch(featuresKey()); + String baseKey = itemsKey(kind); + jedis.watch(baseKey); - FeatureFlag f = getRedisEvenIfDeleted(key, jedis); + VersionedData old = getRedisEvenIfDeleted(kind, item.getKey(), jedis); - if (f != null && f.getVersion() >= feature.getVersion()) { - logger.warn("Attempted to update flag: " + key + " version: " + f.getVersion() + - " with a version that is the same or older: " + feature.getVersion()); + if (old != null && old.getVersion() >= item.getVersion()) { + logger.warn("Attempted to update key: {} version: {}" + + " with a version that is the same or older: {} in \"{}\"", + item.getKey(), old.getVersion(), item.getVersion(), kind.getNamespace()); return; } - jedis.hset(featuresKey(), key, gson.toJson(feature)); + jedis.hset(baseKey, item.getKey(), gson.toJson(item)); if (cache != null) { - cache.invalidate(key); + cache.invalidate(new CacheKey(kind, item.getKey())); } } finally { if (jedis != null) { @@ -346,11 +277,6 @@ public void upsert(String key, FeatureFlag feature) { } } - /** - * Returns true if this store has been initialized - * - * @return true if this store has been initialized - */ @Override public boolean initialized() { // The LoadingCache takes care of both coalescing multiple simultaneous requests and memoizing @@ -362,7 +288,7 @@ public boolean initialized() { /** * Releases all resources associated with the store. The store must no longer be used once closed. * - * @throws IOException + * @throws IOException if an underlying service threw an exception */ public void close() throws IOException { logger.info("Closing LaunchDarkly RedisFeatureStore"); @@ -387,41 +313,41 @@ public CacheStats getCacheStats() { return null; } - private String featuresKey() { - return prefix + ":features"; + private String itemsKey(VersionedDataKind kind) { + return prefix + ":" + kind.getNamespace(); } private Boolean getInit() { try (Jedis jedis = pool.getResource()) { - return jedis.exists(featuresKey()); + return jedis.exists(itemsKey(FEATURES)); } } - private FeatureFlag getRedis(String key) { + private T getRedis(VersionedDataKind kind, String key) { try (Jedis jedis = pool.getResource()) { - return getRedis(key, jedis); + return getRedis(kind, key, jedis); } } - private FeatureFlag getRedis(String key, Jedis jedis) { - FeatureFlag f = getRedisEvenIfDeleted(key, jedis); - if (f != null && f.isDeleted()) { - logger.debug("[get] Key: " + key + " has been deleted. Returning null"); + private T getRedis(VersionedDataKind kind, String key, Jedis jedis) { + T item = getRedisEvenIfDeleted(kind, key, jedis); + if (item != null && item.isDeleted()) { + logger.debug("[get] Key: {} has been deleted in \"{}\". Returning null", key, kind.getNamespace()); return null; } - return f; + return item; } - private FeatureFlag getRedisEvenIfDeleted(String key, Jedis jedis) { + private T getRedisEvenIfDeleted(VersionedDataKind kind, String key, Jedis jedis) { Gson gson = new Gson(); - String featureJson = jedis.hget(featuresKey(), key); + String json = jedis.hget(itemsKey(kind), key); - if (featureJson == null) { - logger.debug("[get] Key: " + key + " not found in feature store. Returning null"); + if (json == null) { + logger.debug("[get] Key: {} not found in \"{}\". Returning null", key, kind.getNamespace()); return null; } - return gson.fromJson(featureJson, FeatureFlag.class); + return gson.fromJson(json, kind.getItemClass()); } private static JedisPoolConfig getPoolConfig() { diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java index 2d75b2686..ebebffbf8 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java @@ -52,7 +52,7 @@ public RedisFeatureStoreBuilder(URI uri, long cacheTimeSecs) { * @param host the hostname to connect to * @param port the port to connect to * @param cacheTimeSecs the cache time in seconds. See {@link RedisFeatureStoreBuilder#cacheTime(long, TimeUnit)} for more information. - * @throws URISyntaxException + * @throws URISyntaxException if the URI is not valid */ public RedisFeatureStoreBuilder(String scheme, String host, int port, long cacheTimeSecs) throws URISyntaxException { this.uri = new URI(scheme, null, host, port, null, null, null); @@ -110,7 +110,7 @@ public RedisFeatureStoreBuilder asyncRefresh(boolean enabled) { /** * Optionally configures the namespace prefix for all keys stored in Redis. * - * @param prefix + * @param prefix the namespace prefix * @return the builder */ public RedisFeatureStoreBuilder prefix(String prefix) { diff --git a/src/main/java/com/launchdarkly/client/Rule.java b/src/main/java/com/launchdarkly/client/Rule.java index f240a9741..6009abc74 100644 --- a/src/main/java/com/launchdarkly/client/Rule.java +++ b/src/main/java/com/launchdarkly/client/Rule.java @@ -20,12 +20,12 @@ class Rule extends VariationOrRollout { this.clauses = clauses; } - boolean matchesUser(LDUser user) { + boolean matchesUser(FeatureStore store, LDUser user) { for (Clause clause : clauses) { - if (!clause.matchesUser(user)) { + if (!clause.matchesUser(store, user)) { return false; } } return true; } -} +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/Segment.java b/src/main/java/com/launchdarkly/client/Segment.java new file mode 100644 index 000000000..eb7fc24d8 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/Segment.java @@ -0,0 +1,148 @@ +package com.launchdarkly.client; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import com.google.gson.reflect.TypeToken; + +class Segment implements VersionedData { + + private static final Type mapType = new TypeToken>() { }.getType(); + + private String key; + private List included; + private List excluded; + private String salt; + private List rules; + private int version; + private boolean deleted; + + static Segment fromJson(LDConfig config, String json) { + return config.gson.fromJson(json, Segment.class); + } + + static Map fromJsonMap(LDConfig config, String json) { + return config.gson.fromJson(json, mapType); + } + + // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation + Segment() {} + + private Segment(Builder builder) { + this.key = builder.key; + this.included = builder.included; + this.excluded = builder.excluded; + this.salt = builder.salt; + this.rules = builder.rules; + this.version = builder.version; + this.deleted = builder.deleted; + } + + public String getKey() { + return key; + } + + public Iterable getIncluded() { + return included; + } + + public Iterable getExcluded() { + return excluded; + } + + public String getSalt() { + return salt; + } + + public Iterable getRules() { + return rules; + } + + public int getVersion() { + return version; + } + + public boolean isDeleted() { + return deleted; + } + + public boolean matchesUser(LDUser user) { + String key = user.getKeyAsString(); + if (key == null) { + return false; + } + if (included.contains(key)) { + return true; + } + if (excluded.contains(key)) { + return false; + } + for (SegmentRule rule: rules) { + if (rule.matchUser(user, key, salt)) { + return true; + } + } + return false; + } + + public static class Builder { + private String key; + private List included = new ArrayList<>(); + private List excluded = new ArrayList<>(); + private String salt = ""; + private List rules = new ArrayList<>(); + private int version = 0; + private boolean deleted; + + public Builder(String key) { + this.key = key; + } + + public Builder(Segment from) { + this.key = from.key; + this.included = new ArrayList<>(from.included); + this.excluded = new ArrayList<>(from.excluded); + this.salt = from.salt; + this.rules = new ArrayList<>(from.rules); + this.version = from.version; + this.deleted = from.deleted; + } + + public Segment build() { + return new Segment(this); + } + + public Builder included(Collection included) { + this.included = new ArrayList<>(included); + return this; + } + + public Builder excluded(Collection excluded) { + this.excluded = new ArrayList<>(excluded); + return this; + } + + public Builder salt(String salt) { + this.salt = salt; + return this; + } + + public Builder rules(Collection rules) { + this.rules = new ArrayList<>(rules); + return this; + } + + public Builder version(int version) { + this.version = version; + return this; + } + + public Builder deleted(boolean deleted) { + this.deleted = deleted; + return this; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/SegmentRule.java b/src/main/java/com/launchdarkly/client/SegmentRule.java new file mode 100644 index 000000000..4498eb7d6 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/SegmentRule.java @@ -0,0 +1,34 @@ +package com.launchdarkly.client; + +import java.util.List; + +public class SegmentRule { + private final List clauses; + private final Integer weight; + private final String bucketBy; + + public SegmentRule(List clauses, Integer weight, String bucketBy) { + this.clauses = clauses; + this.weight = weight; + this.bucketBy = bucketBy; + } + + public boolean matchUser(LDUser user, String segmentKey, String salt) { + for (Clause c: clauses) { + if (!c.matchesUserNoSegments(user)) { + return false; + } + } + + // If the Weight is absent, this rule matches + if (weight == null) { + return true; + } + + // All of the clauses are met. See if the user buckets in + String by = (bucketBy == null) ? "key" : bucketBy; + double bucket = VariationOrRollout.bucketUser(user, segmentKey, by, salt); + double weight = (double)this.weight / 100000.0; + return bucket < weight; + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index bae1da257..a3008fb88 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -1,5 +1,8 @@ package com.launchdarkly.client; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; + import java.io.IOException; import java.net.URI; import java.util.concurrent.Future; @@ -10,6 +13,7 @@ import com.google.common.util.concurrent.SettableFuture; import com.google.gson.Gson; +import com.google.gson.JsonElement; import com.launchdarkly.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.EventHandler; import com.launchdarkly.eventsource.EventSource; @@ -78,26 +82,41 @@ public void onClosed() throws Exception { public void onMessage(String name, MessageEvent event) throws Exception { Gson gson = new Gson(); switch (name) { - case PUT: - store.init(FeatureFlag.fromJsonMap(config, event.getData())); + case PUT: { + FeatureRequestor.AllData allData = gson.fromJson(event.getData(), FeatureRequestor.AllData.class); + store.init(FeatureRequestor.toVersionedDataMap(allData)); if (!initialized.getAndSet(true)) { initFuture.set(null); logger.info("Initialized LaunchDarkly client."); } break; + } case PATCH: { - FeaturePatchData data = gson.fromJson(event.getData(), FeaturePatchData.class); - store.upsert(data.key(), data.feature()); + PatchData data = gson.fromJson(event.getData(), PatchData.class); + if (FEATURES.getKeyFromStreamApiPath(data.path) != null) { + store.upsert(FEATURES, gson.fromJson(data.data, FeatureFlag.class)); + } else if (SEGMENTS.getKeyFromStreamApiPath(data.path) != null) { + store.upsert(SEGMENTS, gson.fromJson(data.data, Segment.class)); + } break; } case DELETE: { - FeatureDeleteData data = gson.fromJson(event.getData(), FeatureDeleteData.class); - store.delete(data.key(), data.version()); + DeleteData data = gson.fromJson(event.getData(), DeleteData.class); + String featureKey = FEATURES.getKeyFromStreamApiPath(data.path); + if (featureKey != null) { + store.delete(FEATURES, featureKey, data.version); + } else { + String segmentKey = SEGMENTS.getKeyFromStreamApiPath(data.path); + if (segmentKey != null) { + store.delete(SEGMENTS, segmentKey, data.version); + } + } break; } case INDIRECT_PUT: try { - store.init(requestor.getAllFlags()); + FeatureRequestor.AllData allData = requestor.getAllData(); + store.init(FeatureRequestor.toVersionedDataMap(allData)); if (!initialized.getAndSet(true)) { initFuture.set(null); logger.info("Initialized LaunchDarkly client."); @@ -107,10 +126,19 @@ public void onMessage(String name, MessageEvent event) throws Exception { } break; case INDIRECT_PATCH: - String key = event.getData(); + String path = event.getData(); try { - FeatureFlag feature = requestor.getFlag(key); - store.upsert(key, feature); + String featureKey = FEATURES.getKeyFromStreamApiPath(path); + if (featureKey != null) { + FeatureFlag feature = requestor.getFlag(featureKey); + store.upsert(FEATURES, feature); + } else { + String segmentKey = SEGMENTS.getKeyFromStreamApiPath(path); + if (segmentKey != null) { + Segment segment = requestor.getSegment(segmentKey); + store.upsert(SEGMENTS, segment); + } + } } catch (IOException e) { logger.error("Encountered exception in LaunchDarkly client", e); } @@ -133,7 +161,7 @@ public void onError(Throwable throwable) { } }; - EventSource.Builder builder = new EventSource.Builder(handler, URI.create(config.streamURI.toASCIIString() + "/flags")) + EventSource.Builder builder = new EventSource.Builder(handler, URI.create(config.streamURI.toASCIIString() + "/all")) .connectionErrorHandler(connectionErrorHandler) .headers(headers) .reconnectTimeMs(config.reconnectTimeMs) @@ -172,43 +200,21 @@ public boolean initialized() { return initialized.get(); } - FeatureFlag getFeature(String key) { - return store.get(key); - } - - private static final class FeaturePatchData { + private static final class PatchData { String path; - FeatureFlag data; - - public FeaturePatchData() { + JsonElement data; - } - - String key() { - return path.substring(1); - } + public PatchData() { - FeatureFlag feature() { - return data; } - } - private static final class FeatureDeleteData { + private static final class DeleteData { String path; int version; - public FeatureDeleteData() { + public DeleteData() { } - - String key() { - return path.substring(1); - } - - int version() { - return version; - } - } -} +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/TestFeatureStore.java b/src/main/java/com/launchdarkly/client/TestFeatureStore.java index d8605ba14..d69e06d2a 100644 --- a/src/main/java/com/launchdarkly/client/TestFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/TestFeatureStore.java @@ -1,19 +1,22 @@ package com.launchdarkly.client; -import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; + /** * 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( + static List TRUE_FALSE_VARIATIONS = Arrays.asList( (JsonElement) (new JsonPrimitive(true)), (JsonElement) (new JsonPrimitive(false)) ); @@ -34,45 +37,29 @@ public void setBooleanValue(String key, Boolean value) { .variations(TRUE_FALSE_VARIATIONS) .version(version.incrementAndGet()) .build(); - upsert(key, newFeature); + upsert(FEATURES, newFeature); } /** * 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. + * @param key the key of the feature flag to evaluate to true */ public void setFeatureTrue(String key) { setBooleanValue(key, true); } - - /** - * @deprecated use {@link #setFeatureTrue(String key)} - */ - @Deprecated - public void turnFeatureOn(String key) { - setFeatureTrue(key); - } - + /** * 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. + * @param key the key of the feature flag to evaluate to false */ public void setFeatureFalse(String key) { setBooleanValue(key, false); } - - /** - * @deprecated use {@link #setFeatureFalse(String key)} - */ - @Deprecated - public void turnFeatureOff(String key) { - setFeatureFalse(key); - } - + /** * Sets the value of an integer multivariate feature flag, for all users. * @param key the key of the flag @@ -112,12 +99,12 @@ public void setJsonValue(String key, JsonElement value) { .variations(Arrays.asList(value)) .version(version.incrementAndGet()) .build(); - upsert(key, newFeature); + upsert(FEATURES, newFeature); } @Override - public void init(java.util.Map features) { - super.init(features); + public void init(Map, Map> allData) { + super.init(allData); initializedForTests = true; } diff --git a/src/main/java/com/launchdarkly/client/UpdateProcessor.java b/src/main/java/com/launchdarkly/client/UpdateProcessor.java index d5d2876f7..5a240260d 100644 --- a/src/main/java/com/launchdarkly/client/UpdateProcessor.java +++ b/src/main/java/com/launchdarkly/client/UpdateProcessor.java @@ -4,7 +4,7 @@ import java.io.IOException; import java.util.concurrent.Future; -public interface UpdateProcessor extends Closeable { +interface UpdateProcessor extends Closeable { /** * Starts the client. @@ -14,7 +14,7 @@ public interface UpdateProcessor extends Closeable { /** * Returns true once the client has been initialized and will never return false again. - * @return + * @return true if the client has been initialized */ boolean initialized(); diff --git a/src/main/java/com/launchdarkly/client/VariationType.java b/src/main/java/com/launchdarkly/client/VariationType.java index cd8b7e904..8d8228f1b 100644 --- a/src/main/java/com/launchdarkly/client/VariationType.java +++ b/src/main/java/com/launchdarkly/client/VariationType.java @@ -3,7 +3,7 @@ import com.google.gson.JsonElement; -public enum VariationType { +enum VariationType { Boolean { @Override void assertResultType(JsonElement result) throws EvaluationException { diff --git a/src/main/java/com/launchdarkly/client/VersionedData.java b/src/main/java/com/launchdarkly/client/VersionedData.java new file mode 100644 index 000000000..6e01b9997 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/VersionedData.java @@ -0,0 +1,11 @@ +package com.launchdarkly.client; + +/** + * Common interface for string-keyed, versioned objects that can be kept in a {@link FeatureStore}. + * @since 3.0.0 + */ +public interface VersionedData { + String getKey(); + int getVersion(); + boolean isDeleted(); +} diff --git a/src/main/java/com/launchdarkly/client/VersionedDataKind.java b/src/main/java/com/launchdarkly/client/VersionedDataKind.java new file mode 100644 index 000000000..05ce87f33 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/VersionedDataKind.java @@ -0,0 +1,82 @@ +package com.launchdarkly.client; + +/** + * The descriptor for a specific kind of {@link VersionedData} objects that may exist in a {@link FeatureStore}. + * @since 3.0.0 + */ +public abstract class VersionedDataKind { + + /** + * A short string that serves as the unique name for the collection of these objects, e.g. "features". + * @return a namespace string + */ + public abstract String getNamespace(); + + /** + * The Java class for objects of this type. + * @return a Java class + */ + public abstract Class getItemClass(); + + /** + * The path prefix for objects of this type in events received from the streaming API. + * @return the URL path + */ + public abstract String getStreamApiPath(); + + /** + * Return an instance of this type with the specified key and version, and deleted=true. + * @param key the unique key + * @param version the version number + * @return a new instance + */ + public abstract T makeDeletedItem(String key, int version); + + /** + * Used internally to match data URLs in the streaming API. + * @param path path from an API message + * @return the parsed key if the path refers to an object of this kind, otherwise null + */ + String getKeyFromStreamApiPath(String path) { + return path.startsWith(getStreamApiPath()) ? path.substring(getStreamApiPath().length()) : null; + } + + + public static VersionedDataKind FEATURES = new VersionedDataKind() { + + public String getNamespace() { + return "features"; + } + + public Class getItemClass() { + return FeatureFlag.class; + } + + public String getStreamApiPath() { + return "/flags/"; + } + + public FeatureFlag makeDeletedItem(String key, int version) { + return new FeatureFlagBuilder(key).deleted(true).version(version).build(); + } + }; + + public static VersionedDataKind SEGMENTS = new VersionedDataKind() { + + public String getNamespace() { + return "segments"; + } + + public Class getItemClass() { + return Segment.class; + } + + public String getStreamApiPath() { + return "/segments/"; + } + + public Segment makeDeletedItem(String key, int version) { + return new Segment.Builder(key).deleted(true).version(version).build(); + } + }; +} diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index 4c27d6f1d..9f4c568d8 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -1,14 +1,16 @@ package com.launchdarkly.client; - import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; + import org.junit.Assert; import org.junit.Before; import org.junit.Test; import java.util.Arrays; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import static java.util.Collections.singletonList; public class FeatureFlagTest { @@ -26,7 +28,7 @@ public void testPrereqDoesNotExist() throws EvaluationException { String keyB = "keyB"; FeatureFlag f1 = newFlagWithPrereq(keyA, keyB); - featureStore.upsert(f1.getKey(), f1); + featureStore.upsert(FEATURES, f1); LDUser user = new LDUser.Builder("userKey").build(); FeatureFlag.EvalResult actual = f1.evaluate(user, featureStore); @@ -44,9 +46,9 @@ public void testPrereqCollectsEventsForPrereqs() throws EvaluationException { FeatureFlag flagB = newFlagWithPrereq(keyB, keyC); FeatureFlag flagC = newFlagOff(keyC); - featureStore.upsert(flagA.getKey(), flagA); - featureStore.upsert(flagB.getKey(), flagB); - featureStore.upsert(flagC.getKey(), flagC); + featureStore.upsert(FEATURES, flagA); + featureStore.upsert(FEATURES, flagB); + featureStore.upsert(FEATURES, flagC); LDUser user = new LDUser.Builder("userKey").build(); @@ -66,6 +68,30 @@ public void testPrereqCollectsEventsForPrereqs() throws EvaluationException { Assert.assertEquals(0, flagCResult.getPrerequisiteEvents().size()); } + @Test + public void testSegmentMatchClauseRetrievesSegmentFromStore() throws Exception { + Segment segment = new Segment.Builder("segkey") + .included(Arrays.asList("foo")) + .version(1) + .build(); + featureStore.upsert(SEGMENTS, segment); + + FeatureFlag flag = segmentMatchBooleanFlag("segkey"); + LDUser user = new LDUser.Builder("foo").build(); + + FeatureFlag.EvalResult result = flag.evaluate(user, featureStore); + Assert.assertEquals(new JsonPrimitive(true), result.getValue()); + } + + @Test + public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Exception { + FeatureFlag flag = segmentMatchBooleanFlag("segkey"); + LDUser user = new LDUser.Builder("foo").build(); + + FeatureFlag.EvalResult result = flag.evaluate(user, featureStore); + Assert.assertEquals(new JsonPrimitive(false), result.getValue()); + } + private FeatureFlag newFlagWithPrereq(String featureKey, String prereqKey) { return new FeatureFlagBuilder(featureKey) .prerequisites(singletonList(new Prerequisite(prereqKey, 0))) @@ -82,4 +108,15 @@ private FeatureFlag newFlagOff(String featureKey) { .on(false) .build(); } + + private FeatureFlag segmentMatchBooleanFlag(String segmentKey) { + Clause clause = new Clause("", Operator.segmentMatch, Arrays.asList(new JsonPrimitive(segmentKey)), false); + Rule rule = new Rule(Arrays.asList(clause), 1, null); + return new FeatureFlagBuilder("key") + .variations(Arrays.asList(new JsonPrimitive(false), new JsonPrimitive(true))) + .fallthrough(new VariationOrRollout(0, null)) + .on(true) + .rules(Arrays.asList(rule)) + .build(); + } } diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java b/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java index c93758176..e82ef1012 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java +++ b/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java @@ -1,14 +1,14 @@ package com.launchdarkly.client; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import java.util.HashMap; +import java.util.Map; -import org.junit.Assert; import org.junit.Test; public abstract class FeatureStoreTestBase { @@ -29,7 +29,9 @@ protected void initStore() { HashMap flags = new HashMap<>(); flags.put(feature1.getKey(), feature1); flags.put(feature2.getKey(), feature2); - store.init(flags); + Map, Map> allData = new HashMap<>(); + allData.put(FEATURES, flags); + store.init(allData); } @Test @@ -41,14 +43,14 @@ public void storeInitializedAfterInit() { @Test public void getExistingFeature() { initStore(); - FeatureFlag result = store.get(feature1.getKey()); + FeatureFlag result = store.get(FEATURES, feature1.getKey()); assertEquals(feature1.getKey(), result.getKey()); } @Test public void getNonexistingFeature() { initStore(); - assertNull(store.get("biz")); + assertNull(store.get(FEATURES, "biz")); } @Test @@ -57,8 +59,8 @@ public void upsertWithNewerVersion() { FeatureFlag newVer = new FeatureFlagBuilder(feature1) .version(feature1.getVersion() + 1) .build(); - store.upsert(newVer.getKey(), newVer); - FeatureFlag result = store.get(newVer.getKey()); + store.upsert(FEATURES, newVer); + FeatureFlag result = store.get(FEATURES, newVer.getKey()); assertEquals(newVer.getVersion(), result.getVersion()); } @@ -68,8 +70,8 @@ public void upsertWithOlderVersion() { FeatureFlag oldVer = new FeatureFlagBuilder(feature1) .version(feature1.getVersion() - 1) .build(); - store.upsert(oldVer.getKey(), oldVer); - FeatureFlag result = store.get(oldVer.getKey()); + store.upsert(FEATURES, oldVer); + FeatureFlag result = store.get(FEATURES, oldVer.getKey()); assertEquals(feature1.getVersion(), result.getVersion()); } @@ -79,37 +81,37 @@ public void upsertNewFeature() { FeatureFlag newFeature = new FeatureFlagBuilder("biz") .version(99) .build(); - store.upsert(newFeature.getKey(), newFeature); - FeatureFlag result = store.get(newFeature.getKey()); + store.upsert(FEATURES, newFeature); + FeatureFlag result = store.get(FEATURES, newFeature.getKey()); assertEquals(newFeature.getKey(), result.getKey()); } @Test public void deleteWithNewerVersion() { initStore(); - store.delete(feature1.getKey(), feature1.getVersion() + 1); - assertNull(store.get(feature1.getKey())); + store.delete(FEATURES, feature1.getKey(), feature1.getVersion() + 1); + assertNull(store.get(FEATURES, feature1.getKey())); } @Test public void deleteWithOlderVersion() { initStore(); - store.delete(feature1.getKey(), feature1.getVersion() - 1); - assertNotNull(store.get(feature1.getKey())); + store.delete(FEATURES, feature1.getKey(), feature1.getVersion() - 1); + assertNotNull(store.get(FEATURES, feature1.getKey())); } @Test public void deleteUnknownFeature() { initStore(); - store.delete("biz", 11); - assertNull(store.get("biz")); + store.delete(FEATURES, "biz", 11); + assertNull(store.get(FEATURES, "biz")); } @Test public void upsertOlderVersionAfterDelete() { initStore(); - store.delete(feature1.getKey(), feature1.getVersion() + 1); - store.upsert(feature1.getKey(), feature1); - assertNull(store.get(feature1.getKey())); + store.delete(FEATURES, feature1.getKey(), feature1.getVersion() + 1); + store.upsert(FEATURES, feature1); + assertNull(store.get(FEATURES, feature1.getKey())); } } diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index a16a933ee..30b2646ec 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -11,12 +11,15 @@ import org.junit.Test; import java.io.IOException; +import java.util.Arrays; import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import static org.easymock.EasyMock.anyObject; import static org.easymock.EasyMock.expect; import static org.junit.Assert.*; @@ -349,6 +352,50 @@ public void testIsFlagKnownCallBeforeInitializationButFeatureStoreIsInited() thr verifyAll(); } + @Test + public void testFeatureMatchesUserBySegment() throws Exception { + TestFeatureStore testFeatureStore = new TestFeatureStore(); + testFeatureStore.setInitialized(true); + LDConfig config = new LDConfig.Builder() + .startWaitMillis(10L) + .stream(false) + .featureStore(testFeatureStore) + .build(); + + expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andReturn(new Object()); + expect(pollingProcessor.start()).andReturn(initFuture); + expect(pollingProcessor.initialized()).andReturn(true).times(1); + expect(eventProcessor.sendEvent(anyObject(Event.class))).andReturn(true).times(1); + replayAll(); + + client = createMockClient(config); + + Segment segment = new Segment.Builder("segment1") + .version(1) + .included(Arrays.asList("user")) + .build(); + testFeatureStore.upsert(SEGMENTS, segment); + + Clause clause = new Clause( + "", + Operator.segmentMatch, + Arrays.asList(new JsonPrimitive("segment1")), + false); + Rule rule = new Rule(Arrays.asList(clause), 0, null); + FeatureFlag feature = new FeatureFlagBuilder("test-feature") + .version(1) + .rules(Arrays.asList(rule)) + .variations(TestFeatureStore.TRUE_FALSE_VARIATIONS) + .on(true) + .fallthrough(new VariationOrRollout(1, null)) + .build(); + testFeatureStore.upsert(FEATURES, feature); + + assertTrue(client.boolVariation("test-feature", new LDUser("user"), false)); + + verifyAll(); + } + @Test public void testUseLdd() throws IOException { LDConfig config = new LDConfig.Builder() @@ -556,4 +603,4 @@ protected EventProcessor createEventProcessor(String sdkKey, LDConfig config) { } }; } -} +} \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java index c2062319d..30d06bb99 100644 --- a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java @@ -20,8 +20,8 @@ public void testConnectionOk() throws Exception { FeatureRequestor requestor = createStrictMock(FeatureRequestor.class); PollingProcessor pollingProcessor = new PollingProcessor(LDConfig.DEFAULT, requestor); - expect(requestor.getAllFlags()) - .andReturn(new HashMap()) + expect(requestor.getAllData()) + .andReturn(new FeatureRequestor.AllData(new HashMap(), new HashMap())) .once(); replayAll(); @@ -37,7 +37,7 @@ public void testConnectionProblem() throws Exception { FeatureRequestor requestor = createStrictMock(FeatureRequestor.class); PollingProcessor pollingProcessor = new PollingProcessor(LDConfig.DEFAULT, requestor); - expect(requestor.getAllFlags()) + expect(requestor.getAllData()) .andThrow(new IOException("This exception is part of a test and yes you should be seeing it.")) .once(); replayAll(); @@ -53,4 +53,4 @@ public void testConnectionProblem() throws Exception { pollingProcessor.close(); verifyAll(); } -} +} \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/client/SegmentTest.java b/src/test/java/com/launchdarkly/client/SegmentTest.java new file mode 100644 index 000000000..0bc45f7a5 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/SegmentTest.java @@ -0,0 +1,141 @@ +package com.launchdarkly.client; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; + +import org.junit.Test; + +import com.google.gson.JsonPrimitive; + +public class SegmentTest { + + private int maxWeight = 100000; + + @Test + public void explicitIncludeUser() { + Segment s = new Segment.Builder("test") + .included(Arrays.asList("foo")) + .salt("abcdef") + .version(1) + .build(); + LDUser u = new LDUser.Builder("foo").build(); + + assertTrue(s.matchesUser(u)); + } + + @Test + public void explicitExcludeUser() { + Segment s = new Segment.Builder("test") + .excluded(Arrays.asList("foo")) + .salt("abcdef") + .version(1) + .build(); + LDUser u = new LDUser.Builder("foo").build(); + + assertFalse(s.matchesUser(u)); + } + + @Test + public void explicitIncludeHasPrecedence() { + Segment s = new Segment.Builder("test") + .included(Arrays.asList("foo")) + .excluded(Arrays.asList("foo")) + .salt("abcdef") + .version(1) + .build(); + LDUser u = new LDUser.Builder("foo").build(); + + assertTrue(s.matchesUser(u)); + } + + @Test + public void matchingRuleWithFullRollout() { + Clause clause = new Clause( + "email", + Operator.in, + Arrays.asList(new JsonPrimitive("test@example.com")), + false); + SegmentRule rule = new SegmentRule( + Arrays.asList(clause), + maxWeight, + null); + Segment s = new Segment.Builder("test") + .salt("abcdef") + .rules(Arrays.asList(rule)) + .build(); + LDUser u = new LDUser.Builder("foo").email("test@example.com").build(); + + assertTrue(s.matchesUser(u)); + } + + @Test + public void matchingRuleWithZeroRollout() { + Clause clause = new Clause( + "email", + Operator.in, + Arrays.asList(new JsonPrimitive("test@example.com")), + false); + SegmentRule rule = new SegmentRule(Arrays.asList(clause), + 0, + null); + Segment s = new Segment.Builder("test") + .salt("abcdef") + .rules(Arrays.asList(rule)) + .build(); + LDUser u = new LDUser.Builder("foo").email("test@example.com").build(); + + assertFalse(s.matchesUser(u)); + } + + @Test + public void matchingRuleWithMultipleClauses() { + Clause clause1 = new Clause( + "email", + Operator.in, + Arrays.asList(new JsonPrimitive("test@example.com")), + false); + Clause clause2 = new Clause( + "name", + Operator.in, + Arrays.asList(new JsonPrimitive("bob")), + false); + SegmentRule rule = new SegmentRule( + Arrays.asList(clause1, clause2), + null, + null); + Segment s = new Segment.Builder("test") + .salt("abcdef") + .rules(Arrays.asList(rule)) + .build(); + LDUser u = new LDUser.Builder("foo").email("test@example.com").name("bob").build(); + + assertTrue(s.matchesUser(u)); + } + + @Test + public void nonMatchingRuleWithMultipleClauses() { + Clause clause1 = new Clause( + "email", + Operator.in, + Arrays.asList(new JsonPrimitive("test@example.com")), + false); + Clause clause2 = new Clause( + "name", + Operator.in, + Arrays.asList(new JsonPrimitive("bill")), + false); + SegmentRule rule = new SegmentRule( + Arrays.asList(clause1, clause2), + null, + null); + Segment s = new Segment.Builder("test") + .salt("abcdef") + .rules(Arrays.asList(rule)) + .build(); + LDUser u = new LDUser.Builder("foo").email("test@example.com").name("bob").build(); + + assertFalse(s.matchesUser(u)); + } +} \ No newline at end of file

CountryCodeLocale