diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7506141ee..52da30952 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -80,6 +80,10 @@ This is a major rewrite that introduces a cleaner API design, adds new features,
- The Redis integration is no longer built into the main SDK library. See: https://github.com/launchdarkly/java-server-sdk-redis
- The deprecated New Relic integration has been removed.
+## [4.14.3] - 2020-09-03
+### Fixed:
+- Bump SnakeYAML from 1.19 to 1.26 to address CVE-2017-18640. The SDK only parses YAML if the application has configured the SDK with a flag data file, so it's unlikely this CVE would affect SDK usage as it would require configuration and access to a local file.
+
## [4.14.2] - 2020-09-01
### Fixed:
- Updated the version of OkHttp contained within the SDK from 3.12.10 to 3.14.9, to address multiple [known issues](https://square.github.io/okhttp/changelog_3x/) including an incompatibility with OpenJDK 8.0.252 under some conditions. ([#204](https://github.com/launchdarkly/java-server-sdk/issues/204))
diff --git a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java
index 772a332d7..45f49cb09 100644
--- a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java
+++ b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java
@@ -96,9 +96,10 @@ private void poll() {
} else {
if (dataSourceUpdates.init(allData.toFullDataSet())) {
dataSourceUpdates.updateStatus(State.VALID, null);
- logger.info("Initialized LaunchDarkly client.");
- initialized.getAndSet(true);
- initFuture.complete(null);
+ if (!initialized.getAndSet(true)) {
+ logger.info("Initialized LaunchDarkly client.");
+ initFuture.complete(null);
+ }
}
}
} catch (HttpErrorException e) {
diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java
index 40683fbdb..a6081eb98 100644
--- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java
+++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java
@@ -6,8 +6,12 @@
* The file data source allows you to use local files as a source of feature flag state. This would
* typically be used in a test environment, to operate using a predetermined feature flag state
* without an actual LaunchDarkly connection. See {@link #dataSource()} for details.
- *
+ *
+ * This is different from {@link TestData}, which allows you to simulate flag configurations
+ * programmatically rather than using a file.
+ *
* @since 4.12.0
+ * @see TestData
*/
public abstract class FileData {
/**
diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java b/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java
new file mode 100644
index 000000000..0ddc9b1dd
--- /dev/null
+++ b/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java
@@ -0,0 +1,691 @@
+package com.launchdarkly.sdk.server.integrations;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.common.collect.Iterables;
+import com.launchdarkly.sdk.ArrayBuilder;
+import com.launchdarkly.sdk.LDValue;
+import com.launchdarkly.sdk.ObjectBuilder;
+import com.launchdarkly.sdk.UserAttribute;
+import com.launchdarkly.sdk.server.DataModel;
+import com.launchdarkly.sdk.server.interfaces.ClientContext;
+import com.launchdarkly.sdk.server.interfaces.DataSource;
+import com.launchdarkly.sdk.server.interfaces.DataSourceFactory;
+import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider;
+import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State;
+import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates;
+import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet;
+import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor;
+import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.Future;
+
+import static java.util.concurrent.CompletableFuture.completedFuture;
+
+/**
+ * A mechanism for providing dynamically updatable feature flag state in a simplified form to an SDK
+ * client in test scenarios.
+ *
+ * Unlike {@link FileData}, this mechanism does not use any external resources. It provides only
+ * the data that the application has put into it using the {@link #update(FlagBuilder)} method.
+ *
+ *
+ * TestData td = TestData.dataSource();
+ * td.update(testData.flag("flag-key-1").booleanFlag().variationForAllUsers(true));
+ *
+ * LDConfig config = new LDConfig.Builder()
+ * .dataSource(td)
+ * .build();
+ * LDClient client = new LDClient(sdkKey, config);
+ *
+ * // flags can be updated at any time:
+ * td.update(testData.flag("flag-key-2")
+ * .variationForUser("some-user-key", true)
+ * .fallthroughVariation(false));
+ *
+ *
+ * The above example uses a simple boolean flag, but more complex configurations are possible using
+ * the methods of the {@link FlagBuilder} that is returned by {@link #flag(String)}. {@link FlagBuilder}
+ * supports many of the ways a flag can be configured on the LaunchDarkly dashboard, but does not
+ * currently support 1. rule operators other than "in" and "not in", or 2. percentage rollouts.
+ *
+ * If the same {@code TestData} instance is used to configure multiple {@code LDClient} instances,
+ * any changes made to the data will propagate to all of the {@code LDClient}s.
+ *
+ * @since 5.1.0
+ * @see FileData
+ */
+public final class TestData implements DataSourceFactory {
+ private final Object lock = new Object();
+ private final Map currentFlags = new HashMap<>();
+ private final Map currentBuilders = new HashMap<>();
+ private final List instances = new CopyOnWriteArrayList<>();
+
+ /**
+ * Creates a new instance of the test data source.
+ *
+ * See {@link TestData} for details.
+ *
+ * @return a new configurable test data source
+ */
+ public static TestData dataSource() {
+ return new TestData();
+ }
+
+ private TestData() {}
+
+ /**
+ * Creates or copies a {@link FlagBuilder} for building a test flag configuration.
+ *
+ * If this flag key has already been defined in this {@code TestData} instance, then the builder
+ * starts with the same configuration that was last provided for this flag.
+ *
+ * Otherwise, it starts with a new default configuration in which the flag has {@code true} and
+ * {@code false} variations, is {@code true} for all users when targeting is turned on and
+ * {@code false} otherwise, and currently has targeting turned on. You can change any of those
+ * properties, and provide more complex behavior, using the {@link FlagBuilder} methods.
+ *
+ * Once you have set the desired configuration, pass the builder to {@link #update(FlagBuilder)}.
+ *
+ * @param key the flag key
+ * @return a flag configuration builder
+ * @see #update(FlagBuilder)
+ */
+ public FlagBuilder flag(String key) {
+ FlagBuilder existingBuilder;
+ synchronized (lock) {
+ existingBuilder = currentBuilders.get(key);
+ }
+ if (existingBuilder != null) {
+ return new FlagBuilder(existingBuilder);
+ }
+ return new FlagBuilder(key).booleanFlag();
+ }
+
+ /**
+ * Updates the test data with the specified flag configuration.
+ *
+ * This has the same effect as if a flag were added or modified on the LaunchDarkly dashboard.
+ * It immediately propagates the flag change to any {@code LDClient} instance(s) that you have
+ * already configured to use this {@code TestData}. If no {@code LDClient} has been started yet,
+ * it simply adds this flag to the test data which will be provided to any {@code LDClient} that
+ * you subsequently configure.
+ *
+ * Any subsequent changes to this {@link FlagBuilder} instance do not affect the test data,
+ * unless you call {@link #update(FlagBuilder)} again.
+ *
+ * @param flagBuilder a flag configuration builder
+ * @return the same {@code TestData} instance
+ * @see #flag(String)
+ */
+ public TestData update(FlagBuilder flagBuilder) {
+ String key = flagBuilder.key;
+ FlagBuilder clonedBuilder = new FlagBuilder(flagBuilder);
+ ItemDescriptor newItem = null;
+
+ synchronized (lock) {
+ ItemDescriptor oldItem = currentFlags.get(key);
+ int oldVersion = oldItem == null ? 0 : oldItem.getVersion();
+ newItem = flagBuilder.createFlag(oldVersion + 1);
+ currentFlags.put(key, newItem);
+ currentBuilders.put(key, clonedBuilder);
+ }
+
+ for (DataSourceImpl instance: instances) {
+ instance.updates.upsert(DataModel.FEATURES, key, newItem);
+ }
+
+ return this;
+ }
+
+ /**
+ * Simulates a change in the data source status.
+ *
+ * Use this if you want to test the behavior of application code that uses
+ * {@link com.launchdarkly.sdk.server.LDClient#getDataSourceStatusProvider()} to track whether the data
+ * source is having problems (for example, a network failure interruptsingthe streaming connection). It
+ * does not actually stop the {@code TestData} data source from working, so even if you have simulated
+ * an outage, calling {@link #update(FlagBuilder)} will still send updates.
+ *
+ * @param newState one of the constants defined by {@link com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State}
+ * @param newError an {@link com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo} instance,
+ * or null
+ * @return the same {@code TestData} instance
+ */
+ public TestData updateStatus(DataSourceStatusProvider.State newState, DataSourceStatusProvider.ErrorInfo newError) {
+ for (DataSourceImpl instance: instances) {
+ instance.updates.updateStatus(newState, newError);
+ }
+ return this;
+ }
+
+ /**
+ * Called internally by the SDK to associate this test data source with an {@code LDClient} instance.
+ * You do not need to call this method.
+ */
+ @Override
+ public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) {
+ DataSourceImpl instance = new DataSourceImpl(dataSourceUpdates);
+ synchronized (lock) {
+ instances.add(instance);
+ }
+ return instance;
+ }
+
+ private FullDataSet makeInitData() {
+ ImmutableMap copiedData;
+ synchronized (lock) {
+ copiedData = ImmutableMap.copyOf(currentFlags);
+ }
+ return new FullDataSet<>(ImmutableMap.of(DataModel.FEATURES, new KeyedItems<>(copiedData.entrySet())).entrySet());
+ }
+
+ private void closedInstance(DataSourceImpl instance) {
+ synchronized (lock) {
+ instances.remove(instance);
+ }
+ }
+
+ /**
+ * A builder for feature flag configurations to be used with {@link TestData}.
+ *
+ * @see TestData#flag(String)
+ * @see TestData#update(FlagBuilder)
+ */
+ public static final class FlagBuilder {
+ private static final int TRUE_VARIATION_FOR_BOOLEAN = 0;
+ private static final int FALSE_VARIATION_FOR_BOOLEAN = 1;
+
+ final String key;
+ int offVariation;
+ boolean on;
+ int fallthroughVariation;
+ CopyOnWriteArrayList variations;
+ Map> targets;
+ List rules;
+
+ private FlagBuilder(String key) {
+ this.key = key;
+ this.on = true;
+ this.variations = new CopyOnWriteArrayList<>();
+ }
+
+ private FlagBuilder(FlagBuilder from) {
+ this.key = from.key;
+ this.offVariation = from.offVariation;
+ this.on = from.on;
+ this.fallthroughVariation = from.fallthroughVariation;
+ this.variations = new CopyOnWriteArrayList<>(from.variations);
+ this.targets = from.targets == null ? null : new HashMap<>(from.targets);
+ }
+
+ private boolean isBooleanFlag() {
+ return variations.size() == 2 &&
+ variations.get(TRUE_VARIATION_FOR_BOOLEAN).equals(LDValue.of(true)) &&
+ variations.get(FALSE_VARIATION_FOR_BOOLEAN).equals(LDValue.of(false));
+ }
+
+ /**
+ * A shortcut for setting the flag to use the standard boolean configuration.
+ *
+ * This is the default for all new flags created with {@link TestData#flag(String)}. The flag
+ * will have two variations, {@code true} and {@code false} (in that order); it will return
+ * {@code false} whenever targeting is off, and {@code true} when targeting is on if no other
+ * settings specify otherwise.
+ *
+ * @return the builder
+ */
+ public FlagBuilder booleanFlag() {
+ if (isBooleanFlag()) {
+ return this;
+ }
+ return variations(LDValue.of(true), LDValue.of(false))
+ .fallthroughVariation(TRUE_VARIATION_FOR_BOOLEAN)
+ .offVariation(FALSE_VARIATION_FOR_BOOLEAN);
+ }
+
+ /**
+ * Sets targeting to be on or off for this flag.
+ *
+ * The effect of this depends on the rest of the flag configuration, just as it does on the
+ * real LaunchDarkly dashboard. In the default configuration that you get from calling
+ * {@link TestData#flag(String)} with a new flag key, the flag will return {@code false}
+ * whenever targeting is off, and {@code true} when targeting is on.
+ *
+ * @param on true if targeting should be on
+ * @return the builder
+ */
+ public FlagBuilder on(boolean on) {
+ this.on = on;
+ return this;
+ }
+
+ /**
+ * Specifies the fallthrough variation for a boolean flag. The fallthrough is the value
+ * that is returned if targeting is on and the user was not matched by a more specific
+ * target or rule.
+ *
+ * If the flag was previously configured with other variations, this also changes it to a
+ * boolean flag.
+ *
+ * @param value true if the flag should return true by default when targeting is on
+ * @return the builder
+ */
+ public FlagBuilder fallthroughVariation(boolean value) {
+ return this.booleanFlag().fallthroughVariation(variationForBoolean(value));
+ }
+
+ /**
+ * Specifies the index of the fallthrough variation. The fallthrough is the variation
+ * that is returned if targeting is on and the user was not matched by a more specific
+ * target or rule.
+ *
+ * @param variationIndex the desired fallthrough variation: 0 for the first, 1 for the second, etc.
+ * @return the builder
+ */
+ public FlagBuilder fallthroughVariation(int variationIndex) {
+ this.fallthroughVariation = variationIndex;
+ return this;
+ }
+
+ /**
+ * Specifies the off variation for a boolean flag. This is the variation that is returned
+ * whenever targeting is off.
+ *
+ * @param value true if the flag should return true when targeting is off
+ * @return the builder
+ */
+ public FlagBuilder offVariation(boolean value) {
+ return this.booleanFlag().offVariation(variationForBoolean(value));
+ }
+
+ /**
+ * Specifies the index of the off variation. This is the variation that is returned
+ * whenever targeting is off.
+ *
+ * @param variationIndex the desired off variation: 0 for the first, 1 for the second, etc.
+ * @return the builder
+ */
+ public FlagBuilder offVariation(int variationIndex) {
+ this.offVariation = variationIndex;
+ return this;
+ }
+
+ /**
+ * Sets the flag to always return the specified boolean variation for all users.
+ *
+ * VariationForAllUsers sets the flag to return the specified boolean variation by default for all users.
+ *
+ * Targeting is switched on, any existing targets or rules are removed, and the flag's variations are
+ * set to true and false. The fallthrough variation is set to the specified value. The off variation is
+ * left unchanged.
+ *
+ * @param variation the desired true/false variation to be returned for all users
+ * @return the builder
+ */
+ public FlagBuilder variationForAllUsers(boolean variation) {
+ return booleanFlag().variationForAllUsers(variationForBoolean(variation));
+ }
+
+ /**
+ * Sets the flag to always return the specified variation for all users.
+ *
+ * The variation is specified by number, out of whatever variation values have already been
+ * defined. Targeting is switched on, and any existing targets or rules are removed. The fallthrough
+ * variation is set to the specified value. The off variation is left unchanged.
+ *
+ * @param variationIndex the desired variation: 0 for the first, 1 for the second, etc.
+ * @return the builder
+ */
+ public FlagBuilder variationForAllUsers(int variationIndex) {
+ return on(true).clearRules().clearUserTargets().fallthroughVariation(variationIndex);
+ }
+
+ /**
+ * Sets the flag to always return the specified variation value for all users.
+ *
+ * The value may be of any JSON type, as defined by {@link LDValue}. This method changes the
+ * flag to have only a single variation, which is this value, and to return the same
+ * variation regardless of whether targeting is on or off. Any existing targets or rules
+ * are removed.
+ *
+ * @param value the desired value to be returned for all users
+ * @return the builder
+ */
+ public FlagBuilder valueForAllUsers(LDValue value) {
+ variations.clear();
+ variations.add(value);
+ return variationForAllUsers(0);
+ }
+
+ /**
+ * Sets the flag to return the specified boolean variation for a specific user key when
+ * targeting is on.
+ *
+ * This has no effect when targeting is turned off for the flag.
+ *
+ * If the flag was not already a boolean flag, this also changes it to a boolean flag.
+ *
+ * @param userKey a user key
+ * @param variation the desired true/false variation to be returned for this user when
+ * targeting is on
+ * @return the builder
+ */
+ public FlagBuilder variationForUser(String userKey, boolean variation) {
+ return booleanFlag().variationForUser(userKey, variationForBoolean(variation));
+ }
+
+ /**
+ * Sets the flag to return the specified variation for a specific user key when targeting
+ * is on.
+ *
+ * This has no effect when targeting is turned off for the flag.
+ *
+ * The variation is specified by number, out of whatever variation values have already been
+ * defined.
+ *
+ * @param userKey a user key
+ * @param variationIndex the desired variation to be returned for this user when targeting is on:
+ * 0 for the first, 1 for the second, etc.
+ * @return the builder
+ */
+ public FlagBuilder variationForUser(String userKey, int variationIndex) {
+ if (targets == null) {
+ targets = new TreeMap<>(); // TreeMap keeps variations in order for test determinacy
+ }
+ for (int i = 0; i < variations.size(); i++) {
+ ImmutableSet keys = targets.get(i);
+ if (i == variationIndex) {
+ if (keys == null) {
+ targets.put(i, ImmutableSortedSet.of(userKey));
+ } else if (!keys.contains(userKey)) {
+ targets.put(i, ImmutableSortedSet.naturalOrder().addAll(keys).add(userKey).build());
+ }
+ } else {
+ if (keys != null && keys.contains(userKey)) {
+ targets.put(i, ImmutableSortedSet.copyOf(Iterables.filter(keys, k -> !k.equals(userKey))));
+ }
+ }
+ }
+ // Note, we use ImmutableSortedSet just to make the output determinate for our own testing
+ return this;
+ }
+
+ /**
+ * Changes the allowable variation values for the flag.
+ *
+ * The value may be of any JSON type, as defined by {@link LDValue}. For instance, a boolean flag
+ * normally has {@code LDValue.of(true), LDValue.of(false)}; a string-valued flag might have
+ * {@code LDValue.of("red"), LDValue.of("green")}; etc.
+ *
+ * @param values the desired variations
+ * @return the builder
+ */
+ public FlagBuilder variations(LDValue... values) {
+ variations.clear();
+ for (LDValue v: values) {
+ variations.add(v);
+ }
+ return this;
+ }
+
+ /**
+ * Starts defining a flag rule, using the "is one of" operator.
+ *
+ * For example, this creates a rule that returns {@code true} if the name is "Patsy" or "Edina":
+ *
+ *
+ * testData.flag("flag")
+ * .ifMatch(UserAttribute.NAME, LDValue.of("Patsy"), LDValue.of("Edina"))
+ * .thenReturn(true));
+ *
+ *
+ * @param attribute the user attribute to match against
+ * @param values values to compare to
+ * @return a {@link FlagRuleBuilder}; call {@link FlagRuleBuilder#thenReturn(boolean)} or
+ * {@link FlagRuleBuilder#thenReturn(int)} to finish the rule, or add more tests with another
+ * method like {@link FlagRuleBuilder#andMatch(UserAttribute, LDValue...)}
+ */
+ public FlagRuleBuilder ifMatch(UserAttribute attribute, LDValue... values) {
+ return new FlagRuleBuilder().andMatch(attribute, values);
+ }
+
+ /**
+ * Starts defining a flag rule, using the "is not one of" operator.
+ *
+ * For example, this creates a rule that returns {@code true} if the name is neither "Saffron" nor "Bubble":
+ *
+ *
+ * testData.flag("flag")
+ * .ifNotMatch(UserAttribute.NAME, LDValue.of("Saffron"), LDValue.of("Bubble"))
+ * .thenReturn(true));
+ *
+
+ * @param attribute the user attribute to match against
+ * @param values values to compare to
+ * @return a {@link FlagRuleBuilder}; call {@link FlagRuleBuilder#thenReturn(boolean)} or
+ * {@link FlagRuleBuilder#thenReturn(int)} to finish the rule, or add more tests with another
+ * method like {@link FlagRuleBuilder#andMatch(UserAttribute, LDValue...)}
+ */
+ public FlagRuleBuilder ifNotMatch(UserAttribute attribute, LDValue... values) {
+ return new FlagRuleBuilder().andNotMatch(attribute, values);
+ }
+
+ /**
+ * Removes any existing rules from the flag. This undoes the effect of methods like
+ * {@link #ifMatch(UserAttribute, LDValue...)}.
+ *
+ * @return the same builder
+ */
+ public FlagBuilder clearRules() {
+ rules = null;
+ return this;
+ }
+
+ /**
+ * Removes any existing user targets from the flag. This undoes the effect of methods like
+ * {@link #variationForUser(String, boolean)}.
+ *
+ * @return the same builder
+ */
+ public FlagBuilder clearUserTargets() {
+ targets = null;
+ return this;
+ }
+
+ ItemDescriptor createFlag(int version) {
+ ObjectBuilder builder = LDValue.buildObject()
+ .put("key", key)
+ .put("version", version)
+ .put("on", on)
+ .put("offVariation", offVariation)
+ .put("fallthrough", LDValue.buildObject().put("variation", fallthroughVariation).build());
+ ArrayBuilder jsonVariations = LDValue.buildArray();
+ for (LDValue v: variations) {
+ jsonVariations.add(v);
+ }
+ builder.put("variations", jsonVariations.build());
+
+ if (targets != null) {
+ ArrayBuilder jsonTargets = LDValue.buildArray();
+ for (Map.Entry> e: targets.entrySet()) {
+ jsonTargets.add(LDValue.buildObject()
+ .put("variation", e.getKey().intValue())
+ .put("values", LDValue.Convert.String.arrayFrom(e.getValue()))
+ .build());
+ }
+ builder.put("targets", jsonTargets.build());
+ }
+
+ if (rules != null) {
+ ArrayBuilder jsonRules = LDValue.buildArray();
+ int ri = 0;
+ for (FlagRuleBuilder r: rules) {
+ ArrayBuilder jsonClauses = LDValue.buildArray();
+ for (Clause c: r.clauses) {
+ ArrayBuilder jsonValues = LDValue.buildArray();
+ for (LDValue v: c.values) {
+ jsonValues.add(v);
+ }
+ jsonClauses.add(LDValue.buildObject()
+ .put("attribute", c.attribute.getName())
+ .put("op", c.operator)
+ .put("values", jsonValues.build())
+ .put("negate", c.negate)
+ .build());
+ }
+ jsonRules.add(LDValue.buildObject()
+ .put("id", "rule" + ri)
+ .put("variation", r.variation)
+ .put("clauses", jsonClauses.build())
+ .build());
+ ri++;
+ }
+ builder.put("rules", jsonRules.build());
+ }
+
+ String json = builder.build().toJsonString();
+ return DataModel.FEATURES.deserialize(json);
+ }
+
+ private static int variationForBoolean(boolean value) {
+ return value ? TRUE_VARIATION_FOR_BOOLEAN : FALSE_VARIATION_FOR_BOOLEAN;
+ }
+
+ /**
+ * A builder for feature flag rules to be used with {@link FlagBuilder}.
+ *
+ * In the LaunchDarkly model, a flag can have any number of rules, and a rule can have any number of
+ * clauses. A clause is an individual test such as "name is 'X'". A rule matches a user if all of the
+ * rule's clauses match the user.
+ *
+ * To start defining a rule, use one of the flag builder's matching methods such as
+ * {@link FlagBuilder#ifMatch(UserAttribute, LDValue...)}. This defines the first clause for the rule.
+ * Optionally, you may add more clauses with the rule builder's methods such as
+ * {@link #andMatch(UserAttribute, LDValue...)}. Finally, call {@link #thenReturn(boolean)} or
+ * {@link #thenReturn(int)} to finish defining the rule.
+ */
+ public final class FlagRuleBuilder {
+ final List clauses = new ArrayList<>();
+ int variation;
+
+ /**
+ * Adds another clause, using the "is one of" operator.
+ *
+ * For example, this creates a rule that returns {@code true} if the name is "Patsy" and the
+ * country is "gb":
+ *
+ *
+ * testData.flag("flag")
+ * .ifMatch(UserAttribute.NAME, LDValue.of("Patsy"))
+ * .andMatch(UserAttribute.COUNTRY, LDValue.of("gb"))
+ * .thenReturn(true));
+ *
+ *
+ * @param attribute the user attribute to match against
+ * @param values values to compare to
+ * @return the rule builder
+ */
+ public FlagRuleBuilder andMatch(UserAttribute attribute, LDValue... values) {
+ clauses.add(new Clause(attribute, "in", values, false));
+ return this;
+ }
+
+ /**
+ * Adds another clause, using the "is not one of" operator.
+ *
+ * For example, this creates a rule that returns {@code true} if the name is "Patsy" and the
+ * country is not "gb":
+ *
+ *
+ * testData.flag("flag")
+ * .ifMatch(UserAttribute.NAME, LDValue.of("Patsy"))
+ * .andNotMatch(UserAttribute.COUNTRY, LDValue.of("gb"))
+ * .thenReturn(true));
+ *
+ *
+ * @param attribute the user attribute to match against
+ * @param values values to compare to
+ * @return the rule builder
+ */
+ public FlagRuleBuilder andNotMatch(UserAttribute attribute, LDValue... values) {
+ clauses.add(new Clause(attribute, "in", values, true));
+ return this;
+ }
+
+ /**
+ * Finishes defining the rule, specifying the result value as a boolean.
+ *
+ * @param variation the value to return if the rule matches the user
+ * @return the flag builder
+ */
+ public FlagBuilder thenReturn(boolean variation) {
+ FlagBuilder.this.booleanFlag();
+ return thenReturn(variationForBoolean(variation));
+ }
+
+ /**
+ * Finishes defining the rule, specifying the result as a variation index.
+ *
+ * @param variationIndex the variation to return if the rule matches the user: 0 for the first, 1
+ * for the second, etc.
+ * @return the flag builder
+ */
+ public FlagBuilder thenReturn(int variationIndex) {
+ this.variation = variationIndex;
+ if (FlagBuilder.this.rules == null) {
+ FlagBuilder.this.rules = new ArrayList<>();
+ }
+ FlagBuilder.this.rules.add(this);
+ return FlagBuilder.this;
+ }
+ }
+
+ private static final class Clause {
+ final UserAttribute attribute;
+ final String operator;
+ final LDValue[] values;
+ final boolean negate;
+
+ Clause(UserAttribute attribute, String operator, LDValue[] values, boolean negate) {
+ this.attribute = attribute;
+ this.operator = operator;
+ this.values = values;
+ this.negate = negate;
+ }
+ }
+ }
+
+ private final class DataSourceImpl implements DataSource {
+ final DataSourceUpdates updates;
+
+ DataSourceImpl(DataSourceUpdates updates) {
+ this.updates = updates;
+ }
+
+ @Override
+ public Future start() {
+ updates.init(makeInitData());
+ updates.updateStatus(State.VALID, null);
+ return completedFuture(null);
+ }
+
+ @Override
+ public boolean isInitialized() {
+ return true;
+ }
+
+ @Override
+ public void close() throws IOException {
+ closedInstance(this);
+ }
+ }
+}
diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java
index 0b3c7592e..64331c0d2 100644
--- a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java
+++ b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java
@@ -2,13 +2,10 @@
import com.launchdarkly.sdk.LDUser;
import com.launchdarkly.sdk.LDValue;
-import com.launchdarkly.sdk.server.DataModel.FeatureFlag;
-import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder;
-import com.launchdarkly.sdk.server.TestComponents.DataSourceFactoryThatExposesUpdater;
import com.launchdarkly.sdk.server.TestComponents.DataStoreFactoryThatExposesUpdater;
import com.launchdarkly.sdk.server.integrations.MockPersistentDataStore;
+import com.launchdarkly.sdk.server.integrations.TestData;
import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider;
-import com.launchdarkly.sdk.server.interfaces.DataStore;
import com.launchdarkly.sdk.server.interfaces.DataStoreFactory;
import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider;
import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent;
@@ -23,9 +20,6 @@
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
-import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder;
-import static com.launchdarkly.sdk.server.TestComponents.initedDataStore;
-import static com.launchdarkly.sdk.server.TestComponents.specificDataStore;
import static com.launchdarkly.sdk.server.TestComponents.specificPersistentDataStore;
import static com.launchdarkly.sdk.server.TestUtil.awaitValue;
import static com.launchdarkly.sdk.server.TestUtil.expectNoMoreValues;
@@ -53,13 +47,10 @@ public class LDClientListenersTest extends EasyMockSupport {
@Test
public void clientSendsFlagChangeEvents() throws Exception {
String flagKey = "flagkey";
- DataStore testDataStore = initedDataStore();
- DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES,
- flagBuilder(flagKey).version(1).build());
- DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build());
+ TestData testData = TestData.dataSource();
+ testData.update(testData.flag(flagKey).on(true));
LDConfig config = new LDConfig.Builder()
- .dataStore(specificDataStore(testDataStore))
- .dataSource(updatableSource)
+ .dataSource(testData)
.events(Components.noEvents())
.build();
@@ -74,7 +65,7 @@ public void clientSendsFlagChangeEvents() throws Exception {
expectNoMoreValues(eventSink1, Duration.ofMillis(100));
expectNoMoreValues(eventSink2, Duration.ofMillis(100));
- updatableSource.updateFlag(flagBuilder(flagKey).version(2).build());
+ testData.update(testData.flag(flagKey).on(false));
FlagChangeEvent event1 = awaitValue(eventSink1, Duration.ofSeconds(1));
FlagChangeEvent event2 = awaitValue(eventSink2, Duration.ofSeconds(1));
@@ -85,7 +76,7 @@ public void clientSendsFlagChangeEvents() throws Exception {
client.getFlagTracker().removeFlagChangeListener(listener1);
- updatableSource.updateFlag(flagBuilder(flagKey).version(3).build());
+ testData.update(testData.flag(flagKey).on(true));
FlagChangeEvent event3 = awaitValue(eventSink2, Duration.ofSeconds(1));
assertThat(event3.getKey(), equalTo(flagKey));
@@ -99,16 +90,12 @@ public void clientSendsFlagValueChangeEvents() throws Exception {
String flagKey = "important-flag";
LDUser user = new LDUser("important-user");
LDUser otherUser = new LDUser("unimportant-user");
- DataStore testDataStore = initedDataStore();
-
- FeatureFlag alwaysFalseFlag = flagBuilder(flagKey).version(1).on(true).variations(false, true)
- .fallthroughVariation(0).build();
- DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, alwaysFalseFlag);
+
+ TestData testData = TestData.dataSource();
+ testData.update(testData.flag(flagKey).on(false));
- DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build());
LDConfig config = new LDConfig.Builder()
- .dataStore(specificDataStore(testDataStore))
- .dataSource(updatableSource)
+ .dataSource(testData)
.events(Components.noEvents())
.build();
@@ -126,9 +113,10 @@ public void clientSendsFlagValueChangeEvents() throws Exception {
expectNoMoreValues(eventSink3, Duration.ofMillis(100));
// make the flag true for the first user only, and broadcast a flag change event
- FeatureFlag flagIsTrueForMyUserOnly = flagBuilder(flagKey).version(2).on(true).variations(false, true)
- .targets(ModelBuilders.target(1, user.getKey())).fallthroughVariation(0).build();
- updatableSource.updateFlag(flagIsTrueForMyUserOnly);
+ testData.update(testData.flag(flagKey)
+ .on(true)
+ .variationForUser(user.getKey(), true)
+ .fallthroughVariation(false));
// eventSink1 receives a value change event
FlagValueChangeEvent event1 = awaitValue(eventSink1, Duration.ofSeconds(1));
@@ -147,22 +135,22 @@ public void clientSendsFlagValueChangeEvents() throws Exception {
@Test
public void dataSourceStatusProviderReturnsLatestStatus() throws Exception {
- DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(new DataBuilder().build());
+ TestData testData = TestData.dataSource();
LDConfig config = new LDConfig.Builder()
- .dataSource(updatableSource)
+ .dataSource(testData)
.events(Components.noEvents())
.build();
Instant timeBeforeStarting = Instant.now();
try (LDClient client = new LDClient(SDK_KEY, config)) {
DataSourceStatusProvider.Status initialStatus = client.getDataSourceStatusProvider().getStatus();
- assertThat(initialStatus.getState(), equalTo(DataSourceStatusProvider.State.INITIALIZING));
+ assertThat(initialStatus.getState(), equalTo(DataSourceStatusProvider.State.VALID));
assertThat(initialStatus.getStateSince(), greaterThanOrEqualTo(timeBeforeStarting));
assertThat(initialStatus.getLastError(), nullValue());
DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo(
DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, 401, null, Instant.now());
- updatableSource.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.OFF, errorInfo);
+ testData.updateStatus(DataSourceStatusProvider.State.OFF, errorInfo);
DataSourceStatusProvider.Status newStatus = client.getDataSourceStatusProvider().getStatus();
assertThat(newStatus.getState(), equalTo(DataSourceStatusProvider.State.OFF));
@@ -173,9 +161,9 @@ public void dataSourceStatusProviderReturnsLatestStatus() throws Exception {
@Test
public void dataSourceStatusProviderSendsStatusUpdates() throws Exception {
- DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(new DataBuilder().build());
+ TestData testData = TestData.dataSource();
LDConfig config = new LDConfig.Builder()
- .dataSource(updatableSource)
+ .dataSource(testData)
.events(Components.noEvents())
.build();
@@ -185,7 +173,7 @@ public void dataSourceStatusProviderSendsStatusUpdates() throws Exception {
DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo(
DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, 401, null, Instant.now());
- updatableSource.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.OFF, errorInfo);
+ testData.updateStatus(DataSourceStatusProvider.State.OFF, errorInfo);
DataSourceStatusProvider.Status newStatus = statuses.take();
assertThat(newStatus.getState(), equalTo(DataSourceStatusProvider.State.OFF));
@@ -264,13 +252,10 @@ public void eventsAreDispatchedOnTaskThread() throws Exception {
int desiredPriority = Thread.MAX_PRIORITY - 1;
BlockingQueue capturedThreads = new LinkedBlockingQueue<>();
- DataStore testDataStore = initedDataStore();
- DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES,
- flagBuilder("flagkey").version(1).build());
- DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build());
+ TestData testData = TestData.dataSource();
+ testData.update(testData.flag("flagkey").on(true));
LDConfig config = new LDConfig.Builder()
- .dataStore(specificDataStore(testDataStore))
- .dataSource(updatableSource)
+ .dataSource(testData)
.events(Components.noEvents())
.threadPriority(desiredPriority)
.build();
@@ -280,7 +265,7 @@ public void eventsAreDispatchedOnTaskThread() throws Exception {
capturedThreads.add(Thread.currentThread());
});
- updatableSource.updateFlag(flagBuilder("flagkey").version(2).build());
+ testData.update(testData.flag("flagkey").on(false));
Thread handlerThread = capturedThreads.take();
diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java
index 125d7320f..04be2a497 100644
--- a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java
+++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java
@@ -2,7 +2,6 @@
import com.launchdarkly.eventsource.EventSource;
import com.launchdarkly.sdk.UserAttribute;
-import com.launchdarkly.sdk.server.DataModel.FeatureFlag;
import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder;
import com.launchdarkly.sdk.server.interfaces.ClientContext;
import com.launchdarkly.sdk.server.interfaces.DataSource;
@@ -41,8 +40,6 @@
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
-import static com.launchdarkly.sdk.server.DataModel.FEATURES;
-
@SuppressWarnings("javadoc")
public class TestComponents {
static ScheduledExecutorService sharedExecutor = Executors.newSingleThreadScheduledExecutor();
@@ -55,10 +52,6 @@ public static ClientContext clientContext(final String sdkKey, final LDConfig co
return new ClientContextImpl(sdkKey, config, sharedExecutor, diagnosticAccumulator);
}
- public static DataSourceFactory dataSourceWithData(FullDataSet data) {
- return (context, dataSourceUpdates) -> new DataSourceWithData(data, dataSourceUpdates);
- }
-
public static DataStore dataStoreThatThrowsException(RuntimeException e) {
return new DataStoreThatThrowsException(e);
}
@@ -139,25 +132,6 @@ public void flush() {
}
}
- public static class DataSourceFactoryThatExposesUpdater implements DataSourceFactory {
- private final FullDataSet initialData;
- DataSourceUpdates dataSourceUpdates;
-
- public DataSourceFactoryThatExposesUpdater(FullDataSet initialData) {
- this.initialData = initialData;
- }
-
- @Override
- public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) {
- this.dataSourceUpdates = dataSourceUpdates;
- return dataSourceWithData(initialData).createDataSource(context, dataSourceUpdates);
- }
-
- public void updateFlag(FeatureFlag flag) {
- dataSourceUpdates.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag));
- }
- }
-
private static class DataSourceThatNeverInitializes implements DataSource {
public Future start() {
return new CompletableFuture<>();
@@ -171,28 +145,6 @@ public void close() throws IOException {
}
};
- private static class DataSourceWithData implements DataSource {
- private final FullDataSet data;
- private final DataSourceUpdates dataSourceUpdates;
-
- DataSourceWithData(FullDataSet data, DataSourceUpdates dataSourceUpdates) {
- this.data = data;
- this.dataSourceUpdates = dataSourceUpdates;
- }
-
- public Future start() {
- dataSourceUpdates.init(data);
- return CompletableFuture.completedFuture(null);
- }
-
- public boolean isInitialized() {
- return true;
- }
-
- public void close() throws IOException {
- }
- }
-
public static class MockDataSourceUpdates implements DataSourceUpdates {
private final DataSourceUpdatesImpl wrappedInstance;
private final DataStoreStatusProvider dataStoreStatusProvider;
diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java
new file mode 100644
index 000000000..407c535c0
--- /dev/null
+++ b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java
@@ -0,0 +1,355 @@
+package com.launchdarkly.sdk.server.integrations;
+
+import com.google.common.collect.ImmutableMap;
+import com.launchdarkly.sdk.LDValue;
+import com.launchdarkly.sdk.UserAttribute;
+import com.launchdarkly.sdk.server.DataModel;
+import com.launchdarkly.sdk.server.interfaces.DataSource;
+import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo;
+import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State;
+import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates;
+import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider;
+import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind;
+import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet;
+import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor;
+
+import org.junit.Test;
+
+import java.util.Map;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.Future;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.function.Function;
+
+import static com.google.common.collect.Iterables.get;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.emptyIterable;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.iterableWithSize;
+import static org.hamcrest.Matchers.not;
+import static org.hamcrest.Matchers.nullValue;
+
+@SuppressWarnings("javadoc")
+public class TestDataTest {
+ private static final LDValue[] THREE_STRING_VALUES =
+ new LDValue[] { LDValue.of("red"), LDValue.of("green"), LDValue.of("blue") };
+
+ private CapturingDataSourceUpdates updates = new CapturingDataSourceUpdates();
+
+ @Test
+ public void initializesWithEmptyData() throws Exception {
+ TestData td = TestData.dataSource();
+ DataSource ds = td.createDataSource(null, updates);
+ Future started = ds.start();
+
+ assertThat(started.isDone(), is(true));
+ assertThat(updates.valid, is(true));
+
+ assertThat(updates.inits.size(), equalTo(1));
+ FullDataSet data = updates.inits.take();
+ assertThat(data.getData(), iterableWithSize(1));
+ assertThat(get(data.getData(), 0).getKey(), equalTo(DataModel.FEATURES));
+ assertThat(get(data.getData(), 0).getValue().getItems(), emptyIterable());
+ }
+
+ @Test
+ public void initializesWithFlags() throws Exception {
+ TestData td = TestData.dataSource();
+
+ td.update(td.flag("flag1").on(true))
+ .update(td.flag("flag2").on(false));
+
+ DataSource ds = td.createDataSource(null, updates);
+ Future started = ds.start();
+
+ assertThat(started.isDone(), is(true));
+ assertThat(updates.valid, is(true));
+
+ assertThat(updates.inits.size(), equalTo(1));
+ FullDataSet data = updates.inits.take();
+ assertThat(data.getData(), iterableWithSize(1));
+ assertThat(get(data.getData(), 0).getKey(), equalTo(DataModel.FEATURES));
+ assertThat(get(data.getData(), 0).getValue().getItems(), iterableWithSize(2));
+
+ Map flags = ImmutableMap.copyOf(get(data.getData(), 0).getValue().getItems());
+ ItemDescriptor flag1 = flags.get("flag1");
+ ItemDescriptor flag2 = flags.get("flag2");
+ assertThat(flag1, not(nullValue()));
+ assertThat(flag2, not(nullValue()));
+ assertThat(flag1.getVersion(), equalTo(1));
+ assertThat(flag2.getVersion(), equalTo(1));
+ assertThat(flagJson(flag1).get("on").booleanValue(), is(true));
+ assertThat(flagJson(flag2).get("on").booleanValue(), is(false));
+ }
+
+ @Test
+ public void addsFlag() throws Exception {
+ TestData td = TestData.dataSource();
+ DataSource ds = td.createDataSource(null, updates);
+ Future started = ds.start();
+
+ assertThat(started.isDone(), is(true));
+ assertThat(updates.valid, is(true));
+
+ td.update(td.flag("flag1").on(true));
+
+ assertThat(updates.upserts.size(), equalTo(1));
+ UpsertParams up = updates.upserts.take();
+ assertThat(up.kind, is(DataModel.FEATURES));
+ assertThat(up.key, equalTo("flag1"));
+ ItemDescriptor flag1 = up.item;
+ assertThat(flag1.getVersion(), equalTo(1));
+ assertThat(flagJson(flag1).get("on").booleanValue(), is(true));
+ }
+
+ @Test
+ public void updatesFlag() throws Exception {
+ TestData td = TestData.dataSource();
+ td.update(td.flag("flag1").on(false));
+
+ DataSource ds = td.createDataSource(null, updates);
+ Future started = ds.start();
+
+ assertThat(started.isDone(), is(true));
+ assertThat(updates.valid, is(true));
+
+ td.update(td.flag("flag1").on(true));
+
+ assertThat(updates.upserts.size(), equalTo(1));
+ UpsertParams up = updates.upserts.take();
+ assertThat(up.kind, is(DataModel.FEATURES));
+ assertThat(up.key, equalTo("flag1"));
+ ItemDescriptor flag1 = up.item;
+ assertThat(flag1.getVersion(), equalTo(2));
+ assertThat(flagJson(flag1).get("on").booleanValue(), is(true));
+ }
+
+ @Test
+ public void flagConfigSimpleBoolean() throws Exception {
+ String basicProps = "\"variations\":[true,false],\"offVariation\":1";
+ String onProps = basicProps + ",\"on\":true";
+ String offProps = basicProps + ",\"on\":false";
+ String fallthroughTrue = ",\"fallthrough\":{\"variation\":0}";
+ String fallthroughFalse = ",\"fallthrough\":{\"variation\":1}";
+
+ verifyFlag(f -> f, onProps + fallthroughTrue);
+ verifyFlag(f -> f.booleanFlag(), onProps + fallthroughTrue);
+ verifyFlag(f -> f.on(true), onProps + fallthroughTrue);
+ verifyFlag(f -> f.on(false), offProps + fallthroughTrue);
+ verifyFlag(f -> f.variationForAllUsers(false), onProps + fallthroughFalse);
+ verifyFlag(f -> f.variationForAllUsers(true), onProps + fallthroughTrue);
+
+ verifyFlag(
+ f -> f.fallthroughVariation(true).offVariation(false),
+ onProps + fallthroughTrue
+ );
+
+ verifyFlag(
+ f -> f.fallthroughVariation(false).offVariation(true),
+ "\"variations\":[true,false],\"on\":true,\"offVariation\":0,\"fallthrough\":{\"variation\":1}"
+ );
+ }
+
+ @Test
+ public void usingBooleanConfigMethodsForcesFlagToBeBoolean() throws Exception {
+ String booleanProps = "\"on\":true"
+ + ",\"variations\":[true,false],\"offVariation\":1,\"fallthrough\":{\"variation\":0}";
+
+ verifyFlag(
+ f -> f.variations(LDValue.of(1), LDValue.of(2))
+ .booleanFlag(),
+ booleanProps
+ );
+ verifyFlag(
+ f -> f.variations(LDValue.of(true), LDValue.of(2))
+ .booleanFlag(),
+ booleanProps
+ );
+ verifyFlag(
+ f -> f.booleanFlag(),
+ booleanProps
+ );
+ }
+
+ @Test
+ public void flagConfigStringVariations() throws Exception {
+ String basicProps = "\"variations\":[\"red\",\"green\",\"blue\"],\"on\":true"
+ + ",\"offVariation\":0,\"fallthrough\":{\"variation\":2}";
+
+ verifyFlag(
+ f -> f.variations(THREE_STRING_VALUES).offVariation(0).fallthroughVariation(2),
+ basicProps
+ );
+ }
+
+ @Test
+ public void userTargets() throws Exception {
+ String booleanFlagBasicProps = "\"on\":true,\"variations\":[true,false]" +
+ ",\"offVariation\":1,\"fallthrough\":{\"variation\":0}";
+ verifyFlag(
+ f -> f.variationForUser("a", true).variationForUser("b", true),
+ booleanFlagBasicProps + ",\"targets\":[{\"variation\":0,\"values\":[\"a\",\"b\"]}]"
+ );
+ verifyFlag(
+ f -> f.variationForUser("a", true).variationForUser("a", true),
+ booleanFlagBasicProps + ",\"targets\":[{\"variation\":0,\"values\":[\"a\"]}]"
+ );
+ verifyFlag(
+ f -> f.variationForUser("a", false).variationForUser("b", true).variationForUser("c", false),
+ booleanFlagBasicProps + ",\"targets\":[{\"variation\":0,\"values\":[\"b\"]}" +
+ ",{\"variation\":1,\"values\":[\"a\",\"c\"]}]"
+ );
+ verifyFlag(
+ f -> f.variationForUser("a", true).variationForUser("b", true).variationForUser("a", false),
+ booleanFlagBasicProps + ",\"targets\":[{\"variation\":0,\"values\":[\"b\"]}" +
+ ",{\"variation\":1,\"values\":[\"a\"]}]"
+ );
+
+ String stringFlagBasicProps = "\"variations\":[\"red\",\"green\",\"blue\"],\"on\":true"
+ + ",\"offVariation\":0,\"fallthrough\":{\"variation\":2}";
+ verifyFlag(
+ f -> f.variations(THREE_STRING_VALUES).offVariation(0).fallthroughVariation(2)
+ .variationForUser("a", 2).variationForUser("b", 2),
+ stringFlagBasicProps + ",\"targets\":[{\"variation\":2,\"values\":[\"a\",\"b\"]}]"
+ );
+ verifyFlag(
+ f -> f.variations(THREE_STRING_VALUES).offVariation(0).fallthroughVariation(2)
+ .variationForUser("a", 2).variationForUser("b", 1).variationForUser("c", 2),
+ stringFlagBasicProps + ",\"targets\":[{\"variation\":1,\"values\":[\"b\"]}" +
+ ",{\"variation\":2,\"values\":[\"a\",\"c\"]}]"
+ );
+ }
+
+ @Test
+ public void flagRules() throws Exception {
+ String basicProps = "\"variations\":[true,false]" +
+ ",\"on\":true,\"offVariation\":1,\"fallthrough\":{\"variation\":0}";
+
+ // match that returns variation 0/true
+ String matchReturnsVariation0 = basicProps +
+ ",\"rules\":[{\"id\":\"rule0\",\"variation\":0,\"trackEvents\":false,\"clauses\":[" +
+ "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":false}" +
+ "]}]";
+ verifyFlag(
+ f -> f.ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(true),
+ matchReturnsVariation0
+ );
+ verifyFlag(
+ f -> f.ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(0),
+ matchReturnsVariation0
+ );
+
+ // match that returns variation 1/false
+ String matchReturnsVariation1 = basicProps +
+ ",\"rules\":[{\"id\":\"rule0\",\"variation\":1,\"trackEvents\":false,\"clauses\":[" +
+ "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":false}" +
+ "]}]";
+ verifyFlag(
+ f -> f.ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(false),
+ matchReturnsVariation1
+ );
+ verifyFlag(
+ f -> f.ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(1),
+ matchReturnsVariation1
+ );
+
+ // negated match
+ verifyFlag(
+ f -> f.ifNotMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(true),
+ basicProps + ",\"rules\":[{\"id\":\"rule0\",\"variation\":0,\"trackEvents\":false,\"clauses\":[" +
+ "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":true}" +
+ "]}]"
+ );
+
+ // multiple clauses
+ verifyFlag(
+ f -> f.ifMatch(UserAttribute.NAME, LDValue.of("Lucy"))
+ .andMatch(UserAttribute.COUNTRY, LDValue.of("gb"))
+ .thenReturn(true),
+ basicProps + ",\"rules\":[{\"id\":\"rule0\",\"variation\":0,\"trackEvents\":false,\"clauses\":[" +
+ "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":false}," +
+ "{\"attribute\":\"country\",\"op\":\"in\",\"values\":[\"gb\"],\"negate\":false}" +
+ "]}]"
+ );
+
+ // multiple rules
+ verifyFlag(
+ f -> f.ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(true)
+ .ifMatch(UserAttribute.NAME, LDValue.of("Mina")).thenReturn(true),
+ basicProps + ",\"rules\":["
+ + "{\"id\":\"rule0\",\"variation\":0,\"trackEvents\":false,\"clauses\":[" +
+ "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":false}" +
+ "]},"
+ + "{\"id\":\"rule1\",\"variation\":0,\"trackEvents\":false,\"clauses\":[" +
+ "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Mina\"],\"negate\":false}" +
+ "]}"
+ + "]"
+ );
+
+ }
+
+ private void verifyFlag(
+ Function configureFlag,
+ String expectedProps
+ ) throws Exception {
+ String expectedJson = "{\"key\":\"flagkey\",\"version\":1," + expectedProps +
+ ",\"clientSide\":false,\"deleted\":false,\"trackEvents\":false,\"trackEventsFallthrough\":false}";
+
+ TestData td = TestData.dataSource();
+
+ DataSource ds = td.createDataSource(null, updates);
+ ds.start();
+
+ td.update(configureFlag.apply(td.flag("flagkey")));
+
+ assertThat(updates.upserts.size(), equalTo(1));
+ UpsertParams up = updates.upserts.take();
+ ItemDescriptor flag = up.item;
+ assertThat(flagJson(flag), equalTo(LDValue.parse(expectedJson)));
+ }
+
+ private static LDValue flagJson(ItemDescriptor flag) {
+ return LDValue.parse(DataModel.FEATURES.serialize(flag));
+ }
+
+ private static class UpsertParams {
+ final DataKind kind;
+ final String key;
+ final ItemDescriptor item;
+
+ UpsertParams(DataKind kind, String key, ItemDescriptor item) {
+ this.kind = kind;
+ this.key = key;
+ this.item = item;
+ }
+ }
+
+ private static class CapturingDataSourceUpdates implements DataSourceUpdates {
+ BlockingQueue> inits = new LinkedBlockingQueue<>();
+ BlockingQueue upserts = new LinkedBlockingQueue<>();
+ boolean valid;
+
+ @Override
+ public boolean init(FullDataSet allData) {
+ inits.add(allData);
+ return true;
+ }
+
+ @Override
+ public boolean upsert(DataKind kind, String key, ItemDescriptor item) {
+ upserts.add(new UpsertParams(kind, key, item));
+ return true;
+ }
+
+ @Override
+ public DataStoreStatusProvider getDataStoreStatusProvider() {
+ return null;
+ }
+
+ @Override
+ public void updateStatus(State newState, ErrorInfo newError) {
+ valid = newState == State.VALID;
+ }
+ }
+}
diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java
new file mode 100644
index 000000000..452b82c17
--- /dev/null
+++ b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java
@@ -0,0 +1,127 @@
+package com.launchdarkly.sdk.server.integrations;
+
+import com.launchdarkly.sdk.LDUser;
+import com.launchdarkly.sdk.LDValue;
+import com.launchdarkly.sdk.UserAttribute;
+import com.launchdarkly.sdk.server.Components;
+import com.launchdarkly.sdk.server.LDClient;
+import com.launchdarkly.sdk.server.LDConfig;
+import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo;
+import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State;
+
+import org.junit.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+
+@SuppressWarnings("javadoc")
+public class TestDataWithClientTest {
+ private static final String SDK_KEY = "sdk-key";
+
+ private TestData td = TestData.dataSource();
+ private LDConfig config = new LDConfig.Builder()
+ .dataSource(td)
+ .events(Components.noEvents())
+ .build();
+
+ @Test
+ public void initializesWithEmptyData() throws Exception {
+ try (LDClient client = new LDClient(SDK_KEY, config)) {
+ assertThat(client.isInitialized(), is(true));
+ }
+ }
+
+ @Test
+ public void initializesWithFlag() throws Exception {
+ td.update(td.flag("flag").on(true));
+
+ try (LDClient client = new LDClient(SDK_KEY, config)) {
+ assertThat(client.boolVariation("flag", new LDUser("user"), false), is(true));
+ }
+ }
+
+ @Test
+ public void updatesFlag() throws Exception {
+ td.update(td.flag("flag").on(false));
+
+ try (LDClient client = new LDClient(SDK_KEY, config)) {
+ assertThat(client.boolVariation("flag", new LDUser("user"), false), is(false));
+
+ td.update(td.flag("flag").on(true));
+
+ assertThat(client.boolVariation("flag", new LDUser("user"), false), is(true));
+ }
+ }
+
+ @Test
+ public void usesTargets() throws Exception {
+ td.update(td.flag("flag").fallthroughVariation(false).variationForUser("user1", true));
+
+ try (LDClient client = new LDClient(SDK_KEY, config)) {
+ assertThat(client.boolVariation("flag", new LDUser("user1"), false), is(true));
+ assertThat(client.boolVariation("flag", new LDUser("user2"), false), is(false));
+ }
+ }
+
+ @Test
+ public void usesRules() throws Exception {
+ td.update(td.flag("flag").fallthroughVariation(false)
+ .ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(true)
+ .ifMatch(UserAttribute.NAME, LDValue.of("Mina")).thenReturn(true));
+
+ try (LDClient client = new LDClient(SDK_KEY, config)) {
+ assertThat(client.boolVariation("flag", new LDUser.Builder("user1").name("Lucy").build(), false), is(true));
+ assertThat(client.boolVariation("flag", new LDUser.Builder("user2").name("Mina").build(), false), is(true));
+ assertThat(client.boolVariation("flag", new LDUser.Builder("user3").name("Quincy").build(), false), is(false));
+ }
+ }
+
+ @Test
+ public void nonBooleanFlags() throws Exception {
+ td.update(td.flag("flag").variations(LDValue.of("red"), LDValue.of("green"), LDValue.of("blue"))
+ .offVariation(0).fallthroughVariation(2)
+ .variationForUser("user1", 1)
+ .ifMatch(UserAttribute.NAME, LDValue.of("Mina")).thenReturn(1));
+
+ try (LDClient client = new LDClient(SDK_KEY, config)) {
+ assertThat(client.stringVariation("flag", new LDUser.Builder("user1").name("Lucy").build(), ""), equalTo("green"));
+ assertThat(client.stringVariation("flag", new LDUser.Builder("user2").name("Mina").build(), ""), equalTo("green"));
+ assertThat(client.stringVariation("flag", new LDUser.Builder("user3").name("Quincy").build(), ""), equalTo("blue"));
+
+ td.update(td.flag("flag").on(false));
+
+ assertThat(client.stringVariation("flag", new LDUser.Builder("user1").name("Lucy").build(), ""), equalTo("red"));
+ }
+ }
+
+ @Test
+ public void canUpdateStatus() throws Exception {
+ try (LDClient client = new LDClient(SDK_KEY, config)) {
+ assertThat(client.getDataSourceStatusProvider().getStatus().getState(), equalTo(State.VALID));
+
+ ErrorInfo ei = ErrorInfo.fromHttpError(500);
+ td.updateStatus(State.INTERRUPTED, ei);
+
+ assertThat(client.getDataSourceStatusProvider().getStatus().getState(), equalTo(State.INTERRUPTED));
+ assertThat(client.getDataSourceStatusProvider().getStatus().getLastError(), equalTo(ei));
+ }
+ }
+
+ @Test
+ public void dataSourcePropagatesToMultipleClients() throws Exception {
+ td.update(td.flag("flag").on(true));
+
+ try (LDClient client1 = new LDClient(SDK_KEY, config)) {
+ try (LDClient client2 = new LDClient(SDK_KEY, config)) {
+ assertThat(client1.boolVariation("flag", new LDUser("user"), false), is(true));
+ assertThat(client2.boolVariation("flag", new LDUser("user"), false), is(true));
+
+ td.update(td.flag("flag").on(false));
+
+ assertThat(client1.boolVariation("flag", new LDUser("user"), false), is(false));
+ assertThat(client2.boolVariation("flag", new LDUser("user"), false), is(false));
+ }
+ }
+ }
+}