com.launchdarkly.sdk.server.integrations.TestData Maven / Gradle / Ivy
Show all versions of launchdarkly-java-server-sdk Show documentation
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);
this.rules = from.rules == null ? null : new ArrayList<>(from.rules);
}
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.
*
* 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
* @since 5.10.0
*/
public FlagBuilder variationForAll(boolean variation) {
return booleanFlag().variationForAll(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
* @since 5.10.0
*/
public FlagBuilder variationForAll(int variationIndex) {
return on(true).clearRules().clearUserTargets().fallthroughVariation(variationIndex);
}
/**
* Deprecated name for {@link #variationForAll(boolean)}.
*
* This method name will be dropped in a future SDK version because "users" will not always be the
* only kind of input for an evaluation.
*
* @param variation the desired true/false variation to be returned for all users
* @return the builder
* @deprecated Use {@link #variationForAll(boolean)}.
*/
@Deprecated
public FlagBuilder variationForAllUsers(boolean variation) {
return variationForAll(variation);
}
/**
* Deprecated name for {@link #variationForAll(int)}.
*
* This method name will be dropped in a future SDK version because "users" will not always be the
* only kind of input for an evaluation.
*
* @param variationIndex the desired variation: 0 for the first, 1 for the second, etc.
* @return the builder
* @deprecated Use {@link #variationForAll(int)}.
*/
@Deprecated
public FlagBuilder variationForAllUsers(int variationIndex) {
return variationForAll(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 valueForAll(LDValue value) {
variations.clear();
variations.add(value);
return variationForAll(0);
}
/**
* Deprecated name for {@link #valueForAll(LDValue)}.
*
* This method name will be dropped in a future SDK version because "users" will not always be the
* only kind of input for an evaluation.
*
* @param value the desired value to be returned for all users
* @return the builder'
* @deprecated Use {@link #valueForAll(LDValue)}.
*/
@Deprecated
public FlagBuilder valueForAllUsers(LDValue value) {
return valueForAll(value);
}
/**
* 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);
}
}
}