diff --git a/.circleci/config.yml b/.circleci/config.yml index 2cb624d..a54648d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -24,9 +24,10 @@ workflows: with-coverage: true requires: - build-linux - - build-test-windows: - name: Java 11 - Windows - OpenJDK - openjdk-version: 11.0.2.01 +# Windows Java 11 build is temporarily disabled - see story 171428 +# - test-windows: +# name: Java 11 - Windows - OpenJDK +# openjdk-version: 11.0.2.01 - build-test-windows: name: Java 17 - Windows - OpenJDK openjdk-version: 17.0.1 diff --git a/README.md b/README.md index 5821bc4..319c026 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This version of the library works with Java 7 and above. ## Contributing -See [Contributing](https://github.com/launchdarkly/dotnet-sdk-common/blob/master/CONTRIBUTING.md). +See [Contributing](CONTRIBUTING.md). ## About LaunchDarkly diff --git a/buildSrc/src/main/kotlin/TestCoverageOverrides.kt b/buildSrc/src/main/kotlin/TestCoverageOverrides.kt index 8014382..697eef6 100644 --- a/buildSrc/src/main/kotlin/TestCoverageOverrides.kt +++ b/buildSrc/src/main/kotlin/TestCoverageOverrides.kt @@ -15,6 +15,7 @@ object TestCoverageOverrides { val methodsWithMissedLineCount = mapOf( "EvaluationReason.error(com.launchdarkly.sdk.EvaluationReason.ErrorKind)" to 1, "EvaluationReasonTypeAdapter.parse(com.google.gson.stream.JsonReader)" to 1, + "LDContext.urlEncodeKey(java.lang.String)" to 2, "LDValue.equals(java.lang.Object)" to 1, "LDValueTypeAdapter.read(com.google.gson.stream.JsonReader)" to 1, "json.LDGson.LDTypeAdapter.write(com.google.gson.stream.JsonWriter, java.lang.Object)" to 1, diff --git a/src/main/java/com/launchdarkly/sdk/AttributeRef.java b/src/main/java/com/launchdarkly/sdk/AttributeRef.java new file mode 100644 index 0000000..3db3a7e --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/AttributeRef.java @@ -0,0 +1,291 @@ +package com.launchdarkly.sdk; + +import java.util.HashMap; +import java.util.Map; + +import com.google.gson.annotations.JsonAdapter; +import com.launchdarkly.sdk.json.JsonSerializable; + +/** + * An attribute name or path expression identifying a value within an {@link LDContext}. + *
+ * Applications are unlikely to need to use the AttributeRef type directly, but see below + * for details of the string attribute reference syntax used by methods like + * {@link ContextBuilder#privateAttributes(String...)}. + *
+ * The reason to use this type directly is to avoid repetitive string parsing in code where + * efficiency is a priority; AttributeRef parses its contents once when it is created, and + * is immutable afterward. If an AttributeRef instance was created from an invalid string, + * it is considered invalid and its {@link #getError()} method will return a non-null error. + *
+ * The string representation of an attribute reference in LaunchDarkly JSON data uses the + * following syntax: + *
+ * This method always returns an AttributeRef that preserves the original string, even if + * validation fails, so that calling {@link #toString()} (or serializing the AttributeRef + * to JSON) will produce the original string. If validation fails, {@link #getError()} will + * return a non-null error and any SDK method that takes this AttributeRef as a parameter + * will consider it invalid. + * + * @param refPath an attribute name or path + * @return an AttributeRef + * @see #fromLiteral(String) + */ + public static AttributeRef fromPath(String refPath) { + if (refPath == null || refPath.isEmpty() || refPath.equals("/")) { + return new AttributeRef(Errors.ATTR_EMPTY, refPath); + } + if (refPath.charAt(0) != '/') { + // When there is no leading slash, this is a simple attribute reference with no character escaping. + return new AttributeRef(refPath, refPath, null); + } + if (refPath.indexOf('/', 1) < 0) { + // There's only one segment, so this is still a simple attribute reference. However, we still may + // need to unescape special characters. + String unescaped = unescapePath(refPath.substring(1)); + if (unescaped == null) { + return new AttributeRef(Errors.ATTR_INVALID_ESCAPE, refPath); + } + return new AttributeRef(refPath, unescaped, null); + } + if (refPath.endsWith("/")) { + // String.split won't behave properly in this case + return new AttributeRef(Errors.ATTR_EXTRA_SLASH, refPath); + } + String[] parsed = refPath.substring(1).split("/"); + for (int i = 0; i < parsed.length; i++) { + String p = parsed[i]; + if (p.isEmpty()) { + return new AttributeRef(Errors.ATTR_EXTRA_SLASH, refPath); + } + String unescaped = unescapePath(p); + if (unescaped == null) { + return new AttributeRef(Errors.ATTR_INVALID_ESCAPE, refPath); + } + parsed[i] = unescaped; + } + return new AttributeRef(refPath, null, parsed); + } + + /** + * Similar to {@link #fromPath(String)}, except that it always interprets the string as a literal + * attribute name, never as a slash-delimited path expression. + *
+ * There is no escaping or unescaping, even if the name contains literal '/' or '~' characters. + * Since an attribute name can contain any characters, this method always returns a valid + * AttributeRef unless the name is empty. + *
+ * For example: {@code AttributeRef.fromLiteral("name")} is exactly equivalent to + * {@code AttributeRef.fromPath("name")}. {@code AttributeRef.fromLiteral("a/b")} is exactly + * equivalent to {@code AttributeRef.fromPath("a/b")} (since the syntax used by + * {@link #fromPath(String)} treats the whole string as a literal as long as it does not start + * with a slash), or to {@code AttributeRef.fromPath("/a~1b")}. + * + * @param attributeName an attribute name + * @return an AttributeRef + * @see #fromPath(String) + */ + public static AttributeRef fromLiteral(String attributeName) { + if (attributeName == null || attributeName.isEmpty()) { + return new AttributeRef(Errors.ATTR_EMPTY, ""); + } + if (attributeName.charAt(0) != '/') { + // When there is no leading slash, this is a simple attribute reference with no character escaping. + AttributeRef internedInstance = COMMON_LITERALS.get(attributeName); + return internedInstance == null ? new AttributeRef(attributeName, attributeName, null) : internedInstance; + } + // If there is a leading slash, then the attribute name actually starts with a slash. To represent it + // as an AttributeRef, it'll need to be escaped. + String escapedPath = "/" + attributeName.replace("~", "~0").replace("/", "~1"); + return new AttributeRef(escapedPath, attributeName, null); + } + + /** + * True for a valid AttributeRef, false for an invalid AttributeRef. + *
+ * An AttributeRef can only be invalid for the following reasons: + *
+ * Otherwise, the AttributeRef is valid, but that does not guarantee that such an attribute exists + * in any given {@link LDContext}. For instance, {@code fromLiteral("name")} is a valid AttributeRef, + * but a specific {@link LDContext} might or might not have a name. + *
+ * See comments on the {@link AttributeRef} type for more details of the attribute reference synax. + * + * @return true if the instance is valid + * @see #getError() + */ + public boolean isValid() { + return error == null; + } + + /** + * Null for a valid AttributeRef, or a non-null error message for an invalid AttributeRef. + *
+ * If this is null, then {@link #isValid()} is true. If it is non-null, then {@link #isValid()} is false. + * + * @return an error string or null + * @see #isValid() + */ + public String getError() { + return error; + } + + /** + * The number of path components in the AttributeRef. + *
+ * For a simple attribute reference such as "name" with no leading slash, this returns 1. + *
+ * For an attribute reference with a leading slash, it is the number of slash-delimited path + * components after the initial slash. For instance, {@code AttributeRef.fromPath("/a/b").getDepth()} + * returns 2. + *
+ * For an invalid attribute reference, it returns zero + * + * @return the number of path components + */ + public int getDepth() { + if (error != null) { + return 0; + } + return components == null ? 1 : components.length; + } + + /** + * Retrieves a single path component from the attribute reference. + *
+ * For a simple attribute reference such as "name" with no leading slash, getComponent returns the + * attribute name if index is zero, and null otherwise. + *
+ * For an attribute reference with a leading slash, if index is non-negative and less than + * {@link #getDepth()}, getComponent returns the path component string at that position. + * + * @param index the zero-based index of the desired path component + * @return the path component, or null if not available + */ + public String getComponent(int index) { + if (components == null) { + return index == 0 ? singlePathComponent : null; + } + return index < 0 || index >= components.length ? null : components[index]; + } + + /** + * Returns the attribute reference as a string, in the same format used by + * {@link #fromPath(String)}. + *
+ * If the AttributeRef was created with {@link #fromPath(String)}, this value is identical to
+ * to the original string. If it was created with {@link #fromLiteral(String)}, the value may
+ * be different due to unescaping (for instance, an attribute whose name is "/a" would be
+ * represented as "~1a").
+ *
+ * @return the attribute reference string (guaranteed non-null)
+ */
+ @Override
+ public String toString() {
+ return rawPath;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (other instanceof AttributeRef) {
+ AttributeRef o = (AttributeRef)other;
+ return rawPath.equals(o.rawPath);
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return rawPath.hashCode();
+ }
+
+ @Override
+ public int compareTo(AttributeRef o) {
+ return rawPath.compareTo(o.rawPath);
+ }
+
+ private static String unescapePath(String path) {
+ // If there are no tildes then there's definitely nothing to do
+ if (path.indexOf('~') < 0) {
+ return path;
+ }
+ StringBuilder ret = new StringBuilder(100); // arbitrary initial capacity
+ for (int i = 0; i < path.length(); i++) {
+ char ch = path.charAt(i);
+ if (ch != '~')
+ {
+ ret.append(ch);
+ continue;
+ }
+ i++;
+ if (i >= path.length())
+ {
+ return null;
+ }
+ switch (path.charAt(i)) {
+ case '0':
+ ret.append('~');
+ break;
+ case '1':
+ ret.append('/');
+ break;
+ default:
+ return null;
+ }
+ }
+ return ret.toString();
+ }
+
+ private static Map
+ * Use this type if you need to construct a context that has only a single kind. To define a
+ * multi-kind context, use {@link LDContext#createMulti(LDContext...)} or
+ * {@link LDContext#multiBuilder()}.
+ *
+ * Obtain an instance of ContextBuilder by calling {@link LDContext#builder(String)} or
+ * {@link LDContext#builder(ContextKind, String)}. Then, call setter methods such as
+ * {@link #name(String)} or {@link #set(String, String)} to specify any additional attributes.
+ * Then, call {@link #build()} to create the context. ContextBuilder setters return a reference
+ * to the same builder, so calls can be
+ * chained:
+ *
+ * A ContextBuilder should not be accessed by multiple threads at once. Once you have called
+ * {@link #build()}, the resulting LDContext is immutable and is safe to use from multiple
+ * threads. Instances created with {@link #build()} are not affected by subsequent actions
+ * taken on the builder.
+ */
+public final class ContextBuilder {
+ private ContextKind kind;
+ private String key;
+ private String name;
+ private Map
+ * The LDContext is immutable and will not be affected by any subsequent actions on the
+ * ContextBuilder.
+ *
+ * It is possible to specify invalid attributes for a ContextBuilder, such as an empty key.
+ * Instead of throwing an exception, the ContextBuilder always returns an LDContext and
+ * you can check {@link LDContext#isValid()} or {@link LDContext#getError()} to see if it
+ * has an error. See {@link LDContext#isValid()} for more information about invalid
+ * conditions. If you pass an invalid LDContext to an SDK method, the SDK will detect this
+ * and will log a description of the error.
+ *
+ * @return a new {@link LDContext}
+ */
+ public LDContext build() {
+ this.copyOnWriteAttributes = attributes != null;
+ this.copyOnWritePrivateAttributes = privateAttributes != null;
+
+ return LDContext.createSingle(kind, key, name, attributes, anonymous, privateAttributes, allowEmptyKey);
+ }
+
+ /**
+ * Sets the context's kind attribute.
+ *
+ * Every LDContext has a kind. Setting it to an empty string or null is equivalent to
+ * {@link ContextKind#DEFAULT} ("user"). This value is case-sensitive. For validation
+ * rules, see {@link ContextKind}.
+ *
+ * @param kind the context kind
+ * @return the builder
+ * @see LDContext#getKind()
+ */
+ public ContextBuilder kind(ContextKind kind) {
+ this.kind = kind;
+ return this;
+ }
+
+ /**
+ * Sets the context's kind attribute, as a string.
+ *
+ * This method is a shortcut for calling {@code kind(ContextKind.of(kindName))}, since the
+ * method name already prevents ambiguity about the intended type
+ *
+ * @param kindString the context kind
+ * @return the builder
+ * @see LDContext#getKind()
+ */
+ public ContextBuilder kind(String kindString) {
+ return kind(ContextKind.of(kindString));
+ }
+
+ /**
+ * Sets the context's key attribute.
+ *
+ * Every Context has a key, which is always a string. It cannot be an empty string, but
+ * there are no other restrictions on its value.
+ *
+ * The key attribute can be referenced by flag rules, flag target lists, and segments.
+ *
+ * @param key the context key
+ * @return the builder
+ * @see LDContext#getKey()
+ */
+ public ContextBuilder key(String key) {
+ this.key = key;
+ return this;
+ }
+
+ /**
+ * Sets the context's name attribute.
+ *
+ * This attribute is optional. It has the following special rules:
+ *
+ * The default value is false. False means that this LDContext represents an entity
+ * such as a user that you want to be able to see on the LaunchDarkly dashboard.
+ *
+ * Setting {@code anonymous} to true excludes this context from the database that is
+ * used by the dashboard. It does not exclude it from analytics event data, so it is
+ * not the same as making attributes private; all non-private attributes will still be
+ * included in events and data export. There is no limitation on what other attributes
+ * may be included (so, for instance, {@code anonymous} does not mean there is no
+ * {@code name}), and the context will still have whatever {@code key} you have given it.
+ *
+ * This value is also addressable in evaluations as the attribute name "anonymous". It
+ * is always treated as a boolean true or false in evaluations.
+ *
+ * @param anonymous true if the context should be excluded from the LaunchDarkly database
+ * @return the builder
+ * @see LDContext#isAnonymous()
+ */
+ public ContextBuilder anonymous(boolean anonymous) {
+ this.anonymous = anonymous;
+ return this;
+ }
+
+ /**
+ * Sets the value of any attribute for the context.
+ *
+ * This includes only attributes that are addressable in evaluations-- not metadata
+ * such as {@link #privateAttributes(String...)}. If {@code attributeName} is
+ * "privateAttributes", you will be setting an attribute with that name which you can
+ * use in evaluations or to record data for your own purposes, but it will be unrelated
+ * to {@link #privateAttributes(String...)}.
+ *
+ * This method uses the {@link LDValue} type to represent a value of any JSON type:
+ * null, boolean, number, string, array, or object. For all attribute names that do
+ * not have special meaning to LaunchDarkly, you may use any of those types. Values of
+ * different JSON types are always treated as different values: for instance, null,
+ * false, and the empty string "" are not the the same, and the number 1 is not the
+ * same as the string "1".
+ *
+ * The following attribute names have special restrictions on their value types, and
+ * any value of an unsupported type will be ignored (leaving the attribute unchanged):
+ *
+ * The attribute name "_meta" is not allowed, because it has special meaning in the
+ * JSON schema for contexts; any attempt to set an attribute with this name has no
+ * effect. Also, any attempt to set an attribute with an empty or null name has no effect.
+ *
+ * Values that are JSON arrays or objects have special behavior when referenced in
+ * flag/segment rules.
+ *
+ * A value of {@code null} or {@link LDValue#ofNull()} is equivalent to removing any
+ * current non-default value of the attribute. Null is not a valid attribute value in
+ * the LaunchDarkly model; any expressions in feature flags that reference an attribute
+ * with a null value will behave as if the attribute did not exist.
+ *
+ * @param attributeName the attribute name to set
+ * @param value the value to set
+ * @return the builder
+ * @see #set(String, boolean)
+ * @see #set(String, int)
+ * @see #set(String, double)
+ * @see #set(String, String)
+ * @see #trySet(String, LDValue)
+ */
+ public ContextBuilder set(String attributeName, LDValue value) {
+ trySet(attributeName, value);
+ return this;
+ }
+
+ /**
+ * Same as {@link #set(String, LDValue)} for a boolean value.
+ *
+ * @param attributeName the attribute name to set
+ * @param value the value to set
+ * @return the builder
+ * @see #set(String, LDValue)
+ */
+ public ContextBuilder set(String attributeName, boolean value) {
+ return set(attributeName, LDValue.of(value));
+ }
+
+ /**
+ * Same as {@link #set(String, LDValue)} for an integer numeric value.
+ *
+ * @param attributeName the attribute name to set
+ * @param value the value to set
+ * @return the builder
+ * @see #set(String, LDValue)
+ */
+ public ContextBuilder set(String attributeName, int value) {
+ return set(attributeName, LDValue.of(value));
+ }
+
+ /**
+ * Same as {@link #set(String, LDValue)} for a double-precision numeric value.
+ *
+ * @param attributeName the attribute name to set
+ * @param value the value to set
+ * @return the builder
+ * @see #set(String, LDValue)
+ */
+ public ContextBuilder set(String attributeName, double value) {
+ return set(attributeName, LDValue.of(value));
+ }
+
+ /**
+ * Same as {@link #set(String, LDValue)} for a string value.
+ *
+ * @param attributeName the attribute name to set
+ * @param value the value to set
+ * @return the builder
+ * @see #set(String, LDValue)
+ */
+ public ContextBuilder set(String attributeName, String value) {
+ return set(attributeName, LDValue.of(value));
+ }
+
+ /**
+ * Same as {@link #set(String, LDValue)}, but returns a boolean indicating whether
+ * the attribute was successfully set.
+ *
+ * @param attributeName the attribute name to set
+ * @param value the value to set
+ * @return true if successful; false if the name was invalid or the value was not
+ * an allowed type for that attribute
+ */
+ public boolean trySet(String attributeName, LDValue value) {
+ if (attributeName == null || attributeName.isEmpty()) {
+ return false;
+ }
+ switch (attributeName) {
+ case "kind":
+ if (!value.isString()) {
+ return false;
+ }
+ kind = ContextKind.of(value.stringValue());
+ break;
+ case "key":
+ if (!value.isString()) {
+ return false;
+ }
+ key = value.stringValue();
+ break;
+ case "name":
+ if (!value.isString() && !value.isNull()) {
+ return false;
+ }
+ name = value.stringValue();
+ break;
+ case "anonymous":
+ if (value.getType() != LDValueType.BOOLEAN) {
+ return false;
+ }
+ anonymous = value.booleanValue();
+ break;
+ case "_meta":
+ return false;
+ default:
+ if (copyOnWriteAttributes) {
+ attributes = new HashMap<>(attributes);
+ copyOnWriteAttributes = false;
+ }
+ if (value == null || value.isNull()) {
+ if (attributes != null) {
+ attributes.remove(attributeName);
+ }
+ } else {
+ if (attributes == null) {
+ attributes = new HashMap<>();
+ }
+ attributes.put(attributeName, value);
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Designates any number of context attributes, or properties within them, as private:
+ * that is, their values will not be recorded by LaunchDarkly.
+ *
+ * Each parameter can be either a simple attribute name (like "email"), or an attribute
+ * reference in the syntax described for {@link AttributeRef} (like "/address/street").
+ *
+ * @param attributeRefs attribute references to mark as private
+ * @return the builder
+ */
+ public ContextBuilder privateAttributes(String... attributeRefs) {
+ if (attributeRefs == null || attributeRefs.length == 0) {
+ return this;
+ }
+ prepareToChangePrivate();
+ for (String a: attributeRefs) {
+ privateAttributes.add(AttributeRef.fromPath(a));
+ }
+ return this;
+ }
+
+ /**
+ * Equivalent to {@link #privateAttributes(String...)}, but uses the {@link AttributeRef}
+ * type.
+ *
+ * Application code is unlikely to need to use the {@link AttributeRef} type directly;
+ * however, in cases where you are constructing LDContexts constructed repeatedly with
+ * the same set of private attributes, if you are also using complex private attribute
+ * path references such as "/address/street", converting this to an AttributeRef once
+ * and reusing it in many {@code privateAttribute} calls is slightly more efficient
+ * than passing a string (since it does not need to parse the path repeatedly).
+ *
+ * @param attributeRefs attribute references to mark as private
+ * @return the builder
+ */
+ public ContextBuilder privateAttributes(AttributeRef... attributeRefs) {
+ if (attributeRefs == null || attributeRefs.length == 0) {
+ return this;
+ }
+ prepareToChangePrivate();
+ for (AttributeRef a: attributeRefs) {
+ privateAttributes.add(a);
+ }
+ return this;
+ }
+
+ // Deliberately not public - this is how we make it possible to deserialize an old-style user
+ // from JSON where the key is an empty string, because that was allowed in the old schema,
+ // whereas in all other cases a context key must not be an empty string.
+ void setAllowEmptyKey(boolean allowEmptyKey) {
+ this.allowEmptyKey = allowEmptyKey;
+ }
+
+ ContextBuilder copyFrom(LDContext context) {
+ kind = context.getKind();
+ key = context.getKey();
+ name = context.getName();
+ anonymous = context.isAnonymous();
+ attributes = context.attributes;
+ privateAttributes = context.privateAttributes;
+ copyOnWriteAttributes = true;
+ copyOnWritePrivateAttributes = true;
+ return this;
+ }
+
+ private void prepareToChangePrivate() {
+ if (copyOnWritePrivateAttributes) {
+ privateAttributes = new ArrayList<>(privateAttributes);
+ copyOnWritePrivateAttributes = false;
+ } else if (privateAttributes == null) {
+ privateAttributes = new ArrayList<>();
+ }
+ }
+}
diff --git a/src/main/java/com/launchdarkly/sdk/ContextKind.java b/src/main/java/com/launchdarkly/sdk/ContextKind.java
new file mode 100644
index 0000000..7745839
--- /dev/null
+++ b/src/main/java/com/launchdarkly/sdk/ContextKind.java
@@ -0,0 +1,120 @@
+package com.launchdarkly.sdk;
+
+import com.google.gson.annotations.JsonAdapter;
+import com.launchdarkly.sdk.json.JsonSerializable;
+
+/**
+ * A string identifier provided by the application to describe what kind of entity an
+ * {@link LDContext} represents.
+ *
+ * The type is a simple wrapper for a String. Using a type that is not just String
+ * makes it clearer where a context kind is expected or returned in the SDK API, so it
+ * cannot be confused with other important strings such as the context key. To convert
+ * a literal string to this type, use the factory method {@link #of(String)}.
+ *
+ * The meaning of the context kind is completely up to the application. Validation rules are
+ * as follows:
+ *
+ * If no kind is specified, the default is "user" (the constant {@link #DEFAULT}).
+ *
+ * For a multi-kind context (see {@link LDContext#createMulti(LDContext...)}), the kind of
+ * the top-level LDContext is always "multi" (the constant {@link #MULTI}); there is a
+ * specific Kind for each of the contexts contained within it.
+ *
+ * To learn more, read the
+ * documentation.
+ */
+@JsonAdapter(ContextKindTypeAdapter.class)
+public final class ContextKind implements Comparable
+ * A value of null or "" will be changed to {@link #DEFAULT}.
+ *
+ * @param stringValue the string value
+ * @return a ContextKind
+ */
+ public static ContextKind of(String stringValue) {
+ if (stringValue == null || stringValue.isEmpty() || stringValue.equals(DEFAULT.kindName)) {
+ return DEFAULT;
+ }
+ if (stringValue.equals(MULTI.kindName)) {
+ return MULTI;
+ }
+ return new ContextKind(stringValue);
+ }
+
+ /**
+ * True if this is equal to {@link #DEFAULT} ("user").
+ * @return true if this is the default kind
+ */
+ public boolean isDefault() {
+ return this == DEFAULT; // can use == here because of() ensures there's only one instance with that value
+ }
+
+ /**
+ * Returns the string value of the context kind. This is never null.
+ */
+ @Override
+ public String toString() {
+ return kindName;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return other instanceof ContextKind &&
+ (this == other || kindName.equals(((ContextKind)other).kindName));
+ }
+
+ @Override
+ public int hashCode() {
+ return kindName.hashCode();
+ }
+
+ String validateAsSingleKind() {
+ if (isDefault()) {
+ return null;
+ }
+ if (this == MULTI) {
+ return Errors.CONTEXT_KIND_MULTI_FOR_SINGLE;
+ }
+ if (kindName.equals("kind")) {
+ return Errors.CONTEXT_KIND_CANNOT_BE_KIND;
+ }
+ for (int i = 0; i < kindName.length(); i++) {
+ char ch = kindName.charAt(i);
+ if ((ch < 'a' || ch > 'z') && (ch < 'A' || ch > 'Z') && (ch < '0' || ch > '9') &&
+ ch != '.' && ch != '_' && ch != '-')
+ {
+ return Errors.CONTEXT_KIND_INVALID_CHARS;
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public int compareTo(ContextKind o) {
+ return kindName.compareTo(o.kindName);
+ }
+}
diff --git a/src/main/java/com/launchdarkly/sdk/ContextKindTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/ContextKindTypeAdapter.java
new file mode 100644
index 0000000..af0c4c4
--- /dev/null
+++ b/src/main/java/com/launchdarkly/sdk/ContextKindTypeAdapter.java
@@ -0,0 +1,19 @@
+package com.launchdarkly.sdk;
+
+import com.google.gson.TypeAdapter;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonWriter;
+
+import java.io.IOException;
+
+final class ContextKindTypeAdapter extends TypeAdapter
+ * Use this builder if you need to construct a context that has multiple {@link ContextKind}
+ * values, each with its own corresponding LDContext. To define a single-kind context,
+ * use {@link LDContext#builder(String)} or any of the single-kind factory methods
+ * in {@link LDContext}.
+ *
+ * Obtain an instance of ContextMultiBuilder by calling {@link LDContext#multiBuilder()};
+ * then, call {@link #add(LDContext)} to specify the individual context for each kind. The
+ * {@link #add(LDContext)} method returns a reference to the same builder, so calls can be
+ * chained:
+ *
+ * A ContextMultiBuilder should not be accessed by multiple threads at once. Once you have
+ * called {@link #build()}, the resulting LDContext is immutable and is safe to use from
+ * multiple threads. Instances created with {@link #build()} are not affected by subsequent
+ * actions taken on the builder.
+ *
+ * @see LDContext#createMulti(LDContext...)
+ */
+public final class ContextMultiBuilder {
+ private List
+ * The LDContext is immutable and will not be affected by any subsequent actions on the
+ * builder.
+ *
+ * It is possible for a ContextMultiBuilder to represent an invalid state. Instead of
+ * throwing an exception, the ContextMultiBuilder always returns an LDContext, and you
+ * can check {@link LDContext#isValid()} or {@link LDContext#getError()} to see if it
+ * has an error. See {@link LDContext#isValid()} for more information about invalid
+ * context conditions. If you pass an invalid context to an SDK method, the SDK will
+ * detect this and will log a description of the error.
+ *
+ * If only one context kind was added to the builder, this method returns a single-kind
+ * LDContext rather than a multi-kind one.
+ *
+ * @return a new {@link LDContext}
+ */
+ public LDContext build() {
+ if (contexts == null || contexts.size() == 0) {
+ return LDContext.failed(Errors.CONTEXT_KIND_MULTI_WITH_NO_KINDS);
+ }
+ if (contexts.size() == 1) {
+ return contexts.get(0);
+ }
+
+ LDContext[] contextsArray = contexts.toArray(new LDContext[contexts.size()]);
+ return LDContext.createMultiInternal(contextsArray);
+ }
+
+ /**
+ * Adds an individual LDContext for a specific kind to the builer.
+ *
+ * It is invalid to add more than one LDContext for the same kind, or to add an LDContext
+ * that is itself invalid. This error is detected when you call {@link #build()}.
+ *
+ * If the nested context is multi-kind, this is exactly equivalent to adding each of the
+ * individual kinds from it separately. For instance, in the following example, "multi1" and
+ * "multi2" end up being exactly the same:
+ *
+ * LDContext is the newer replacement for the previous, less flexible {@link LDUser} type.
+ * The current SDK still supports LDUser, but LDContext is now the preferred model and may
+ * entirely replace LDUser in the future.
+ *
+ * To create an LDContext of a single kind, such as a user, you may use {@link #create(String)}
+ * or {@link #create(ContextKind, String)} when only the key matters; or, to specify other
+ * attributes, use {@link #builder(String)}.
+ *
+ * To create an LDContext with multiple kinds, use {@link #createMulti(LDContext...)} or
+ * {@link #multiBuilder()}.
+ *
+ * An LDContext can be in an error state if it was built with invalid attributes. See
+ * {@link #isValid()} and {@link #getError()}.
+ *
+ * LaunchDarkly defines a standard JSON encoding for contexts, used by the JavaScript SDK
+ * and also in analytics events. {@link LDContext} can be converted to and from JSON in any of
+ * these ways:
+ *
+ * To learn more about contexts, read the
+ * documentation.
+ */
+@JsonAdapter(LDContextTypeAdapter.class)
+public final class LDContext implements JsonSerializable {
+ static final String ATTR_KIND = "kind";
+ static final String ATTR_KEY = "key";
+ static final String ATTR_NAME = "name";
+ static final String ATTR_ANONYMOUS = "anonymous";
+
+
+ final String error;
+ final ContextKind kind;
+ final LDContext[] multiContexts;
+ final String key;
+ final String fullyQualifiedKey;
+ final String name;
+ final Map
+ * To specify additional properties, use {@link #builder(String)}. To create a multi-kind
+ * LDContext, use {@link #createMulti(LDContext...)} or {@link #multiBuilder()}. To create a
+ * single-kind LDContext of a different kind than "user", use {@link #create(ContextKind, String)}.
+ *
+ * @param key the context key
+ * @return an LDContext
+ * @see #create(ContextKind, String)
+ * @see #builder(String)
+ */
+ public static LDContext create(String key) {
+ return create(ContextKind.DEFAULT, key);
+ }
+
+ /**
+ * Creates a single-kind LDContext with only the kind and keys specified.
+ *
+ * To specify additional properties, use {@link #builder(ContextKind, String)}. To create a multi-kind
+ * LDContext, use {@link #createMulti(LDContext...)} or {@link #multiBuilder()}.
+ *
+ * @param kind the context kind; if null, {@link ContextKind#DEFAULT} will be used
+ * @param key the context key
+ * @return an LDContext
+ * @see #create(String)
+ * @see #builder(ContextKind, String)
+ */
+ public static LDContext create(ContextKind kind, String key) {
+ return createSingle(kind, key, null, null, false, null, false);
+ }
+
+ /**
+ * Creates a multi-kind LDContext out of the specified single-kind LDContexts.
+ *
+ * To create a single-kind Context, use {@link #create(String)}, {@link #create(ContextKind, String)},
+ * or {@link #builder(String)}.
+ *
+ * For the returned LDContext to be valid, the contexts list must not be empty, and all of its
+ * elements must be valid LDContexts. Otherwise, the returned LDContext will be invalid as
+ * reported by {@link #getError()}.
+ *
+ * If only one context parameter is given, the method returns that same context.
+ *
+ * If the nested context is multi-kind, this is exactly equivalent to adding each of the
+ * individual kinds from it separately. For instance, in the following example, "multi1" and
+ * "multi2" end up being exactly the same:
+ *
+ * This method is used by the SDK whenever an application passes a {@link LDUser} instance
+ * to methods such as {@code identify}. The SDK operates internally on the {@link LDContext}
+ * model, which is more flexible than the older LDUser model: an L User can always be converted
+ * to an LDContext, but not vice versa. The {@link ContextKind} of the resulting Context is
+ * {@link ContextKind#DEFAULT} ("user").
+ *
+ * Because there is some overhead to this conversion, it is more efficient for applications to
+ * construct an LDContext and pass that to the SDK, rather than an LDUser. This is also recommended
+ * because the LDUser type may be removed in a future version of the SDK.
+ *
+ * If the {@code user} parameter is null, or if the user has a null key, the method returns an
+ * LDContext in an invalid state (see {@link LDContext#isValid()}).
+ *
+ * @param user an LDUser object
+ * @return an LDContext with the same attributes as the LDUser
+ */
+ public static LDContext fromUser(LDUser user) {
+ if (user == null) {
+ return failed(Errors.CONTEXT_FROM_NULL_USER);
+ }
+ String key = user.getKey();
+ if (key == null) {
+ if (user.isAnonymous()) {
+ // In the old user model, a user was able to have a null key for the special case
+ // where (in the Android SDK only) the user was anonymous and the SDK would generate a
+ // key for it. There is a different mechanism for this in the new Android SDK, but we
+ // will replace the null key with "" so the original context is valid.
+ key = "";
+ } else {
+ return failed(Errors.CONTEXT_NO_KEY);
+ }
+ }
+ Map
+ * You may use {@link ContextBuilder} methods to set additional attributes and/or change the
+ * {@link ContextBuilder#kind(ContextKind)} before calling {@link ContextBuilder#build()}.
+ * If you do not change any values, the defaults for the LDContext are that its {@code kind} is
+ * {@link ContextKind#DEFAULT} ("user"), its {@code key} is set to the key parameter passed here,
+ * {@code anonymous} is {@code false}, and it has no values for any other attributes.
+ *
+ * This method is for building an LDContext that has only a single Kind. To define a multi-kind
+ * LDContext, use {@link #multiBuilder()}.
+ *
+ * if {@code key} is an empty string, there is no default. An LDContext must have a non-empty
+ * key, so if you call {@link ContextBuilder#build()} in this state without using
+ * {@link ContextBuilder#key(String)} to set the key, you will get an invalid LDContext.
+ *
+ * @param key the context key
+ * @return a builder
+ * @see #builder(ContextKind, String)
+ * @see #multiBuilder()
+ * @see #create(String)
+ */
+ public static ContextBuilder builder(String key) {
+ return builder(ContextKind.DEFAULT, key);
+ }
+
+ /**
+ * Creates a {@link ContextBuilder} for building an LDContext, initializing its {@code key} and
+ * {@code kind}.
+ *
+ * You may use {@link ContextBuilder} methods to set additional attributes and/or change the
+ * {@link ContextBuilder#kind(ContextKind)} before calling {@link ContextBuilder#build()}.
+ * If you do not change any values, the defaults for the LDContext are that its {@code kind} and
+ * {@code key} is set to the parameters passed here, {@code anonymous} is {@code false}, and it has
+ * no values for any other attributes.
+ *
+ * This method is for building an LDContext that has only a single Kind. To define a multi-kind
+ * LDContext, use {@link #multiBuilder()}.
+ *
+ * if {@code key} is an empty string, there is no default. An LDContext must have a non-empty
+ * key, so if you call {@link ContextBuilder#build()} in this state without using
+ * {@link ContextBuilder#key(String)} to set the key, you will get an invalid LDContext.
+ *
+ * @param kind the context kind; if null, {@link ContextKind#DEFAULT} is used
+ * @param key the context key
+ * @return a builder
+ * @see #builder(String)
+ * @see #multiBuilder()
+ * @see #create(ContextKind, String)
+ */
+ public static ContextBuilder builder(ContextKind kind, String key) {
+ return new ContextBuilder(kind, key);
+ }
+
+ /**
+ * Creates a builder whose properties are the same as an existing single-kind LDContext.
+ *
+ * You may then change the builder's state in any way and call {@link ContextBuilder#build()}
+ * to create a new independent LDContext.
+ *
+ * @param context the context to copy from
+ * @return a builder
+ * @see #builder(String)
+ */
+ public static ContextBuilder builderFromContext(LDContext context) {
+ return new ContextBuilder().copyFrom(context);
+ }
+
+ /**
+ * Creates a {@link ContextMultiBuilder} for building a multi-kind context.
+ *
+ * This method is for building a Context that has multiple {@link ContextKind} values,
+ * each with its own nested LDContext. To define a single-kind context, use
+ * {@link #builder(String)} instead.
+ *
+ * @return a builder
+ * @see #createMulti(LDContext...)
+ */
+ public static ContextMultiBuilder multiBuilder() {
+ return new ContextMultiBuilder();
+ }
+
+ /**
+ * Returns {@code true} for a valid LDContext, {@code false} for an invalid one.
+ *
+ * A valid context is one that can be used in SDK operations. An invalid context is one that
+ * is missing necessary attributes or has invalid attributes, indicating an incorrect usage
+ * of the SDK API. The only ways for a context to be invalid are:
+ *
+ * In any of these cases, {@link #isValid()} will return false, and {@link #getError()}
+ * will return a description of the error.
+ *
+ * Since in normal usage it is easy for applications to be sure they are using context kinds
+ * correctly, and because throwing an exception is undesirable in application code that uses
+ * LaunchDarkly, the SDK stores the error state in the LDContext itself and checks for such
+ * errors at the time the Context is used, such as in a flag evaluation. At that point, if
+ * the context is invalid, the operation will fail in some well-defined way as described in
+ * the documentation for that method, and the SDK will generally log a warning as well. But
+ * in any situation where you are not sure if you have a valid LDContext, you can check
+ * {@link #isValid()} or {@link #getError()}.
+ *
+ * @return true if the context is valid
+ * @see #getError()
+ */
+ public boolean isValid() {
+ return error == null;
+ }
+
+ /**
+ * Returns null for a valid LDContext, or an error message for an invalid one.
+ *
+ * If this is null, then {@link #isValid()} is true. If it is non-null, then {@link #isValid()}
+ * is false.
+ *
+ * @return an error description or null
+ * @see #isValid()
+ */
+ public String getError() {
+ return error;
+ }
+
+ /**
+ * Returns the context's {@code kind} attribute.
+ *
+ * Every valid context has a non-empty {@link ContextKind}. For multi-kind contexts, this value
+ * is {@link ContextKind#MULTI} and the kinds within the context can be inspected with
+ * {@link #getIndividualContext(int)} or {@link #getIndividualContext(String)}.
+ *
+ * @return the context kind
+ * @see ContextBuilder#kind(ContextKind)
+ */
+ public ContextKind getKind() {
+ return kind;
+ }
+
+ /**
+ * Returns true if this is a multi-kind context.
+ *
+ * If this value is true, then {@link #getKind()} is guaranteed to be
+ * {@link ContextKind#MULTI}, and you can inspect the individual contexts for each kind
+ * with {@link #getIndividualContext(int)} or {@link #getIndividualContext(ContextKind)}.
+ *
+ * If this value is false, then {@link #getKind()} is guaranteed to return a value that
+ * is not {@link ContextKind#MULTI}.
+ *
+ * @return true for a multi-kind context, false for a single-kind context
+ */
+ public boolean isMultiple() {
+ return multiContexts != null;
+ }
+
+ /**
+ * Returns the context's {@code key} attribute.
+ *
+ * For a single-kind context, this value is set by one of the LDContext factory methods
+ * or builders ({@link #create(String)}, {@link #create(ContextKind, String)},
+ * {@link #builder(String)}, {@link #builder(ContextKind, String)}).
+ *
+ * For a multi-kind context, there is no single value and {@link #getKey()} returns an
+ * empty string. Use {@link #getIndividualContext(int)} or {@link #getIndividualContext(String)}
+ * to inspect the LDContext for a particular kind, then call {@link #getKey()} on it.
+ *
+ * This value is never null.
+ *
+ * @return the context key
+ * @see ContextBuilder#key(String)
+ */
+ public String getKey() {
+ return key;
+ }
+
+ /**
+ * Returns the context's {@code name} attribute.
+ *
+ * For a single-kind context, this value is set by {@link ContextBuilder#name(String)}.
+ * It is null if no value was set.
+ *
+ * For a multi-kind context, there is no single value and {@link #getName()} returns null.
+ * Use {@link #getIndividualContext(int)} or {@link #getIndividualContext(String)} to
+ * inspect the LDContext for a particular kind, then call {@link #getName()} on it.
+ *
+ * @return the context name or null
+ * @see ContextBuilder#name(String)
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Returns true if this context is only intended for flag evaluations and will not be
+ * indexed by LaunchDarkly.
+ *
+ * The default value is false. False means that this LDContext represents an entity
+ * such as a user that you want to be able to see on the LaunchDarkly dashboard.
+ *
+ * Setting {@code anonymous} to true excludes this context from the database that is
+ * used by the dashboard. It does not exclude it from analytics event data, so it is
+ * not the same as making attributes private; all non-private attributes will still be
+ * included in events and data export. There is no limitation on what other attributes
+ * may be included (so, for instance, {@code anonymous} does not mean there is no
+ * {@code name}), and the context will still have whatever {@code key} you have given it.
+ *
+ * This value is also addressable in evaluations as the attribute name "anonymous". It
+ * is always treated as a boolean true or false in evaluations.
+ *
+ * @return true if the context should be excluded from the LaunchDarkly database
+ * @see ContextBuilder#anonymous(boolean)
+ */
+ public boolean isAnonymous() {
+ return anonymous;
+ }
+
+ /**
+ * Looks up the value of any attribute of the context by name.
+ *
+ * This includes only attributes that are addressable in evaluations-- not metadata such
+ * as {@link #getPrivateAttribute(int)}.
+ *
+ * For a single-kind context, the attribute name can be any custom attribute that was set
+ * by methods like {@link ContextBuilder#set(String, boolean)}. It can also be one of the
+ * built-in ones like "kind", "key", or "name"; in such cases, it is equivalent to
+ * {@link #getKind()}, {@link #getKey()}, or {@link #getName()}, except that the value is
+ * returned using the general-purpose {@link LDValue} type.
+ *
+ * For a multi-kind context, the only supported attribute name is "kind". Use
+ * {@link #getIndividualContext(int)} or {@link #getIndividualContext(ContextKind)} to
+ * inspect the LDContext for a particular kind and then get its attributes.
+ *
+ * This method does not support complex expressions for getting individual values out of
+ * JSON objects or arrays, such as "/address/street". Use {@link #getValue(AttributeRef)}
+ * with an {@link AttributeRef} for that purpose.
+ *
+ * If the value is found, the return value is the attribute value, using the type
+ * {@link LDValue} to represent a value of any JSON type.
+ *
+ * If there is no such attribute, the return value is {@link LDValue#ofNull()} (the method
+ * never returns a Java {@code null}). An attribute that actually exists cannot have a null
+ * value.
+ *
+ * @param attributeName the desired attribute name
+ * @return the value or {@link LDValue#ofNull()}
+ * @see #getValue(AttributeRef)
+ * @see ContextBuilder#set(String, String)
+ */
+ public LDValue getValue(String attributeName) {
+ return getTopLevelAttribute(attributeName);
+ }
+
+ /**
+ * Looks up the value of any attribute of the context, or a value contained within an
+ * attribute, based on an {@link AttributeRef}.
+ *
+ * This includes only attributes that are addressable in evaluations-- not metadata such
+ * as {@link #getPrivateAttribute(int)}.
+ *
+ * This implements the same behavior that the SDK uses to resolve attribute references
+ * during a flag evaluation. In a single-kind context, the {@link AttributeRef} can
+ * represent a simple attribute name-- either a built-in one like "name" or "key", or a
+ * custom attribute that was set by methods like {@link ContextBuilder#set(String, String)}--
+ * or, it can be a slash-delimited path using a JSON-Pointer-like syntax. See
+ * {@link AttributeRef} for more details.
+ *
+ * For a multi-kind context, the only supported attribute name is "kind". Use
+ * {@link #getIndividualContext(int)} or {@link #getIndividualContext(ContextKind)} to
+ * inspect the LDContext for a particular kind and then get its attributes.
+ *
+ * This method does not support complex expressions for getting individual values out of
+ * JSON objects or arrays, such as "/address/street". Use {@link #getValue(AttributeRef)}
+ * with an {@link AttributeRef} for that purpose.
+ *
+ * If the value is found, the return value is the attribute value, using the type
+ * {@link LDValue} to represent a value of any JSON type.
+ *
+ * If there is no such attribute, the return value is {@link LDValue#ofNull()} (the method
+ * never returns a Java {@code null}). An attribute that actually exists cannot have a null
+ * value.
+ * @param attributeRef an attribute reference
+ * @return the attribute value
+ */
+ public LDValue getValue(AttributeRef attributeRef) {
+ if (attributeRef == null || !attributeRef.isValid()) {
+ return LDValue.ofNull();
+ }
+
+ String name = attributeRef.getComponent(0);
+
+ if (isMultiple()) {
+ if (attributeRef.getDepth() == 1 && name.equals("kind")) {
+ return LDValue.of(kind.toString());
+ }
+ return LDValue.ofNull(); // multi-kind context has no other addressable attributes
+ }
+
+ // Look up attribute in single-kind context
+ LDValue value = getTopLevelAttribute(name);
+ if (value.isNull()) {
+ return value;
+ }
+ for (int i = 1; i < attributeRef.getDepth(); i++) {
+ String component = attributeRef.getComponent(i);
+ value = value.get(component); // returns LDValue.null() if either property isn't found or value isn't an object
+ if (value.isNull()) {
+ break;
+ }
+ }
+ return value;
+ }
+
+ /**
+ * Returns the names of all non-built-in attributes that have been set in this context.
+ *
+ * For a single-kind context, this includes all the names that were passed to
+ * any of the overloads of {@link ContextBuilder#set(String, LDValue)} as long as the
+ * values were not null (since a null value in LaunchDarkly is equivalent to the attribute
+ * not being set).
+ *
+ * For a multi-kind context, there are no such names.
+ *
+ * @return an iterable of strings (may be empty, but will never be null)
+ */
+ public Iterable
+ * For a valid single-kind context, this returns 1. For a multi-kind context, it returns
+ * the number of kinds that were added with {@link #createMulti(LDContext...)} or
+ * {@link #multiBuilder()}. For an invalid context, it returns zero.
+ *
+ * @return the number of context kinds
+ */
+ public int getIndividualContextCount() {
+ if (error != null) {
+ return 0;
+ }
+ return multiContexts == null ? 1 : multiContexts.length;
+ }
+
+ /**
+ * Returns the single-kind LDContext corresponding to one of the kinds in this context.
+ *
+ * If this method is called on a single-kind LDContext, then the only allowable value
+ * for {@code index} is zero, and the return value on success is the same LDContext. If
+ * the method is called on a multi-kind context, then index must be non-negative and
+ * less than the number of kinds (that is, less than the return value of
+ * {@link #getIndividualContextCount()}), and the return value on success is one of the
+ * individual LDContexts within.
+ *
+ * @param index the zero-based index of the context to get
+ * @return an {@link LDContext}, or null if the index was out of range
+ */
+ public LDContext getIndividualContext(int index) {
+ if (multiContexts == null) {
+ return index == 0 ? this : null;
+ }
+ return index < 0 || index >= multiContexts.length ? null : multiContexts[index];
+ }
+
+ /**
+ * Returns the single-kind LDContext corresponding to one of the kinds in this context.
+ *
+ * If this method is called on a single-kind LDContext, then the only allowable value
+ * for {@code kind} is the same as {@link #getKind()}, and the return value on success
+ * is the same LDContext. If the method is called on a multi-kind context, then
+ * {@code kind} should be match the kind of one of the contexts that was added with
+ * {@link #createMulti(LDContext...)} or {@link #multiBuilder()}, and the return value on
+ * success is the corresponding individual LDContext within.
+ *
+ * @param kind the context kind to get; if null, defaults to {@link ContextKind#DEFAULT}
+ * @return an {@link LDContext}, or null if that kind was not found
+ */
+ public LDContext getIndividualContext(ContextKind kind) {
+ if (kind == null) {
+ kind = ContextKind.DEFAULT;
+ }
+ if (multiContexts == null) {
+ return this.kind.equals(kind) ? this : null;
+ }
+ for (LDContext c: multiContexts) {
+ if (c.kind.equals(kind)) {
+ return c;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Same as {@link #getIndividualContext(ContextKind)}, but specifies the kind as a
+ * plain string.
+ *
+ * @param kind the context kind to get
+ * @return an {@link LDContext}, or null if that kind was not found
+ */
+ public LDContext getIndividualContext(String kind) {
+ if (kind == null || kind.isEmpty()) {
+ return getIndividualContext(ContextKind.DEFAULT);
+ }
+ if (multiContexts == null) {
+ return this.kind.toString().equals(kind) ? this : null;
+ }
+ for (LDContext c: multiContexts) {
+ if (c.kind.toString().equals(kind)) {
+ return c;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns the number of private attribute references that were specified for this context.
+ *
+ * This is equal to the total number of values passed to {@link ContextBuilder#privateAttributes(String...)}
+ * and/or its overload {@link ContextBuilder#privateAttributes(AttributeRef...)}.
+ *
+ * @return the number of private attribute references
+ */
+ public int getPrivateAttributeCount() {
+ return privateAttributes == null ? 0 : privateAttributes.size();
+ }
+
+ /**
+ * Retrieves one of the private attribute references that were specified for this context.
+ *
+ * @param index a non-negative index that must be less than {@link #getPrivateAttributeCount()}
+ * @return an {@link AttributeRef}, or null if the index was out of range
+ */
+ public AttributeRef getPrivateAttribute(int index) {
+ if (privateAttributes == null) {
+ return null;
+ }
+ return index < 0 || index >= privateAttributes.size() ? null : privateAttributes.get(index);
+ }
+
+ /**
+ * Returns a string that describes the LDContext uniquely based on {@code kind} and
+ * {@code key} values.
+ *
+ * This value is used whenever LaunchDarkly needs a string identifier based on all of the
+ * {@code kind} and {@code key} values in the context; the SDK may use this for caching
+ * previously seen contexts, for instance.
+ *
+ * @return the fully-qualified key
+ */
+ public String getFullyQualifiedKey() {
+ return fullyQualifiedKey;
+ }
+
+ /**
+ * Returns a string representation of the context.
+ *
+ * For a valid context, this is currently defined as being the same as the JSON representation,
+ * since that is the simplest way to represent all of the LDContext properties. However,
+ * application code should not rely on {@link #toString()} always being the same as the JSON
+ * representation. If you specifically want the latter, use {@link JsonSerialization#serialize(JsonSerializable)}.
+ *
+ * For an invalid context, {@link #toString()} returns a description of why it is invalid.
+ */
+ @Override
+ public String toString() {
+ if (!isValid()) {
+ return ("(invalid LDContext: " + getError() + ")");
+ }
+ return JsonSerialization.serialize(this);
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof LDContext)) {
+ return false;
+ }
+ LDContext o = (LDContext)other;
+ if (!Objects.equals(error, o.error)) {
+ return false;
+ }
+ if (error != null) {
+ return true; // there aren't any other attributes
+ }
+ if (!kind.equals(o.kind)) {
+ return false;
+ }
+ if (isMultiple()) {
+ if (multiContexts.length != o.multiContexts.length) {
+ return false;
+ }
+ for (int i = 0; i < multiContexts.length; i++) {
+ if (!multiContexts[i].equals(o.multiContexts[i])) {
+ return false;
+ }
+ }
+ return true;
+ }
+ if (!key.equals(o.key) || !Objects.equals(name, o.name) || anonymous != o.anonymous) {
+ return false;
+ }
+ if ((attributes == null ? 0 : attributes.size()) !=
+ (o.attributes == null ? 0 : o.attributes.size())) {
+ return false;
+ }
+ if (attributes != null) {
+ for (Map.Entry
- * The only mandatory property is the {@code key}, which must uniquely identify each user; this could be a username
- * or email address for authenticated users, or a session ID for anonymous users. All other built-in properties are
- * optional. You may also define custom properties with arbitrary names and values.
+ * {@link LDUser} contains any user-specific properties that may be used in feature flag
+ * configurations to produce different flag variations for different users. You may define
+ * these properties however you wish.
*
- * For a fuller description of user attributes and how they can be referenced in feature flag rules, see the reference
- * guides on Setting user attributes
- * and Targeting users.
+ * LDUser supports only a subset of the behaviors that are available with the newer
+ * {@link LDContext} type. An LDUser is equivalent to an individual {@link LDContext} that has
+ * a {@link ContextKind} of {@link ContextKind#DEFAULT} ("user"); it also has more constraints
+ * on attribute values than LDContext does (for instance, built-in attributes such as
+ * {@link LDUser.Builder#email(String)} can only have string values). Older LaunchDarkly SDKs
+ * only had the LDUser model, and the LDUser type has been retained for backward compatibility,
+ * but it may be removed in a future SDK version; also, the SDK will always convert an LDUser
+ * to an LDContext internally, which has some overhead. Therefore, developers are recommended
+ * to migrate toward using LDContext.
*
- * LaunchDarkly defines a standard JSON encoding for user objects, used by the JavaScript SDK and also in analytics
- * events. {@link LDUser} can be converted to and from JSON in any of these ways:
+ * The only mandatory property of LDUser is the {@code key}, which must uniquely identify each
+ * user. For authenticated users, this may be a username or e-mail address. For anonymous
+ * users, this could be an IP address or session ID.
+ *
+ * Besides the mandatory key, LDUser supports two kinds of optional attributes: built-in
+ * attributes (e.g. {@link LDUser.Builder#name(String)} and {@link LDUser.Builder#country(String)})
+ * and custom attributes. The built-in attributes have specific allowed value types; also, two
+ * of them ({@code name} and {@code anonymous}) have special meanings in LaunchDarkly. Custom
+ * attributes have flexible value types, and can have any names that do not conflict with
+ * built-in attributes.
+ *
+ * Both built-in attributes and custom attributes can be referenced in targeting rules, and
+ * are included in analytics data.
+ *
+ * Instances of LDUser are immutable once created. They can be created with the constructor,
+ * or using a builder pattern with {@link LDUser.Builder}.
+ *
+ * LaunchDarkly defines a standard JSON encoding for user objects, used by the JavaScript SDK
+ * and also in analytics events. {@link LDUser} can be converted to and from JSON in any of
+ * these ways:
*
- * This can be used to represent complex data in a user custom attribute (see {@link LDUser.Builder#custom(String, LDValue)}),
+ * This can be used to represent complex data in a context attribute (see {@link ContextBuilder#set(String, LDValue)}),
* or to get a feature flag value that uses a complex type or that does not always use the same
* type (see the client's {@code jsonValueVariation} methods).
*
diff --git a/src/main/java/com/launchdarkly/sdk/UserAttribute.java b/src/main/java/com/launchdarkly/sdk/UserAttribute.java
index f8fb8ec..e02060f 100644
--- a/src/main/java/com/launchdarkly/sdk/UserAttribute.java
+++ b/src/main/java/com/launchdarkly/sdk/UserAttribute.java
@@ -13,9 +13,11 @@
/**
* Represents a built-in or custom attribute name supported by {@link LDUser}.
*
- * This abstraction helps to distinguish attribute names from other {@link String} values, and also
- * improves efficiency in feature flag data structures and evaluations because built-in attributes
- * always reuse the same instances.
+ * Application code rarely needs to use this type; it is used internally by the SDK for
+ * efficiency in flag evaluations. It can also be used as a reference for the constant
+ * names of built-in attributes such as {@link #EMAIL}. However, in the newer
+ * {@link LDContext} model, there are very few reserved attribute names, so the
+ * equivalent of {@link #EMAIL} would simply be a custom attribute called "email".
*
* For a fuller description of user attributes and how they can be referenced in feature flag rules, see the reference
* guides on Setting user attributes
@@ -32,15 +34,6 @@ public LDValue apply(LDUser u) {
}
});
- /**
- * Represents the secondary key attribute.
- */
- public static final UserAttribute SECONDARY_KEY = new UserAttribute("secondary", new Function
* This causes Gson to use the correct JSON representation logic (the same that would be used by
* {@link JsonSerialization}) for any types that have the SDK's {@link JsonSerializable} marker
- * interface, such as {@link LDUser} and {@link LDValue}, regardless of whether they are the
+ * interface, such as {@link LDContext} and {@link LDValue}, regardless of whether they are the
* top-level object being serialized or are contained in something else such as a collection. It
* does not affect Gson's behavior for any other classes.
*
diff --git a/src/main/java/com/launchdarkly/sdk/json/LDJackson.java b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java
index cbe1669..4cf1499 100644
--- a/src/main/java/com/launchdarkly/sdk/json/LDJackson.java
+++ b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java
@@ -11,7 +11,7 @@
import com.fasterxml.jackson.databind.Module;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.module.SimpleModule;
-import com.launchdarkly.sdk.LDUser;
+import com.launchdarkly.sdk.LDContext;
import com.launchdarkly.sdk.LDValue;
import java.io.IOException;
@@ -31,7 +31,7 @@
*
* This causes Jackson to use the correct JSON representation logic (the same that would be used by
* {@link JsonSerialization}) for any types that have the SDK's {@link JsonSerializable} marker
- * interface, such as {@link LDUser} and {@link LDValue}, regardless of whether they are the
+ * interface, such as {@link LDContext} and {@link LDValue}, regardless of whether they are the
* top-level object being serialized or are contained in something else such as a collection. It
* does not affect Jackson's behavior for any other classes.
*
diff --git a/src/main/java/com/launchdarkly/sdk/json/SerializationException.java b/src/main/java/com/launchdarkly/sdk/json/SerializationException.java
index 909416d..c922d67 100644
--- a/src/main/java/com/launchdarkly/sdk/json/SerializationException.java
+++ b/src/main/java/com/launchdarkly/sdk/json/SerializationException.java
@@ -16,4 +16,12 @@ public class SerializationException extends Exception {
public SerializationException(Throwable cause) {
super(cause);
}
+
+ /**
+ * Creates an instance.
+ * @param message a description of the error
+ */
+ public SerializationException(String message) {
+ super(message);
+ }
}
diff --git a/src/test/java/com/launchdarkly/sdk/AttributeRefTest.java b/src/test/java/com/launchdarkly/sdk/AttributeRefTest.java
new file mode 100644
index 0000000..b0da156
--- /dev/null
+++ b/src/test/java/com/launchdarkly/sdk/AttributeRefTest.java
@@ -0,0 +1,136 @@
+package com.launchdarkly.sdk;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static java.util.Arrays.asList;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+
+@SuppressWarnings("javadoc")
+public class AttributeRefTest extends BaseTest {
+ @Test
+ public void invalidRef() {
+ testInvalidRef(null, Errors.ATTR_EMPTY);
+ testInvalidRef("", Errors.ATTR_EMPTY);
+ testInvalidRef("/", Errors.ATTR_EMPTY);
+ testInvalidRef("//", Errors.ATTR_EXTRA_SLASH);
+ testInvalidRef("/a//b", Errors.ATTR_EXTRA_SLASH);
+ testInvalidRef("/a/b/", Errors.ATTR_EXTRA_SLASH);
+ testInvalidRef("/a~x", Errors.ATTR_INVALID_ESCAPE);
+ testInvalidRef("/a/b~x", Errors.ATTR_INVALID_ESCAPE);
+ testInvalidRef("/a/b~", Errors.ATTR_INVALID_ESCAPE);
+ }
+
+ private void testInvalidRef(String s, String expectedError) {
+ AttributeRef a = AttributeRef.fromPath(s);
+ assertThat(a.isValid(), is(false));
+ assertThat(a.getError(), equalTo(expectedError));
+ assertThat(a.toString(), equalTo(s == null ? "" : s));
+ assertThat(a.getDepth(), equalTo(0));
+ }
+
+ @Test
+ public void invalidLiteral() {
+ testInvalidLiteral(null, Errors.ATTR_EMPTY);
+ testInvalidLiteral("", Errors.ATTR_EMPTY);
+ }
+
+ private void testInvalidLiteral(String s, String expectedError) {
+ AttributeRef a = AttributeRef.fromLiteral(s);
+ assertThat(a.isValid(), is(false));
+ assertThat(a.getError(), equalTo(expectedError));
+ assertThat(a.toString(), equalTo(s == null ? "" : s));
+ assertThat(a.getDepth(), equalTo(0));
+ }
+
+ @Test
+ public void refWithNoLeadingSlash() {
+ testRefWithNoLeadingSlash("name");
+ testRefWithNoLeadingSlash("name/with/slashes");
+ testRefWithNoLeadingSlash("name~0~1with-what-looks-like-escape-sequences");
+ }
+
+ private void testRefWithNoLeadingSlash(String s) {
+ AttributeRef a = AttributeRef.fromPath(s);
+ assertThat(a.isValid(), is(true));
+ assertThat(a.getError(), nullValue());
+ assertThat(a.toString(), equalTo(s));
+ assertThat(a.getDepth(), equalTo(1));
+ assertThat(a.getComponent(0), equalTo(s));
+ }
+
+ @Test
+ public void refSimpleWithLeadingSlash() {
+ testRefSimpleWithLeadingSlash("/name", "name");
+ testRefSimpleWithLeadingSlash("/0", "0");
+ testRefSimpleWithLeadingSlash("/name~1with~1slashes~0and~0tildes", "name/with/slashes~and~tildes");
+ }
+
+ private void testRefSimpleWithLeadingSlash(String s, String unescaped) {
+ AttributeRef a = AttributeRef.fromPath(s);
+ assertThat(a.isValid(), is(true));
+ assertThat(a.getError(), nullValue());
+ assertThat(a.toString(), equalTo(s));
+ assertThat(a.getDepth(), equalTo(1));
+ assertThat(a.getComponent(0), equalTo(unescaped));
+ }
+
+ @Test
+ public void literal() {
+ testLiteral("name", "name");
+ testLiteral("a/b", "a/b");
+ testLiteral("/a/b~c", "/~1a~1b~0c");
+ testLiteral("/", "/~1");
+ }
+
+ private void testLiteral(String s, String escaped) {
+ AttributeRef a = AttributeRef.fromLiteral(s);
+ assertThat(a.isValid(), is(true));
+ assertThat(a.getError(), nullValue());
+ assertThat(a.toString(), equalTo(escaped));
+ assertThat(a.getDepth(), equalTo(1));
+ assertThat(a.getComponent(0), equalTo(s));
+ }
+
+ @Test
+ public void getComponent() {
+ testGetComponent("", 0, 0, null);
+ testGetComponent("key", 1, 0, "key");
+ testGetComponent("/key", 1, 0, "key");
+ testGetComponent("/a/b", 2, 0, "a");
+ testGetComponent("/a/b", 2, 1, "b");
+ testGetComponent("/a~1b/c", 2, 0, "a/b");
+ testGetComponent("/a~0b/c", 2, 0, "a~b");
+ testGetComponent("/a/10/20/30x", 4, 1, "10");
+ testGetComponent("/a/10/20/30x", 4, 2, "20");
+ testGetComponent("/a/10/20/30x", 4, 3, "30x");
+ testGetComponent("", 0, -1, null);
+ testGetComponent("key", 1, -1, null);
+ testGetComponent("key", 1, 1, null);
+ testGetComponent("/key", 1, -1, null);
+ testGetComponent("/key", 1, 1, null);
+ testGetComponent("/a/b", 2, -1, null);
+ testGetComponent("/a/b", 2, 2, null);
+ }
+
+ private void testGetComponent(String input, int depth, int index, String expectedName) {
+ AttributeRef a = AttributeRef.fromPath(input);
+ assertThat(a.toString(), equalTo(input));
+ assertThat(a.getDepth(), equalTo(depth));
+ assertThat(a.getComponent(index), equalTo(expectedName));
+ }
+
+ @Test
+ public void equality() {
+ List
+ *
+ * LDContext context = LDContext.builder("user-key")
+ * .name("my-name)
+ * .set("country", "us")
+ * .build();
+ *
+ *
+ *
+ * @param name the name attribute (null to unset the attribute)
+ * @return the builder
+ * @see LDContext#getName()
+ */
+ public ContextBuilder name(String name) {
+ this.name = name;
+ return this;
+ }
+
+ /**
+ * Sets whether the context is only intended for flag evaluations and should not be
+ * indexed by LaunchDarkly.
+ *
+ *
+ *
+ *
+ *
+ *
+ * LDContext context = LDContext.multiBuilder()
+ * .add(LDContext.create("my-user-key"))
+ * .add(LDContext.create(ContextKind.of("organization"), "my-org-key"))
+ * .build();
+ *
+ *
+ * @param context the context to add
+ * @return the builder
+ */
+ public ContextMultiBuilder add(LDContext context) {
+ if (context != null) {
+ if (contexts == null) {
+ contexts = new ArrayList<>();
+ }
+ if (context.isMultiple()) {
+ for (LDContext c: context.multiContexts) {
+ contexts.add(c);
+ }
+ } else {
+ contexts.add(context);
+ }
+ }
+ return this;
+ }
+}
diff --git a/src/main/java/com/launchdarkly/sdk/Errors.java b/src/main/java/com/launchdarkly/sdk/Errors.java
new file mode 100644
index 0000000..d8322b5
--- /dev/null
+++ b/src/main/java/com/launchdarkly/sdk/Errors.java
@@ -0,0 +1,19 @@
+package com.launchdarkly.sdk;
+
+abstract class Errors {
+ private Errors() {}
+
+ static final String ATTR_EMPTY = "attribute reference cannot be empty";
+ static final String ATTR_EXTRA_SLASH = "attribute reference contained a double slash or a trailing slash";
+ static final String ATTR_INVALID_ESCAPE =
+ "attribute reference contained an escape character (~) that was not followed by 0 or 1";
+
+ static final String CONTEXT_FROM_NULL_USER = "tried to use a null LDUser reference";
+ static final String CONTEXT_NO_KEY = "context key must not be null or empty";
+ static final String CONTEXT_KIND_CANNOT_BE_EMPTY = "context kind must not be empty in JSON";
+ static final String CONTEXT_KIND_CANNOT_BE_KIND = "\"kind\" is not a valid context kind";
+ static final String CONTEXT_KIND_INVALID_CHARS = "context kind contains disallowed characters";
+ static final String CONTEXT_KIND_MULTI_FOR_SINGLE = "context of kind \"multi\" must be created with NewMulti or NewMultiBuilder";
+ static final String CONTEXT_KIND_MULTI_WITH_NO_KINDS = "multi-kind context must contain at least one kind";
+ static final String CONTEXT_KIND_MULTI_DUPLICATES = "multi-kind context cannot have same kind more than once";
+}
diff --git a/src/main/java/com/launchdarkly/sdk/LDContext.java b/src/main/java/com/launchdarkly/sdk/LDContext.java
new file mode 100644
index 0000000..23af19e
--- /dev/null
+++ b/src/main/java/com/launchdarkly/sdk/LDContext.java
@@ -0,0 +1,939 @@
+package com.launchdarkly.sdk;
+
+import com.google.gson.annotations.JsonAdapter;
+import com.launchdarkly.sdk.json.JsonSerializable;
+import com.launchdarkly.sdk.json.JsonSerialization;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * A collection of attributes that can be referenced in flag evaluations and analytics events.
+ *
+ * LDContext c1 = LDContext.create(ContextKind.of("kind1"), "key1");
+ * LDContext c2 = LDContext.create(ContextKind.of("kind2"), "key2");
+ * LDContext c3 = LDContext.create(ContextKind.of("kind3"), "key3");
+ *
+ * LDContext multi1 = LDContext.multiBuilder().add(c1).add(c2).add(c3).build();
+ *
+ * LDContext c1plus2 = LDContext.multiBuilder().add(c1).add(c2).build();
+ * LDContext multi2 = LDContext.multiBuilder().add(c1plus2).add(c3).build();
+ *
+ *
+ *
+ *
+ * @param contexts a list of contexts
+ * @return an LDContext
+ * @see #multiBuilder()
+ */
+ public static LDContext createMulti(LDContext... contexts) {
+ if (contexts == null || contexts.length == 0) {
+ return failed(Errors.CONTEXT_KIND_MULTI_WITH_NO_KINDS);
+ }
+ if (contexts.length == 1) {
+ return contexts[0]; // just return a single-kind context
+ }
+ for (LDContext c: contexts) {
+ if (c.isMultiple()) {
+ ContextMultiBuilder b = multiBuilder();
+ for (LDContext c1: contexts) {
+ b.add(c1);
+ }
+ return b.build();
+ }
+ }
+ // copy the array because the caller could've passed in an array that they will later mutate
+ LDContext[] copied = Arrays.copyOf(contexts, contexts.length);
+ return createMultiInternal(copied);
+ }
+
+ /**
+ * Converts a user to an equivalent {@link LDContext} instance.
+ *
+ * LDContext c1 = LDContext.create(ContextKind.of("kind1"), "key1");
+ * LDContext c2 = LDContext.create(ContextKind.of("kind2"), "key2");
+ * LDContext c3 = LDContext.create(ContextKind.of("kind3"), "key3");
+ *
+ * LDContext multi1 = LDContext.createMulti(c1, c2, c3);
+ *
+ * LDContext c1plus2 = LDContext.createMulti(c1, c2);
+ * LDContext multi2 = LDContext.createMulti(c1plus2, c3);
+ *
+ *
+ *
*
> testValues = new ArrayList<>();
+ for (String s: new String[] {"", "a", "b", "/a/b", "/a/c", "///"}) {
+ testValues.add(asList(AttributeRef.fromPath(s), AttributeRef.fromPath(s)));
+ }
+ TestHelpers.doEqualityTests(testValues);
+ }
+}
diff --git a/src/test/java/com/launchdarkly/sdk/ContextBuilderTest.java b/src/test/java/com/launchdarkly/sdk/ContextBuilderTest.java
new file mode 100644
index 0000000..d30bff9
--- /dev/null
+++ b/src/test/java/com/launchdarkly/sdk/ContextBuilderTest.java
@@ -0,0 +1,134 @@
+package com.launchdarkly.sdk;
+
+import org.junit.Test;
+
+import java.util.List;
+
+import static com.launchdarkly.sdk.LDContextTest.kind1;
+import static com.launchdarkly.sdk.LDContextTest.kind2;
+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.nullValue;
+
+@SuppressWarnings("javadoc")
+public class ContextBuilderTest {
+ @Test
+ public void setValueTypes() {
+ assertThat(LDContext.builder("key").set("a", true).build().getValue("a"), equalTo(LDValue.of(true)));
+ assertThat(LDContext.builder("key").set("a", 1).build().getValue("a"), equalTo(LDValue.of(1)));
+ assertThat(LDContext.builder("key").set("a", 1.5).build().getValue("a"), equalTo(LDValue.of(1.5)));
+ assertThat(LDContext.builder("key").set("a", "b").build().getValue("a"), equalTo(LDValue.of("b")));
+ }
+
+ @Test
+ public void setValueToNullRemovesAttribute() {
+ assertThat(LDContext.builder("key").set("a", true).set("a", LDValue.ofNull())
+ .build().getCustomAttributeNames(), emptyIterable());
+ assertThat(LDContext.builder("key").set("a", true).set("a", (LDValue)null)
+ .build().getCustomAttributeNames(), emptyIterable());
+ }
+
+ @Test
+ public void setValueCanSetBuiltInPropertiesToValidValueType() {
+ assertThat(LDContext.builder(kind1, "key").set("kind", kind2.toString()).build().getKind(), equalTo(kind2));
+ assertThat(LDContext.builder("key").set("key", "a").build().getKey(), equalTo("a"));
+ assertThat(LDContext.builder("key").name("x").set("name", "a").build().getName(), equalTo("a"));
+ assertThat(LDContext.builder("key").name("x").set("name", LDValue.ofNull()).build().getName(), nullValue());
+ assertThat(LDContext.builder("key").set("anonymous", true).build().isAnonymous(), is(true));
+ assertThat(LDContext.builder("key").anonymous(true).set("anonymous", false).build().isAnonymous(), is(false));
+ }
+
+ @Test
+ public void setValueCannotSetMetaProperties() {
+ LDContext c2 = LDContext.builder("key").set("privateAttributes", "x").build();
+ assertThat(c2.getPrivateAttributeCount(), equalTo(0));
+ assertThat(c2.getValue("privateAttributes"), equalTo(LDValue.of("x")));
+ }
+
+ @Test
+ public void setValueIgnoresInvalidNamesAndInvalidValueTypes() {
+ LDContext c = LDContext.builder("key").set("_meta",
+ LDValue.buildObject().put("privateAttributes", LDValue.arrayOf(LDValue.of("a"))).build()).build();
+ assertThat(c.getPrivateAttributeCount(), equalTo(0));
+ assertThat(c.getValue("_meta"), equalTo(LDValue.ofNull()));
+
+ assertThat(LDContext.builder(kind1, "key").set("kind", LDValue.of(1)).build().getKind(), equalTo(kind1));
+ assertThat(LDContext.builder("key").set("key", 1).build().getKey(), equalTo("key"));
+ assertThat(LDContext.builder("key").name("x").set("name", 1).build().getName(), equalTo("x"));
+ assertThat(LDContext.builder("key").anonymous(true).set("anonymous", LDValue.ofNull()).build().isAnonymous(), is(true));
+ assertThat(LDContext.builder("key").set("", true).build().getCustomAttributeNames(), emptyIterable());
+ assertThat(LDContext.builder("key").set(null, true).build().getCustomAttributeNames(), emptyIterable());
+ }
+
+ @Test
+ public void copyOnWriteAttributes() {
+ ContextBuilder cb = LDContext.builder("key").set("a", 1);
+ LDContext c1 = cb.build();
+
+ cb.set("a", 2).set("b", 3);
+ LDContext c2 = cb.build();
+
+ assertThat(c1.getValue("a"), equalTo(LDValue.of(1)));
+ assertThat(c1.getValue("b"), equalTo(LDValue.ofNull()));
+ assertThat(c2.getValue("a"), equalTo(LDValue.of(2)));
+ assertThat(c2.getValue("b"), equalTo(LDValue.of(3)));
+ }
+
+ @Test
+ public void privateAttributes() {
+ LDContext c1 = LDContext.create("a");
+ assertThat(c1.getPrivateAttributeCount(), equalTo(0));
+ assertThat(c1.getPrivateAttribute(0), nullValue());
+ assertThat(c1.getPrivateAttribute(-1), nullValue());
+
+ LDContext c2 = LDContext.builder("a").privateAttributes("a", "b").build();
+ assertThat(c2.getPrivateAttributeCount(), equalTo(2));
+ assertThat(c2.getPrivateAttribute(0), equalTo(AttributeRef.fromLiteral("a")));
+ assertThat(c2.getPrivateAttribute(1), equalTo(AttributeRef.fromLiteral("b")));
+ assertThat(c2.getPrivateAttribute(2), nullValue());
+ assertThat(c2.getPrivateAttribute(-1), nullValue());
+
+ LDContext c3 = LDContext.builder("a").privateAttributes(AttributeRef.fromPath("/a"),
+ AttributeRef.fromPath("/a/b")).build();
+ assertThat(c3.getPrivateAttributeCount(), equalTo(2));
+ assertThat(c3.getPrivateAttribute(0), equalTo(AttributeRef.fromPath("/a")));
+ assertThat(c3.getPrivateAttribute(1), equalTo(AttributeRef.fromPath("/a/b")));
+ assertThat(c3.getPrivateAttribute(2), nullValue());
+ assertThat(c3.getPrivateAttribute(-1), nullValue());
+
+ // no-op cases
+ assertThat(LDContext.builder("a").privateAttributes((String[])null).build()
+ .getPrivateAttributeCount(), equalTo(0));
+ assertThat(LDContext.builder("a").privateAttributes((AttributeRef[])null).build()
+ .getPrivateAttributeCount(), equalTo(0));
+ }
+
+ @Test
+ public void copyOnWritePrivateAttributes() {
+ ContextBuilder cb = LDContext.builder("key").privateAttributes("a");
+ LDContext c1 = cb.build();
+
+ cb.privateAttributes("b");
+ LDContext c2 = cb.build();
+
+ assertThat(c1.getPrivateAttributeCount(), equalTo(1));
+ assertThat(c2.getPrivateAttributeCount(), equalTo(2));
+ }
+
+ @Test
+ public void builderFromContext() {
+ List
> values = LDContextTest.makeValues();
+ for (List
> testValues = new ArrayList<>();
+ for (ContextKind kind: new ContextKind[] { ContextKind.DEFAULT, ContextKind.of("A"), ContextKind.of("a"), ContextKind.of("b") }) {
+ testValues.add(asList(kind, kind));
+ }
+ TestHelpers.doEqualityTests(testValues);
+ }
+
+ @Test
+ public void testHashCode() {
+ assertThat(ContextKind.of("abc").hashCode(), equalTo("abc".hashCode()));
+ }
+}
diff --git a/src/test/java/com/launchdarkly/sdk/ContextMultiBuilderTest.java b/src/test/java/com/launchdarkly/sdk/ContextMultiBuilderTest.java
new file mode 100644
index 0000000..730d13c
--- /dev/null
+++ b/src/test/java/com/launchdarkly/sdk/ContextMultiBuilderTest.java
@@ -0,0 +1,78 @@
+package com.launchdarkly.sdk;
+
+import org.junit.Test;
+
+import static com.launchdarkly.sdk.LDContextTest.invalidKindThatIsLiterallyKind;
+import static com.launchdarkly.sdk.LDContextTest.kind1;
+import static com.launchdarkly.sdk.LDContextTest.kind2;
+import static com.launchdarkly.sdk.LDContextTest.kind3;
+import static com.launchdarkly.sdk.LDContextTest.shouldBeInvalid;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.sameInstance;
+
+@SuppressWarnings("javadoc")
+public class ContextMultiBuilderTest {
+ @Test
+ public void builderIsEquivalentToConstructor() {
+ LDContext c1 = LDContext.create(kind1, "key1");
+ LDContext c2 = LDContext.create(kind2, "key2");
+
+ assertThat(LDContext.createMulti(c1, c2),
+ equalTo(LDContext.multiBuilder().add(c1).add(c2).build()));
+ }
+
+ @Test
+ public void builderWithOneKindReturnsSingleKindContext() {
+ LDContext c1 = LDContext.create("key");
+ LDContext c2 = LDContext.multiBuilder().add(c1).build();
+ assertThat(c2, sameInstance(c1));
+ }
+
+ @Test
+ public void nestedMultiKindContextIsFlattened() {
+ LDContext c1 = LDContext.create(kind1, "key1");
+ LDContext c2 = LDContext.create(kind2, "key2");
+ LDContext c3 = LDContext.create(kind3, "key3");
+ LDContext c1plus2 = LDContext.multiBuilder().add(c1).add(c2).build();
+
+ assertThat(LDContext.multiBuilder().add(c1plus2).add(c3).build(),
+ equalTo(LDContext.multiBuilder().add(c1).add(c2).add(c3).build()));
+ }
+
+ @Test
+ public void builderValidationErrors() {
+ shouldBeInvalid(
+ LDContext.multiBuilder().build(),
+ Errors.CONTEXT_KIND_MULTI_WITH_NO_KINDS);
+
+ shouldBeInvalid(
+ LDContext.multiBuilder()
+ .add(LDContext.create(kind1, "key1"))
+ .add(LDContext.create(kind1, "key2"))
+ .build(),
+ Errors.CONTEXT_KIND_MULTI_DUPLICATES);
+
+ shouldBeInvalid(
+ LDContext.multiBuilder()
+ .add(LDContext.create(""))
+ .add(LDContext.create(invalidKindThatIsLiterallyKind, "key"))
+ .build(),
+ Errors.CONTEXT_NO_KEY + ", " + Errors.CONTEXT_KIND_CANNOT_BE_KIND);
+ }
+
+ @Test
+ public void modifyingBuilderDoesNotAffectPreviouslyCreatedInstances() {
+ LDContext c1 = LDContext.create(kind1, "key1"),
+ c2 = LDContext.create(kind2, "key2"),
+ c3 = LDContext.create(kind3, "key3");
+
+ ContextMultiBuilder mb = LDContext.multiBuilder();
+ mb.add(c1).add(c2);
+ LDContext mc1 = mb.build();
+ mb.add(c3);
+ LDContext mc2 = mb.build();
+ assertThat(mc1.getIndividualContextCount(), equalTo(2));
+ assertThat(mc2.getIndividualContextCount(), equalTo(3));
+ }
+}
diff --git a/src/test/java/com/launchdarkly/sdk/LDContextTest.java b/src/test/java/com/launchdarkly/sdk/LDContextTest.java
new file mode 100644
index 0000000..f59f2bb
--- /dev/null
+++ b/src/test/java/com/launchdarkly/sdk/LDContextTest.java
@@ -0,0 +1,424 @@
+package com.launchdarkly.sdk;
+
+import com.launchdarkly.sdk.json.JsonSerialization;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static java.util.Arrays.asList;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.emptyIterable;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+import static org.hamcrest.Matchers.sameInstance;
+import static org.junit.Assert.fail;
+
+@SuppressWarnings("javadoc")
+public class LDContextTest {
+ static final ContextKind
+ kind1 = ContextKind.of("kind1"),
+ kind2 = ContextKind.of("kind2"),
+ kind3 = ContextKind.of("kind3"),
+ invalidKindThatIsLiterallyKind = ContextKind.of("kind"),
+ invalidKindWithDisallowedChar = ContextKind.of("ørg");
+
+ @Test
+ public void singleKindConstructors() {
+ LDContext c1 = LDContext.create("x");
+ assertThat(c1.getKind(), equalTo(ContextKind.DEFAULT));
+ assertThat(c1.getKey(), equalTo("x"));
+ assertThat(c1.getName(), nullValue());
+ assertThat(c1.isAnonymous(), is(false));
+ assertThat(c1.getCustomAttributeNames(), emptyIterable());
+
+ LDContext c2 = LDContext.create(kind1, "x");
+ assertThat(c2.getKind(), equalTo(kind1));
+ assertThat(c2.getKey(), equalTo("x"));
+ assertThat(c2.getName(), nullValue());
+ assertThat(c2.isAnonymous(), is(false));
+ assertThat(c2.getCustomAttributeNames(), emptyIterable());
+ }
+
+ @Test
+ public void singleKindBuilderProperties() {
+ assertThat(LDContext.builder(".").kind(kind1).build().getKind(), equalTo(kind1));
+ assertThat(LDContext.builder(".").key("x").build().getKey(), equalTo("x"));
+ assertThat(LDContext.builder(".").name("x").build().getName(), equalTo("x"));
+ assertThat(LDContext.builder(".").name("x").name(null).build().getName(), nullValue());
+ assertThat(LDContext.builder(".").anonymous(true).build().isAnonymous(), is(true));
+ assertThat(LDContext.builder(".").anonymous(true).anonymous(false).build().isAnonymous(), is(false));
+ assertThat(LDContext.builder(".").set("a", "x").build().getValue("a"), equalTo(LDValue.of("x")));
+ }
+
+ @Test
+ public void invalidContexts() {
+ shouldBeInvalid(LDContext.create(null), Errors.CONTEXT_NO_KEY);
+ shouldBeInvalid(LDContext.create(""), Errors.CONTEXT_NO_KEY);
+ shouldBeInvalid(LDContext.create(invalidKindThatIsLiterallyKind, "key"),
+ Errors.CONTEXT_KIND_CANNOT_BE_KIND);
+ shouldBeInvalid(LDContext.create(invalidKindWithDisallowedChar, "key"),
+ Errors.CONTEXT_KIND_INVALID_CHARS);
+
+ shouldBeInvalid(LDContext.create(ContextKind.MULTI, "key"), Errors.CONTEXT_KIND_MULTI_FOR_SINGLE);
+
+ shouldBeInvalid(LDContext.createMulti(), Errors.CONTEXT_KIND_MULTI_WITH_NO_KINDS);
+
+ shouldBeInvalid(
+ LDContext.createMulti(
+ LDContext.create(kind1, "key1"),
+ LDContext.create(kind1, "key2")
+ ),
+ Errors.CONTEXT_KIND_MULTI_DUPLICATES);
+
+ shouldBeInvalid(
+ LDContext.createMulti(
+ LDContext.create(""),
+ LDContext.create(invalidKindThatIsLiterallyKind, "key")
+ ),
+ Errors.CONTEXT_NO_KEY + ", " + Errors.CONTEXT_KIND_CANNOT_BE_KIND);
+ }
+
+ static void shouldBeInvalid(LDContext c, String expectedError) {
+ assertThat(c.isValid(), is(false));
+ assertThat(c.getError(), equalTo(expectedError));
+
+ // we guarantee that key is non-null even for invalid contexts, just to reduce risk of NPEs
+ assertThat(c.getKey(), equalTo(""));
+ }
+
+ @Test
+ public void multiple() {
+ assertThat(LDContext.create("my-key").isMultiple(), is(false));
+ assertThat(LDContext.createMulti(LDContext.create(kind1, "key1"), LDContext.create(kind2, "key2")).isMultiple(),
+ is(true));
+ }
+
+ @Test
+ public void fullyQualifiedKey() {
+ assertThat(LDContext.create("abc").getFullyQualifiedKey(), equalTo("abc"));
+ assertThat(LDContext.create("abc:d").getFullyQualifiedKey(), equalTo("abc:d"));
+ assertThat(LDContext.create(kind1, "key1").getFullyQualifiedKey(), equalTo("kind1:key1"));
+ assertThat(LDContext.create(kind1, "my:key%x/y").getFullyQualifiedKey(), equalTo("kind1:my%3Akey%25x/y"));
+ assertThat(
+ LDContext.createMulti(LDContext.create(kind1, "key1"), LDContext.create(kind2, "key:2")).getFullyQualifiedKey(),
+ equalTo("kind1:key1:kind2:key%3A2"));
+ }
+
+ @Test
+ public void customAttributeNames() {
+ assertThat(LDContext.create("a").getCustomAttributeNames(), emptyIterable());
+
+ assertThat(LDContext.builder("a").name("b").build().getCustomAttributeNames(), emptyIterable());
+
+ assertThat(LDContext.builder("a").set("email", "b").set("happy", true).build().getCustomAttributeNames(),
+ containsInAnyOrder("email", "happy"));
+
+ // meta-attributes and non-optional attributes are not included
+ assertThat(LDContext.builder("a").anonymous(true).privateAttributes("email").build().getCustomAttributeNames(),
+ emptyIterable());
+
+ // none for multi-kind context
+ assertThat(
+ LDContext.createMulti(
+ LDContext.builder(kind1, "key1").set("a", "b").build(),
+ LDContext.builder(kind2, "key2").set("a", "b").build()
+ ).getCustomAttributeNames(),
+ emptyIterable());
+ }
+
+ @Test
+ public void getValue() {
+ LDContext c = LDContext.builder("my-key").kind("org").name("x")
+ .set("my-attr", "y").set("/starts-with-slash", "z").build();
+
+ expectAttributeFoundForName(LDValue.of("org"), c, "kind");
+ expectAttributeFoundForName(LDValue.of("my-key"), c, "key");
+ expectAttributeFoundForName(LDValue.of("x"), c, "name");
+ expectAttributeFoundForName(LDValue.of("y"), c, "my-attr");
+ expectAttributeFoundForName(LDValue.of("z"), c, "/starts-with-slash");
+
+ expectAttributeNotFoundForName(c, "/kind");
+ expectAttributeNotFoundForName(c, "/key");
+ expectAttributeNotFoundForName(c, "/name");
+ expectAttributeNotFoundForName(c, "/my-attr");
+ expectAttributeNotFoundForName(c, "other");
+ expectAttributeNotFoundForName(c, "");
+ expectAttributeNotFoundForName(c, "/");
+
+ LDContext mc = LDContext.createMulti(c, LDContext.create(ContextKind.of("otherkind"), "otherkey"));
+
+ expectAttributeFoundForName(LDValue.of("multi"), mc, "kind");
+
+ expectAttributeNotFoundForName(mc, "/kind");
+ expectAttributeNotFoundForName(mc, "key");
+
+ // does not allow querying of subpath/element
+ LDValue objValue = LDValue.buildObject().put("a", 1).build();
+ LDValue arrayValue = LDValue.arrayOf(LDValue.of(1));
+ LDContext c1 = LDContext.builder("key").set("obj-attr", objValue).set("array-attr", arrayValue).build();
+ expectAttributeFoundForName(objValue, c1, "obj-attr");
+ expectAttributeFoundForName(arrayValue, c1, "array-attr");
+ expectAttributeNotFoundForName(c1, "/obj-attr/a");
+ expectAttributeNotFoundForName(c1, "/array-attr/0");
+ }
+
+ private static void expectAttributeFoundForName(LDValue expectedValue, LDContext c, String name) {
+ LDValue value = c.getValue(name);
+ if (value.isNull()) {
+ fail(String.format("attribute \"%s\" should have been found, but was not", name));
+ }
+ assertThat(value, equalTo(expectedValue));
+ }
+
+ private static void expectAttributeNotFoundForName(LDContext c, String name) {
+ LDValue value = c.getValue(name);
+ if (!value.isNull()) {
+ fail(String.format("attribute \"%s\" should not have been found, but was", name));
+ }
+ }
+
+ @Test
+ public void getValueForRefSpecialTopLevelAttributes() {
+ LDContext multi = LDContext.createMulti(
+ LDContext.create("my-key"), LDContext.create(ContextKind.of("otherkind"), "otherkey"));
+
+ expectAttributeFoundForRef(LDValue.of("org"), LDContext.create(ContextKind.of("org"), "my-key"), "kind");
+ expectAttributeFoundForRef(LDValue.of("multi"), multi, "kind");
+
+ expectAttributeFoundForRef(LDValue.of("my-key"), LDContext.create("my-key"), "key");
+ expectAttributeNotFoundForRef(multi, "key");
+
+ expectAttributeFoundForRef(LDValue.of("my-name"), LDContext.builder("key").name("my-name").build(), "name");
+ expectAttributeNotFoundForRef(LDContext.create("key"), "name");
+ expectAttributeNotFoundForRef(multi, "name");
+
+ expectAttributeFoundForRef(LDValue.of(false), LDContext.create("key"), "anonymous");
+ expectAttributeFoundForRef(LDValue.of(true), LDContext.builder("key").anonymous(true).build(), "anonymous");
+ expectAttributeNotFoundForRef(multi, "anonymous");
+ }
+
+ private static void expectAttributeFoundForRef(LDValue expectedValue, LDContext c, String ref) {
+ LDValue value = c.getValue(AttributeRef.fromPath(ref));
+ if (value.isNull()) {
+ fail(String.format("attribute \"{}\" should have been found, but was not", ref));
+ }
+ assertThat(value, equalTo(expectedValue));
+ }
+
+ private static void expectAttributeNotFoundForRef(LDContext c, String ref) {
+ LDValue value = c.getValue(AttributeRef.fromPath(ref));
+ if (!value.isNull()) {
+ fail(String.format("attribute \"{}\" should not have been found, but was", ref));
+ }
+ }
+
+ @Test
+ public void getValueForRefCannotGetMetaProperties() {
+ expectAttributeNotFoundForRef(LDContext.builder("key").privateAttributes("attr").build(), "privateAttributes");
+ }
+
+ @Test
+ public void getValueForRefCustomAttributeSingleKind() {
+ // simple attribute name
+ expectAttributeFoundForRef(LDValue.of("abc"),
+ LDContext.builder("key").set("my-attr", "abc").build(), "my-attr");
+
+ // simple attribute name not found
+ expectAttributeNotFoundForRef(LDContext.create("key"), "my-attr");
+ expectAttributeNotFoundForRef(LDContext.builder("key").set("other-attr", "abc").build(), "my-attr");
+
+ // property in object
+ expectAttributeFoundForRef(LDValue.of("abc"),
+ LDContext.builder("key").set("my-attr", LDValue.parse("{\"my-prop\":\"abc\"}")).build(),
+ "/my-attr/my-prop");
+
+ // property in object not found
+ expectAttributeNotFoundForRef(
+ LDContext.builder("key").set("my-attr", LDValue.parse("{\"my-prop\":\"abc\"}")).build(),
+ "/my-attr/other-prop");
+
+ // property in nested object
+ expectAttributeFoundForRef(LDValue.of("abc"),
+ LDContext.builder("key").set("my-attr", LDValue.parse("{\"my-prop\":{\"sub-prop\":\"abc\"}}")).build(),
+ "/my-attr/my-prop/sub-prop");
+
+ // property in value that is not an object
+ expectAttributeNotFoundForRef(
+ LDContext.builder("key").set("my-attr", "xyz").build(),
+ "/my-attr/my-prop");
+ }
+
+ @Test
+ public void getValueForInvalidRef() {
+ expectAttributeNotFoundForRef(LDContext.create("key"), "/");
+ }
+
+ @Test
+ public void multiKindContexts() {
+ LDContext c1 = LDContext.create(kind1, "key1");
+ LDContext c2 = LDContext.create(kind2, "key2");
+ LDContext multi = LDContext.createMulti(c1, c2);
+
+ assertThat(c1.getIndividualContextCount(), equalTo(1));
+ assertThat(c1.getIndividualContext(0), sameInstance(c1));
+ assertThat(c1.getIndividualContext(1), nullValue());
+ assertThat(c1.getIndividualContext(-1), nullValue());
+ assertThat(c1.getIndividualContext(kind1), sameInstance(c1));
+ assertThat(c1.getIndividualContext(kind1.toString()), sameInstance(c1));
+ assertThat(c1.getIndividualContext(kind2), nullValue());
+ assertThat(c1.getIndividualContext(kind2.toString()), nullValue());
+
+ assertThat(multi.getIndividualContextCount(), equalTo(2));
+ assertThat(multi.getIndividualContext(0), sameInstance(c1));
+ assertThat(multi.getIndividualContext(1), sameInstance(c2));
+ assertThat(multi.getIndividualContext(2), nullValue());
+ assertThat(multi.getIndividualContext(-1), nullValue());
+ assertThat(multi.getIndividualContext(kind1), sameInstance(c1));
+ assertThat(multi.getIndividualContext(kind1.toString()), sameInstance(c1));
+ assertThat(multi.getIndividualContext(kind2), sameInstance(c2));
+ assertThat(multi.getIndividualContext(kind2.toString()), sameInstance(c2));
+ assertThat(multi.getIndividualContext(kind3), nullValue());
+ assertThat(multi.getIndividualContext(kind3.toString()), nullValue());
+
+ assertThat(LDContext.createMulti(c1), sameInstance(c1));
+
+ LDContext uc1 = LDContext.create("key1");
+ LDContext multi2 = LDContext.createMulti(uc1, c2);
+ assertThat(multi2.getIndividualContext(ContextKind.DEFAULT), sameInstance(uc1));
+ assertThat(multi2.getIndividualContext((ContextKind)null), sameInstance(uc1));
+ assertThat(multi2.getIndividualContext(""), sameInstance(uc1));
+
+ LDContext invalid = LDContext.create("");
+ assertThat(invalid.getIndividualContextCount(), equalTo(0));
+
+ LDContext c3 = LDContext.create(kind3, "key3");
+ LDContext c1plus2 = LDContext.multiBuilder().add(c1).add(c2).build();
+
+ assertThat(LDContext.createMulti(c1plus2, c3), equalTo(LDContext.createMulti(c1, c2, c3)));
+ }
+
+ @Test
+ public void stringRepresentation() {
+ LDContext c = LDContext.create(kind1, "a");
+ assertThat(c.toString(), equalTo(JsonSerialization.serialize(c)));
+
+ assertThat(LDContext.create("").toString(), containsString(Errors.CONTEXT_NO_KEY));
+ }
+
+ @Test
+ public void equality() {
+ List
> values = makeValues();
+ TestHelpers.doEqualityTests(values);
+ }
+
+ static List
> makeValues() {
+ // This awkward pattern of creating every value twice is due to how our current
+ // TestHelpers.doEqualityTests() works. When we are able to migrate to using the
+ // similar method in java-test-helpers, we can use a single lambda for each instead.
+ List
> values = new ArrayList<>();
+
+ values.add(asList(LDContext.create("a"), LDContext.create("a")));
+ values.add(asList(LDContext.create("b"), LDContext.create("b")));
+
+ values.add(asList(LDContext.create(kind1, "a"), LDContext.create(kind1, "a")));
+ values.add(asList(LDContext.create(kind1, "b"), LDContext.create(kind1, "b")));
+
+ values.add(asList(LDContext.builder("a").name("b").build(), LDContext.builder("a").name("b").build()));
+
+ values.add(asList(LDContext.builder("a").anonymous(true).build(), LDContext.builder("a").anonymous(true).build()));
+
+ values.add(asList(LDContext.builder("a").set("b", true).build(), LDContext.builder("a").set("b", true).build()));
+ values.add(asList(LDContext.builder("a").set("b", false).build(), LDContext.builder("a").set("b", false).build()));
+
+ values.add(asList(LDContext.builder("a").set("b", true).set("c", false).build(),
+ LDContext.builder("a").set("c", false).set("b", true).build())); // ordering of attributes doesn't matter
+
+ values.add(asList(LDContext.builder("a").privateAttributes("b").build(),
+ LDContext.builder("a").privateAttributes("b").build()));
+ values.add(asList(LDContext.builder("a").privateAttributes("b", "c").build(),
+ LDContext.builder("a").privateAttributes("c", "b").build())); // ordering of private attributes doesn't matter
+ values.add(asList(LDContext.builder("a").privateAttributes("b", "d").build(),
+ LDContext.builder("a").privateAttributes("b", "d").build()));
+
+ values.add(asList(
+ LDContext.createMulti(LDContext.create(kind1, "a"), LDContext.create(kind2, "b")),
+ LDContext.createMulti(LDContext.create(kind2, "b"), LDContext.create(kind1, "a")) // ordering of kinds doesn't matter
+ ));
+ values.add(asList(
+ LDContext.createMulti(LDContext.create(kind1, "a"), LDContext.create(kind2, "c")),
+ LDContext.createMulti(LDContext.create(kind1, "a"), LDContext.create(kind2, "c"))
+ ));
+ values.add(asList(
+ LDContext.createMulti(LDContext.create(kind1, "a"), LDContext.create(kind2, "b"), LDContext.create(kind3, "c")),
+ LDContext.createMulti(LDContext.create(kind1, "a"), LDContext.create(kind2, "b"), LDContext.create(kind3, "c"))
+ ));
+ values.add(asList(LDContext.create(""), LDContext.create(""))); // invalid context
+ values.add(asList(LDContext.createMulti(), LDContext.createMulti())); // invalid with a different error
+ return values;
+ }
+
+ @Test
+ public void contextFromUser() {
+ LDUser u1 = new LDUser.Builder("key")
+ .ip("127.0.0.1")
+ .firstName("Bob")
+ .lastName("Loblaw")
+ .email("bob@example.com")
+ .privateName("Bob Loblaw")
+ .avatar("image")
+ .country("US")
+ .anonymous(true)
+ .build();
+ LDContext c1 = LDContext.fromUser(u1);
+ assertThat(c1, equalTo(
+ LDContext.builder(u1.getKey())
+ .set("ip", u1.getIp())
+ .set("firstName", u1.getFirstName())
+ .set("lastName", u1.getLastName())
+ .set("email", u1.getEmail())
+ .set("name", u1.getName())
+ .set("avatar", u1.getAvatar())
+ .set("country", u1.getCountry())
+ .privateAttributes("name")
+ .anonymous(true)
+ .build()
+ ));
+
+ // test case where there were no built-in optional attrs, only custom
+ LDUser u2 = new LDUser.Builder("key")
+ .custom("c1", "v1")
+ .privateCustom("c2", "v2")
+ .build();
+ LDContext c2 = LDContext.fromUser(u2);
+ assertThat(c2, equalTo(
+ LDContext.builder(u2.getKey())
+ .set("c1", "v1")
+ .set("c2", "v2")
+ .privateAttributes("c2")
+ .build()
+ ));
+
+ // anonymous user with null key
+ LDUser u3 = new LDUser.Builder((String)null).anonymous(true).build();
+ LDContext c3 = LDContext.fromUser(u3);
+ assertThat(c3.isValid(), is(true));
+ assertThat(c3.getKey(), equalTo(""));
+ assertThat(c3.isAnonymous(), is(true));
+ }
+
+ @Test
+ public void contextFromUserErrors() {
+ LDContext c1 = LDContext.fromUser(null);
+ assertThat(c1.isValid(), is(false));
+ assertThat(c1.getError(), equalTo(Errors.CONTEXT_FROM_NULL_USER));
+
+ LDContext c2 = LDContext.fromUser(new LDUser((String)null));
+ assertThat(c2.isValid(), is(false));
+ assertThat(c2.getError(), equalTo(Errors.CONTEXT_NO_KEY));
+}
+}
diff --git a/src/test/java/com/launchdarkly/sdk/LDUserTest.java b/src/test/java/com/launchdarkly/sdk/LDUserTest.java
index 8b741fa..678977a 100644
--- a/src/test/java/com/launchdarkly/sdk/LDUserTest.java
+++ b/src/test/java/com/launchdarkly/sdk/LDUserTest.java
@@ -25,13 +25,6 @@
@SuppressWarnings("javadoc")
public class LDUserTest extends BaseTest {
private static enum OptionalStringAttributes {
- secondary(
- new Function
> testValues = new ArrayList<>();
testValues.add(asList(new LDUser(key), new LDUser(key)));
testValues.add(asList(new LDUser("key2"), new LDUser("key2")));
+ testValues.add(asList(new LDUser.Builder(key).anonymous(true).build(),
+ new LDUser.Builder(key).anonymous(true).build()));
for (OptionalStringAttributes a: OptionalStringAttributes.values()) {
List