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: + *

+ */ +@JsonAdapter(AttributeRefTypeAdapter.class) +public final class AttributeRef implements JsonSerializable, Comparable { + private static final Map COMMON_LITERALS = makeLiteralsMap( + "kind", "key", "name", "anonymous", // built-ins + "email", "firstName", "lastName", "country", "ip", "avatar" // frequently used custom attributes + ); + + private final String error; + private final String rawPath; + private final String singlePathComponent; + private final String[] components; + + private AttributeRef(String rawPath, String singlePathComponent, String[] components) { + this.error = null; + this.rawPath = rawPath == null ? "" : rawPath; + this.singlePathComponent = singlePathComponent; + this.components = components; + } + + private AttributeRef(String error, String rawPath) { + this.error = error; + this.rawPath = rawPath == null ? "" : rawPath; + this.singlePathComponent = null; + this.components = null; + } + + /** + * Creates an AttributeRef from a string. For the supported syntax and examples, see + * comments on the {@link AttributeRef} type. + *

+ * 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 makeLiteralsMap(String... names) { + Map ret = new HashMap<>(); + for (String name: names) { + ret.put(name, new AttributeRef(name, name, null)); + } + return ret; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/AttributeRefTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/AttributeRefTypeAdapter.java new file mode 100644 index 0000000..770d2b9 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/AttributeRefTypeAdapter.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 AttributeRefTypeAdapter extends TypeAdapter { + @Override + public AttributeRef read(JsonReader reader) throws IOException { + return AttributeRef.fromPath(Helpers.readNonNullableString(reader)); + } + + @Override + public void write(JsonWriter writer, AttributeRef a) throws IOException { + writer.value(a.toString()); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/ContextBuilder.java b/src/main/java/com/launchdarkly/sdk/ContextBuilder.java new file mode 100644 index 0000000..548bba3 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/ContextBuilder.java @@ -0,0 +1,396 @@ +package com.launchdarkly.sdk; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A mutable object that uses the builder pattern to specify properties for {@link LDContext}. + *

+ * 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: + *


+ *     LDContext context = LDContext.builder("user-key")
+ *       .name("my-name)
+ *       .set("country", "us")
+ *       .build();
+ * 
+ *

+ * 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 attributes; + private boolean anonymous; + private List privateAttributes; + private boolean copyOnWriteAttributes; + private boolean copyOnWritePrivateAttributes; + private boolean allowEmptyKey; + + ContextBuilder() {} + + ContextBuilder(ContextKind kind, String key) { + this.kind = kind; + this.key = key; + } + + /** + * Creates an {@link LDContext} from the current builder properties. + *

+ * 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: + *

+ * + * @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. + *

+ * 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, JsonSerializable { + /** + * A constant for the default kind of "user". + */ + public static final ContextKind DEFAULT = new ContextKind("user"); + + /** + * A constant for the kind that all multi-kind contexts have. + */ + public static final ContextKind MULTI = new ContextKind("multi"); + + private final String kindName; + + private ContextKind(String kindName) { + this.kindName = kindName; + } + + /** + * Constructor from a string value. + *

+ * 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 { + @Override + public ContextKind read(JsonReader reader) throws IOException { + return ContextKind.of(Helpers.readNonNullableString(reader)); + } + + @Override + public void write(JsonWriter writer, ContextKind k) throws IOException { + writer.value(k.toString()); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/ContextMultiBuilder.java b/src/main/java/com/launchdarkly/sdk/ContextMultiBuilder.java new file mode 100644 index 0000000..fe812d2 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/ContextMultiBuilder.java @@ -0,0 +1,106 @@ +package com.launchdarkly.sdk; + +import java.util.ArrayList; +import java.util.List; + +/** + * A mutable object that uses the builder pattern to specify properties for a multi-kind + * {@link LDContext}. + *

+ * 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: + *


+ *     LDContext context = LDContext.multiBuilder()
+ *       .add(LDContext.create("my-user-key"))
+ *       .add(LDContext.create(ContextKind.of("organization"), "my-org-key"))
+ *       .build();
+ * 
+ *

+ * 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 contexts; + + ContextMultiBuilder() {} + + /** + * Creates an {@link LDContext} from the current builder properties. + *

+ * 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 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 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 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: + *

    + *
  1. With {@link JsonSerialization}. + *
  2. With Gson, if and only if you configure your {@code Gson} instance with + * {@link com.launchdarkly.sdk.json.LDGson}. + *
  3. With Jackson, if and only if you configure your {@code ObjectMapper} instance with + * {@link com.launchdarkly.sdk.json.LDJackson}. + *
+ *

+ * 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 attributes; + final boolean anonymous; + final List privateAttributes; + + private LDContext( + ContextKind kind, + LDContext[] multiContexts, + String key, + String fullyQualifiedKey, + String name, + Map attributes, + boolean anonymous, + List privateAttributes + ) { + this.error = null; + this.kind = kind == null ? ContextKind.DEFAULT : kind; + this.multiContexts = multiContexts; + this.key = key; + this.fullyQualifiedKey = fullyQualifiedKey; + this.name = name; + this.attributes = attributes; + this.anonymous = anonymous; + this.privateAttributes = privateAttributes; + } + + private LDContext(String error) { + this.error = error; + this.kind = null; + this.multiContexts = null; + this.key = ""; + this.fullyQualifiedKey = ""; + this.name = null; + this.attributes = null; + this.anonymous = false; + this.privateAttributes = null; + } + + // Internal factory method for single-kind contexts. + static LDContext createSingle( + ContextKind kind, + String key, + String name, + Map attributes, + boolean anonymous, + List privateAttributes, + boolean allowEmptyKey // allowEmptyKey is true only when deserializing old-style user JSON + ) { + if (kind != null) { + String error = kind.validateAsSingleKind(); + if (error != null) { + return failed(error); + } + } + if (key == null || (key.isEmpty() && !allowEmptyKey)) { + return failed(Errors.CONTEXT_NO_KEY); + } + String fullyQualifiedKey = kind.isDefault() ? key : + (kind.toString() + ":" + escapeKeyForFullyQualifiedKey(key)); + return new LDContext(kind, null, key, fullyQualifiedKey, name, attributes, anonymous, privateAttributes); + } + + // Internal factory method for multi-kind contexts - implements all of the validation logic + // except for validating that there is more than one context. We take ownership of the list + // that is passed in, so it is effectively immutable afterward; ContextMultiBuilder has + // copy-on-write logic to manage that. + static LDContext createMultiInternal(LDContext[] multiContexts) { + List errors = null; + boolean duplicates = false; + for (int i = 0; i < multiContexts.length; i++) { + LDContext c = multiContexts[i]; + if (!c.isValid()) { + if (errors == null) { + errors = new ArrayList(); + } + errors.add(c.getError()); + } else { + for (int j = 0; j < i; j++) { + if (multiContexts[j].getKind().equals(c.getKind())) { + duplicates = true; + break; + } + } + } + } + if (duplicates) { + if (errors == null) { + errors = new ArrayList(); + } + errors.add(Errors.CONTEXT_KIND_MULTI_DUPLICATES); + } + + if (errors != null) { + StringBuilder s = new StringBuilder(); + for (String e: errors) { + if (s.length() != 0) { + s.append(", "); + } + s.append(e); + } + return failed(s.toString()); + } + + Arrays.sort(multiContexts, ByKindComparator.INSTANCE); + StringBuilder fullKey = new StringBuilder(); + for (LDContext c: multiContexts) { + if (fullKey.length() != 0) { + fullKey.append(':'); + } + fullKey.append(c.getKind().toString()).append(':').append(escapeKeyForFullyQualifiedKey(c.getKey())); + } + return new LDContext(ContextKind.MULTI, multiContexts, "", fullKey.toString(), + null, null, false, null); + } + + // Internal factory method for a context in an invalid state. + static LDContext failed(String error) { + return new LDContext(error); + } + + /** + * Creates a single-kind LDContext with a kind of {@link ContextKind#DEFAULT}} and the specified key. + *

+ * 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: + *


+   *     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);
+   * 
+ * + * @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. + *

+ * 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 attributes = null; + for (UserAttribute a: UserAttribute.OPTIONAL_STRING_ATTRIBUTES) { + if (a == UserAttribute.NAME) { + continue; + } + LDValue value = user.getAttribute(a); + if (!value.isNull()) { + if (attributes == null) { + attributes = new HashMap<>(); + } + attributes.put(a.getName(), value); + } + } + if (user.custom != null && !user.custom.isEmpty()) { + if (attributes == null) { + attributes = new HashMap<>(); + } + for (Map.Entry kv: user.custom.entrySet()) { + attributes.put(kv.getKey().getName(), kv.getValue()); + } + } + List privateAttributes = null; + if (user.privateAttributeNames != null && !user.privateAttributeNames.isEmpty()) { + privateAttributes = new ArrayList<>(); + for (UserAttribute pa: user.privateAttributeNames) { + privateAttributes.add(AttributeRef.fromLiteral(pa.getName())); + } + } + return new LDContext( + ContextKind.DEFAULT, + null, + key, + key, + user.getName(), + attributes, + user.isAnonymous(), + privateAttributes + ); + } + + /** + * Creates a {@link ContextBuilder} for building an LDContext, initializing its {@code key} and setting + * {@code kind} to {@link ContextKind#DEFAULT}. + *

+ * 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 getCustomAttributeNames() { + return attributes == null ? Collections.emptyList() : attributes.keySet(); + } + + /** + * Returns the number of context kinds in this context. + *

+ * 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 kv: attributes.entrySet()) { + if (!Objects.equals(o.attributes.get(kv.getKey()), kv.getValue())) { + return false; + } + } + } + if (getPrivateAttributeCount() != o.getPrivateAttributeCount()) { + return false; + } + if (privateAttributes != null) { + for (AttributeRef a: privateAttributes) { + boolean found = false; + for (AttributeRef a1: o.privateAttributes) { + if (a1.equals(a)) { + found = true; + break; + } + } + if (!found) { + return false; + } + } + } + return true; + } + + @Override + public int hashCode() { + // This implementation of hashCode() is inefficient due to the need to create a predictable ordering + // of attribute names. That's necessary just for the sake of aligning with the behavior of equals(), + // which is insensitive to ordering. However, using an LDContext as a map key is not an anticipated + // or recommended use case. + int h = Objects.hash(error, kind, key, name, anonymous); + if (multiContexts != null) { + for (LDContext c: multiContexts) { + h = h * 17 + c.hashCode(); + } + } + if (attributes != null) { + String[] names = attributes.keySet().toArray(new String[attributes.size()]); + Arrays.sort(names); + for (String name: names) { + h = (h * 17 + name.hashCode()) * 17 + attributes.get(name).hashCode(); + } + } + if (privateAttributes != null) { + AttributeRef[] refs = privateAttributes.toArray(new AttributeRef[privateAttributes.size()]); + Arrays.sort(refs);; + for (AttributeRef a: refs) { + h = h * 17 + a.hashCode(); + } + } + return h; + } + + private LDValue getTopLevelAttribute(String attributeName) { + switch (attributeName) { + case "kind": + return LDValue.of(kind.toString()); + case "key": + return multiContexts == null ? LDValue.of(key) : LDValue.ofNull(); + case "name": + return LDValue.of(name); + case "anonymous": + return LDValue.of(anonymous); + default: + if (attributes == null) { + return LDValue.ofNull(); + } + LDValue v = attributes.get(attributeName); + return v == null ? LDValue.ofNull() : v; + } + } + + private static String escapeKeyForFullyQualifiedKey(String key) { + // When building a FullyQualifiedKey, ':' and '%' are percent-escaped; we do not use a full + // URL-encoding function because implementations of this are inconsistent across platforms. + return key.replace("%", "%25").replace(":", "%3A"); + } + + private static class ByKindComparator implements Comparator { + static final ByKindComparator INSTANCE = new ByKindComparator(); + + public int compare(LDContext c1, LDContext c2) { + return c1.getKind().compareTo(c2.getKind()); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/LDContextTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/LDContextTypeAdapter.java new file mode 100644 index 0000000..f0ca4d1 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/LDContextTypeAdapter.java @@ -0,0 +1,197 @@ +package com.launchdarkly.sdk; + +import com.google.gson.JsonIOException; +import com.google.gson.JsonParseException; +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; +import java.util.Map; + +import static com.launchdarkly.sdk.LDContext.ATTR_ANONYMOUS; +import static com.launchdarkly.sdk.LDContext.ATTR_KEY; +import static com.launchdarkly.sdk.LDContext.ATTR_KIND; +import static com.launchdarkly.sdk.LDContext.ATTR_NAME; + +final class LDContextTypeAdapter extends TypeAdapter { + private static final String JSON_PROP_META = "_meta"; + private static final String JSON_PROP_PRIVATE = "privateAttributes"; + private static final String JSON_PROP_OLD_PRIVATE = "privateAttributeNames"; + private static final String JSON_PROP_OLD_CUSTOM = "custom"; + + @Override + public void write(JsonWriter out, LDContext c) throws IOException { + if (!c.isValid()) { + throw new JsonIOException("tried to serialize invalid LDContext: " + c.getError()); + } + if (c.isMultiple()) { + out.beginObject(); + out.name(ATTR_KIND).value(ContextKind.MULTI.toString()); + for (LDContext c1: c.multiContexts) { + out.name(c1.getKind().toString()); + writeSingleKind(out, c1, false); + } + out.endObject(); + } else { + writeSingleKind(out, c, true); + } + } + + private void writeSingleKind(JsonWriter out, LDContext c, boolean includeKind) throws IOException { + out.beginObject(); + if (includeKind) { + out.name(ATTR_KIND).value(c.getKind().toString()); + } + out.name(ATTR_KEY).value(c.getKey()); + if (c.getName() != null) { + out.name(ATTR_NAME).value(c.getName()); + } + if (c.isAnonymous()) { + out.name(ATTR_ANONYMOUS).value(c.isAnonymous()); + } + if (c.attributes != null) { + for (Map.Entry kv: c.attributes.entrySet()) { + out.name(kv.getKey()); + LDValueTypeAdapter.INSTANCE.write(out, kv.getValue()); + } + } + if (c.getPrivateAttributeCount() != 0) { + out.name(JSON_PROP_META).beginObject(); + out.name(JSON_PROP_PRIVATE).beginArray(); + for (AttributeRef a: c.privateAttributes) { + out.value(a.toString()); + } + out.endArray(); + out.endObject(); + } + out.endObject(); + } + + @Override + public LDContext read(JsonReader in) throws IOException { + LDValue obj = requireValueType(LDValueTypeAdapter.INSTANCE.read(in), LDValueType.OBJECT, false, null); + ContextKind kind = null; + for (String key: obj.keys()) { + if (key.equals(ATTR_KIND)) { + kind = ContextKind.of( + requireValueType(obj.get(key), LDValueType.STRING, false, ATTR_KIND).stringValue()); + break; + } + } + LDContext ret; + if (kind == null) { + ret = readOldUser(obj); + } else if (kind.equals(ContextKind.MULTI)) { + ContextMultiBuilder mb = LDContext.multiBuilder(); + for (String key: obj.keys()) { + if (!key.equals(ATTR_KIND)) { + mb.add(readSingleKind(obj.get(key), ContextKind.of(key))); + } + } + ret = mb.build(); + } else { + ret = readSingleKind(obj, null); + } + if (!ret.isValid()) { + throw new JsonParseException("invalid LDContext: " + ret.getError()); + } + return ret; + } + + private static LDValue requireValueType(LDValue v, LDValueType t, boolean nullable, String propName) throws JsonParseException { + if (v.getType() != t && !(nullable && v.isNull())) { + throw new JsonParseException("expected " + t + ", found " + v.getType() + + (propName == null ? "" : (" for " + propName))); + } + return v; + } + + private static LDContext readOldUser(LDValue obj) throws JsonParseException { + requireValueType(obj, LDValueType.OBJECT, false, null); + ContextBuilder cb = LDContext.builder(null); + cb.setAllowEmptyKey(true); + for (String key: obj.keys()) { + LDValue v = obj.get(key); + switch (key) { + case ATTR_KEY: + cb.key(requireValueType(v, LDValueType.STRING, false, key).stringValue()); + break; + case ATTR_NAME: + cb.name(requireValueType(v, LDValueType.STRING, true, key).stringValue()); + break; + case ATTR_ANONYMOUS: + cb.anonymous(requireValueType(v, LDValueType.BOOLEAN, true, key).booleanValue()); + break; + case JSON_PROP_OLD_PRIVATE: + LDValue privateAttrs = requireValueType(v, LDValueType.ARRAY, true, JSON_PROP_OLD_PRIVATE); + for (LDValue privateAttr: privateAttrs.values()) { + cb.privateAttributes(AttributeRef.fromLiteral( + requireValueType(privateAttr, LDValueType.STRING, false, JSON_PROP_PRIVATE).stringValue())); + } + break; + case JSON_PROP_OLD_CUSTOM: + for (String customKey: requireValueType(v, LDValueType.OBJECT, true, JSON_PROP_OLD_CUSTOM).keys()) { + cb.set(customKey, v.get(customKey)); + } + break; + case "firstName": + case "lastName": + case "email": + case "country": + case "ip": + case "avatar": + cb.set(key, requireValueType(v, LDValueType.STRING, true, key)); + break; + default: + break; + } + } + return cb.build(); + } + + private static LDContext readSingleKind(LDValue obj, ContextKind kind) throws JsonParseException { + requireValueType(obj, LDValueType.OBJECT, false, kind == null ? null : kind.toString()); + ContextBuilder cb = LDContext.builder("").kind(kind); + boolean hasNonEmptyKind = kind != null; + for (String key: obj.keys()) { + LDValue v = obj.get(key); + switch (key) { + case ATTR_KIND: + String s = requireValueType(v, LDValueType.STRING, false, key).stringValue(); + if (!s.isEmpty()) { + // We need this extra check because the builder, when used programmatically, treats an + // unset/emty kind the same as ContextKind.DEFAULT-- but that's not the behavior we + // want for JSON. + hasNonEmptyKind = true; + cb.kind(s); + } + break; + case ATTR_KEY: + cb.key(requireValueType(v, LDValueType.STRING, false, key).stringValue()); + break; + case ATTR_NAME: + cb.name(requireValueType(v, LDValueType.STRING, true, key).stringValue()); + break; + case ATTR_ANONYMOUS: + cb.anonymous(requireValueType(v, LDValueType.BOOLEAN, true, key).booleanValue()); + break; + case JSON_PROP_META: + LDValue meta = requireValueType(v, LDValueType.OBJECT, true, key); + LDValue privateAttrs = requireValueType(meta.get(JSON_PROP_PRIVATE), + LDValueType.ARRAY, true, JSON_PROP_PRIVATE); + for (LDValue privateAttr: privateAttrs.values()) { + cb.privateAttributes(AttributeRef.fromPath( + requireValueType(privateAttr, LDValueType.STRING, false, JSON_PROP_PRIVATE).stringValue())); + } + break; + default: + cb.set(key, v); + } + } + if (!hasNonEmptyKind) { + return LDContext.failed(Errors.CONTEXT_KIND_CANNOT_BE_EMPTY); + } + return cb.build(); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/LDUser.java b/src/main/java/com/launchdarkly/sdk/LDUser.java index 234f12b..c6dbaa5 100644 --- a/src/main/java/com/launchdarkly/sdk/LDUser.java +++ b/src/main/java/com/launchdarkly/sdk/LDUser.java @@ -16,18 +16,42 @@ import static java.util.Collections.unmodifiableSet; /** - * A collection of attributes that can affect flag evaluation, usually corresponding to a user of your application. + * Attributes of a user for whom you are evaluating feature flags. *

- * 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: *

    *
  1. With {@link JsonSerialization}. *
  2. With Gson, if and only if you configure your {@code Gson} instance with @@ -41,14 +65,13 @@ public class LDUser implements JsonSerializable { // Note that these fields are all stored internally as LDValue rather than String so that // we don't waste time repeatedly converting them to LDValue in the rule evaluation logic. final LDValue key; - final LDValue secondary; final LDValue ip; final LDValue email; final LDValue name; final LDValue avatar; final LDValue firstName; final LDValue lastName; - final LDValue anonymous; + final boolean anonymous; final LDValue country; final Map custom; Set privateAttributeNames; @@ -57,13 +80,12 @@ protected LDUser(Builder builder) { this.key = LDValue.of(builder.key); this.ip = LDValue.of(builder.ip); this.country = LDValue.of(builder.country); - this.secondary = LDValue.of(builder.secondary); this.firstName = LDValue.of(builder.firstName); this.lastName = LDValue.of(builder.lastName); this.email = LDValue.of(builder.email); this.name = LDValue.of(builder.name); this.avatar = LDValue.of(builder.avatar); - this.anonymous = builder.anonymous == null ? LDValue.ofNull() : LDValue.of(builder.anonymous); + this.anonymous = builder.anonymous; this.custom = builder.custom == null ? null : unmodifiableMap(builder.custom); this.privateAttributeNames = builder.privateAttributes == null ? null : unmodifiableSet(builder.privateAttributes); } @@ -75,8 +97,9 @@ protected LDUser(Builder builder) { */ public LDUser(String key) { this.key = LDValue.of(key); - this.secondary = this.ip = this.email = this.name = this.avatar = this.firstName = this.lastName = this.anonymous = this.country = + this.ip = this.email = this.name = this.avatar = this.firstName = this.lastName = this.country = LDValue.ofNull(); + this.anonymous = false; this.custom = null; this.privateAttributeNames = null; } @@ -90,15 +113,6 @@ public String getKey() { return key.stringValue(); } - /** - * Returns the value of the secondary key property for the user, if set. - * - * @return a string or null - */ - public String getSecondary() { - return secondary.stringValue(); - } - /** * Returns the value of the IP property for the user, if set. * @@ -168,7 +182,7 @@ public String getAvatar() { * @return true for an anonymous user */ public boolean isAnonymous() { - return anonymous.booleanValue(); + return anonymous; } /** @@ -227,15 +241,14 @@ public boolean equals(Object o) { if (o instanceof LDUser) { LDUser ldUser = (LDUser) o; return Objects.equals(key, ldUser.key) && - Objects.equals(secondary, ldUser.secondary) && Objects.equals(ip, ldUser.ip) && Objects.equals(email, ldUser.email) && Objects.equals(name, ldUser.name) && Objects.equals(avatar, ldUser.avatar) && Objects.equals(firstName, ldUser.firstName) && Objects.equals(lastName, ldUser.lastName) && - Objects.equals(anonymous, ldUser.anonymous) && Objects.equals(country, ldUser.country) && + anonymous == ldUser.anonymous && Objects.equals(custom, ldUser.custom) && Objects.equals(privateAttributeNames, ldUser.privateAttributeNames); } @@ -244,7 +257,7 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(key, secondary, ip, email, name, avatar, firstName, lastName, anonymous, country, custom, privateAttributeNames); + return Objects.hash(key, ip, email, name, avatar, firstName, lastName, anonymous, country, custom, privateAttributeNames); } @Override @@ -264,15 +277,14 @@ public String toString() { */ public static class Builder { private String key; - private String secondary; private String ip; private String firstName; private String lastName; private String email; private String name; private String avatar; - private Boolean anonymous; private String country; + private boolean anonymous = false; private Map custom; private Set privateAttributes; @@ -292,14 +304,13 @@ public Builder(String key) { */ public Builder(LDUser user) { this.key = user.key.stringValue(); - this.secondary = user.secondary.stringValue(); this.ip = user.ip.stringValue(); this.firstName = user.firstName.stringValue(); this.lastName = user.lastName.stringValue(); this.email = user.email.stringValue(); this.name = user.name.stringValue(); this.avatar = user.avatar.stringValue(); - this.anonymous = user.anonymous.isNull() ? null : user.anonymous.booleanValue(); + this.anonymous = user.anonymous; this.country = user.country.stringValue(); this.custom = user.custom == null ? null : new HashMap<>(user.custom); this.privateAttributes = user.privateAttributeNames == null ? null : new HashSet<>(user.privateAttributeNames); @@ -338,30 +349,6 @@ public Builder privateIp(String s) { return ip(s); } - /** - * Sets the secondary key for a user. This affects - * feature flag targeting - * as follows: if you have chosen to bucket users by a specific attribute, the secondary key (if set) - * is used to further distinguish between users who are otherwise identical according to that attribute. - * @param s the secondary key for the user - * @return the builder - */ - public Builder secondary(String s) { - this.secondary = s; - return this; - } - - /** - * Sets the secondary key for a user, and ensures that the secondary key attribute is not sent back to - * LaunchDarkly. - * @param s the secondary key for the user - * @return the builder - */ - public Builder privateSecondary(String s) { - addPrivate(UserAttribute.SECONDARY_KEY); - return secondary(s); - } - /** * Set the country for a user. Before version 5.0.0, this field was validated and normalized by the SDK * as an ISO-3166-1 country code before assignment. This behavior has been removed so that the SDK can diff --git a/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java index 9528781..c560762 100644 --- a/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java +++ b/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java @@ -22,9 +22,6 @@ public LDUser read(JsonReader reader) throws IOException { case "key": builder.key(readNullableString(reader)); break; - case "secondary": - builder.secondary(readNullableString(reader)); - break; case "ip": builder.ip(readNullableString(reader)); break; @@ -100,6 +97,9 @@ public void write(JsonWriter writer, LDUser user) throws IOException { writer.beginObject(); for (UserAttribute attr: UserAttribute.BUILTINS.values()) { + if (attr == UserAttribute.ANONYMOUS && !user.isAnonymous()) { + continue; // anonymous: false value doesn't need to be serialized + } LDValue value = user.getAttribute(attr); if (!value.isNull()) { writer.name(attr.getName()); diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java index bf36572..3784ecc 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValue.java +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -1,6 +1,5 @@ package com.launchdarkly.sdk; -import com.google.gson.Gson; import com.google.gson.annotations.JsonAdapter; import com.google.gson.stream.JsonWriter; import com.launchdarkly.sdk.json.JsonSerializable; @@ -23,7 +22,7 @@ * values (a JSON array), or a map of strings to {@link LDValue} values (a JSON object). It is easily * convertible to standard Java types. *

    - * 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() { - public LDValue apply(LDUser u) { - return u.secondary; - } - }); - /** * Represents the IP address attribute. */ @@ -109,7 +102,7 @@ public LDValue apply(LDUser u) { */ public static final UserAttribute ANONYMOUS = new UserAttribute("anonymous", new Function() { public LDValue apply(LDUser u) { - return u.anonymous; + return LDValue.of(u.anonymous); } }); @@ -117,10 +110,12 @@ public LDValue apply(LDUser u) { static final Map BUILTINS; static { BUILTINS = new HashMap<>(); - for (UserAttribute a: new UserAttribute[] { KEY, SECONDARY_KEY, IP, EMAIL, NAME, AVATAR, FIRST_NAME, LAST_NAME, COUNTRY, ANONYMOUS }) { + for (UserAttribute a: new UserAttribute[] { KEY, IP, EMAIL, NAME, AVATAR, FIRST_NAME, LAST_NAME, COUNTRY, ANONYMOUS }) { BUILTINS.put(a.getName(), a); } } + static final UserAttribute[] OPTIONAL_STRING_ATTRIBUTES = + new UserAttribute[] { IP, EMAIL, NAME, AVATAR, FIRST_NAME, LAST_NAME, COUNTRY }; private final String name; final Function builtInGetter; diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java index 034717c..7d6df2c 100644 --- a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java @@ -2,8 +2,11 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.launchdarkly.sdk.AttributeRef; +import com.launchdarkly.sdk.ContextKind; import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; @@ -83,6 +86,11 @@ public static T deserialize(String json, Class o // We use this internally in situations where generic type checking isn't desirable static T deserializeInternal(String json, Class objectClass) throws SerializationException { + if (json == null || json.isEmpty()) { + // Annoyingly, Gson tolerates a totally empty input string and considers it equivalent to null, + // but that isn't a valid JSON document. + throw new SerializationException("input string was null/empty"); + } try { return gson.fromJson(json, objectClass); } catch (Exception e) { @@ -143,8 +151,11 @@ static Iterable> getDeserializableClasses() { // default case where it *doesn't* exist. This functionality is tested in the Java SDK. synchronized (knownDeserializableClasses) { if (knownDeserializableClasses.isEmpty()) { + knownDeserializableClasses.add(AttributeRef.class); + knownDeserializableClasses.add(ContextKind.class); knownDeserializableClasses.add(EvaluationReason.class); knownDeserializableClasses.add(EvaluationDetail.class); + knownDeserializableClasses.add(LDContext.class); knownDeserializableClasses.add(LDUser.class); knownDeserializableClasses.add(LDValue.class); knownDeserializableClasses.add(UserAttribute.class); diff --git a/src/main/java/com/launchdarkly/sdk/json/LDGson.java b/src/main/java/com/launchdarkly/sdk/json/LDGson.java index 4212e75..dd59ec2 100644 --- a/src/main/java/com/launchdarkly/sdk/json/LDGson.java +++ b/src/main/java/com/launchdarkly/sdk/json/LDGson.java @@ -11,7 +11,7 @@ import com.google.gson.reflect.TypeToken; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; -import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; import java.io.IOException; @@ -45,7 +45,7 @@ *

    * 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> 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 l: values) { + LDContext c1 = l.get(0); + if (c1.isMultiple() || !c1.isValid()) { + continue; + } + LDContext c2 = LDContext.builderFromContext(c1).build(); + if (!c2.equals(c1)) { + assertThat(c2, equalTo(c1)); + } + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/ContextKindTest.java b/src/test/java/com/launchdarkly/sdk/ContextKindTest.java new file mode 100644 index 0000000..eeebfb0 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/ContextKindTest.java @@ -0,0 +1,53 @@ +package com.launchdarkly.sdk; + +import org.hamcrest.Matchers; +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; + +@SuppressWarnings("javadoc") +public class ContextKindTest { + @Test + public void nonEmptyValue() { + assertThat(ContextKind.of("abc").toString(), equalTo("abc")); + } + + @Test + public void nullOrEmptyBecomesDefault() { + assertThat(ContextKind.of(null).toString(), equalTo("user")); + assertThat(ContextKind.of("").toString(), equalTo("user")); + } + + @Test + public void predefinedValuesAreInterned() { + assertThat(ContextKind.of("user"), Matchers.sameInstance(ContextKind.DEFAULT)); + assertThat(ContextKind.of("multi"), Matchers.sameInstance(ContextKind.MULTI)); + } + + @Test + public void isDefault() { + assertThat(ContextKind.of("abc").isDefault(), is(false)); + assertThat(ContextKind.of("user").isDefault(), is(true)); + assertThat(ContextKind.DEFAULT.isDefault(), is(true)); + } + + @Test + public void equality() { + 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() { public String apply(LDUser u) { return u.getSecondary(); } }, - new BiFunction() - { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.secondary(s); } }, - new BiFunction() - { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateSecondary(s); } }), - ip( new Function() { public String apply(LDUser u) { return u.getIp(); } }, new BiFunction() @@ -108,7 +101,6 @@ public void simpleConstructorSetsKey() { assertEquals(a.toString(), LDValue.ofNull(), user.getAttribute(a.attribute)); } assertThat(user.isAnonymous(), is(false)); - assertThat(user.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.ofNull())); assertThat(user.getAttribute(UserAttribute.forName("custom-attr")), equalTo(LDValue.ofNull())); assertThat(user.getCustomAttributes(), emptyIterable()); assertThat(user.getPrivateAttributes(), emptyIterable()); @@ -131,7 +123,6 @@ public void builderSetsOptionalStringAttribute() { } } assertThat(user.isAnonymous(), is(false)); - assertThat(user.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.ofNull())); assertThat(user.getAttribute(UserAttribute.forName("custom-attr")), equalTo(LDValue.ofNull())); assertThat(user.getCustomAttributes(), emptyIterable()); assertThat(user.getPrivateAttributes(), emptyIterable()); @@ -156,7 +147,6 @@ public void builderSetsPrivateOptionalStringAttribute() { } } assertThat(user.isAnonymous(), is(false)); - assertThat(user.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.ofNull())); assertThat(user.getAttribute(UserAttribute.forName("custom-attr")), equalTo(LDValue.ofNull())); assertThat(user.getCustomAttributes(), emptyIterable()); assertThat(user.getPrivateAttributes(), contains(a.attribute)); @@ -238,7 +228,6 @@ public void builderSetsPrivateCustomAttributes() { @Test public void canCopyUserWithBuilder() { LDUser user = new LDUser.Builder("key") - .secondary("secondary") .ip("127.0.0.1") .firstName("Bob") .lastName("Loblaw") @@ -283,6 +272,8 @@ public void equalValuesAreEqual() { List> 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 equalValues = new ArrayList<>(); for (int i = 0; i < 2; i++) { @@ -299,13 +290,6 @@ public void equalValuesAreEqual() { } testValues.add(equalValuesPrivate); } - for (boolean anonValue: new boolean[] { true, false }) { - List equalValues = new ArrayList<>(); - for (int i = 0; i < 2; i++) { - equalValues.add(new LDUser.Builder(key).anonymous(anonValue).build()); - } - testValues.add(equalValues); - } for (String attrName: new String[] { "custom1", "custom2" }) { LDValue[] values = new LDValue[] { LDValue.of(true), LDValue.of(false) }; for (LDValue attrValue: values) { @@ -334,4 +318,4 @@ public void simpleStringRepresentation() { LDUser user = new LDUser.Builder("userkey").name("x").build(); assertEquals("LDUser(" + JsonSerialization.serialize(user) + ")", user.toString()); } -} +} \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/sdk/TestHelpers.java b/src/test/java/com/launchdarkly/sdk/TestHelpers.java index 7389cc8..80d09c5 100644 --- a/src/test/java/com/launchdarkly/sdk/TestHelpers.java +++ b/src/test/java/com/launchdarkly/sdk/TestHelpers.java @@ -14,7 +14,7 @@ public class TestHelpers { public static Iterable builtInAttributes() { return UserAttribute.BUILTINS.values(); } - + public static List listFromIterable(Iterable it) { List list = new ArrayList<>(); for (T t: it) { diff --git a/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java index 9de50b0..24932f4 100644 --- a/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java +++ b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java @@ -19,12 +19,6 @@ public void keyAttribute() { assertTrue(UserAttribute.KEY.isBuiltIn()); } - @Test - public void secondaryKeyAttribute() { - assertEquals("secondary", UserAttribute.SECONDARY_KEY.getName()); - assertTrue(UserAttribute.SECONDARY_KEY.isBuiltIn()); - } - @Test public void ipAttribute() { assertEquals("ip", UserAttribute.IP.getName()); diff --git a/src/test/java/com/launchdarkly/sdk/json/AttributeRefJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/AttributeRefJsonSerializationTest.java new file mode 100644 index 0000000..6ddc093 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/AttributeRefJsonSerializationTest.java @@ -0,0 +1,39 @@ +package com.launchdarkly.sdk.json; + +import com.launchdarkly.sdk.AttributeRef; +import com.launchdarkly.sdk.BaseTest; + +import org.junit.Test; + +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserialize; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserializeInvalidJson; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; + +@SuppressWarnings("javadoc") +public class AttributeRefJsonSerializationTest extends BaseTest { + @Test + public void serialization() throws Exception { + testSerialization("a", "\"a\""); + testSerialization("/a/b", "\"/a/b\""); + testSerialization("///invalid", "\"///invalid\""); + } + + private void testSerialization(String attrPath, String expected) throws Exception { + verifySerialize(AttributeRef.fromPath(attrPath), expected); + } + + @Test + public void deserialization() throws Exception { + testDeserialization("\"a\"", "a"); + testDeserialization("\"/a/b\"", "/a/b"); + testDeserialization("\"///invalid\"", "///invalid"); + + verifyDeserializeInvalidJson(AttributeRef.class, "2"); + verifyDeserializeInvalidJson(AttributeRef.class, "[]"); + verifyDeserializeInvalidJson(AttributeRef.class, "{}"); + } + + private void testDeserialization(String json, String attrPath) throws Exception { + verifyDeserialize(AttributeRef.fromPath(attrPath), json); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/json/ContextKindJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/ContextKindJsonSerializationTest.java new file mode 100644 index 0000000..866ef62 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/ContextKindJsonSerializationTest.java @@ -0,0 +1,26 @@ +package com.launchdarkly.sdk.json; + +import com.launchdarkly.sdk.BaseTest; +import com.launchdarkly.sdk.ContextKind; + +import org.junit.Test; + +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserializeInvalidJson; + +@SuppressWarnings("javadoc") +public class ContextKindJsonSerializationTest extends BaseTest { + @Test + public void serializationAndDeserialization() throws Exception { + verifySerializeAndDeserialize(ContextKind.DEFAULT, "\"user\""); + verifySerializeAndDeserialize(ContextKind.of("org"), "\"org\""); + } + + @Test + public void deserializeInvalid() throws Exception { + verifyDeserializeInvalidJson(ContextKind.class, "true"); + verifyDeserializeInvalidJson(ContextKind.class, "3"); + verifyDeserializeInvalidJson(ContextKind.class, "{}"); + verifyDeserializeInvalidJson(ContextKind.class, "[]"); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java index 5c60e1d..4512f0c 100644 --- a/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java @@ -42,10 +42,12 @@ public void reasonJsonSerializations() throws Exception { verifySerializeAndDeserialize(EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND), "{\"kind\":\"ERROR\",\"errorKind\":\"FLAG_NOT_FOUND\"}"); - // properties with defaults can be included + // properties with defaults can be included, explicit default values are ignored in parsing verifyDeserialize(EvaluationReason.fallthrough(false), "{\"kind\":\"FALLTHROUGH\",\"inExperiment\":false}"); verifyDeserialize(EvaluationReason.ruleMatch(1, "id", false), "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\",\"inExperiment\":false}"); + verifyDeserialize(EvaluationReason.ruleMatch(1, null, false), + "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":null}"); // unknown properties are ignored JsonTestHelpers.verifyDeserialize(EvaluationReason.off(), "{\"kind\":\"OFF\",\"other\":true}"); @@ -54,6 +56,7 @@ public void reasonJsonSerializations() throws Exception { verifyDeserializeInvalidJson(EvaluationReason.class, "{}"); // must have "kind" verifyDeserializeInvalidJson(EvaluationReason.class, "{\"kind\":3}"); verifyDeserializeInvalidJson(EvaluationReason.class, "{\"kind\":\"other\"}"); + verifyDeserializeInvalidJson(EvaluationReason.class, "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":3}"); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java new file mode 100644 index 0000000..ce9cfc0 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java @@ -0,0 +1,149 @@ +package com.launchdarkly.sdk.json; + +import com.google.gson.JsonIOException; +import com.launchdarkly.sdk.ContextKind; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; + +import org.junit.Test; + +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserialize; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +@SuppressWarnings("javadoc") +public class LDContextJsonSerializationTest { + private static final ContextKind + kind1 = ContextKind.of("kind1"), + kind2 = ContextKind.of("kind2"); + + private static final LDValue[] ALL_TYPE_VALUES = new LDValue[] { + LDValue.of(true), LDValue.of(1), LDValue.of(1.5), LDValue.of("c"), + LDValue.arrayOf(), LDValue.buildObject().build() + }; + + @Test + public void minimalJsonEncoding() throws Exception { + LDContext context = LDContext.create("userkey"); + verifySerializeAndDeserialize(context, "{\"kind\":\"user\",\"key\":\"userkey\"}"); + + verifySerialize((LDContext)null, "null"); + } + + @Test + public void singleKindContexts() throws Exception { + verifySerializeAndDeserialize( + LDContext.create("a"), + "{\"kind\":\"user\",\"key\":\"a\"}"); + + verifySerializeAndDeserialize( + LDContext.create(kind1, "a"), + "{\"kind\":\"kind1\",\"key\":\"a\"}"); + + verifySerializeAndDeserialize( + LDContext.builder("a").name("b").build(), + "{\"kind\":\"user\",\"key\":\"a\",\"name\":\"b\"}"); + + verifySerializeAndDeserialize( + LDContext.builder("a").anonymous(true).build(), + "{\"kind\":\"user\",\"key\":\"a\",\"anonymous\":true}"); + + for (LDValue customValue: ALL_TYPE_VALUES) { + verifySerializeAndDeserialize( + LDContext.builder("a").set("b", customValue).build(), + "{\"kind\":\"user\",\"key\":\"a\",\"b\":" + customValue.toJsonString() + "}"); + } + + verifySerializeAndDeserialize( + LDContext.builder("a").privateAttributes("b").build(), + "{\"kind\":\"user\",\"key\":\"a\",\"_meta\":{\"privateAttributes\":[\"b\"]}}"); + + verifySerializeAndDeserialize( + LDContext.builder("a").privateAttributes("/b/c").build(), + "{\"kind\":\"user\",\"key\":\"a\",\"_meta\":{\"privateAttributes\":[\"/b/c\"]}}"); + } + + @Test + public void multiKindContext() throws Exception { + verifySerializeAndDeserialize( + LDContext.createMulti(LDContext.create(kind1, "a"), LDContext.create(kind2, "b")), + "{\"kind\":\"multi\",\"kind1\":{\"key\":\"a\"},\"kind2\":{\"key\":\"b\"}}"); + } + + @Test + public void convertOldUser() throws Exception { + verifyDeserialize(LDContext.create("a"), "{\"key\":\"a\"}"); + + verifyDeserialize(LDContext.builder("a").name("b").build(), + "{\"key\":\"a\",\"name\":\"b\"}"); + + verifyDeserialize(LDContext.builder("a").anonymous(true).build(), + "{\"key\":\"a\",\"anonymous\":true}"); + + verifyDeserialize(LDContext.builder("a").build(), + "{\"key\":\"a\",\"anonymous\":false}"); + + verifyDeserialize(LDContext.builder("a").build(), + "{\"key\":\"a\",\"anonymous\":null}"); + + for (String builtInName: new String[] { "firstName", "lastName", "email", "country", "ip", "avatar" }) { + verifyDeserialize(LDContext.builder("a").set(builtInName, "b").build(), + "{\"key\":\"a\",\"" + builtInName + "\":\"b\"}"); + } + + for (LDValue customValue: ALL_TYPE_VALUES) { + verifyDeserialize( + LDContext.builder("a").set("b", customValue).build(), + "{\"key\":\"a\",\"custom\":{\"b\":" + customValue.toJsonString() + "}}"); + } + + verifyDeserialize(LDContext.builder("a").privateAttributes("b").build(), + "{\"key\":\"a\",\"privateAttributeNames\":[\"b\"]}"); + + // For old user JSON only, an empty key is allowed; an LDContext can't be constructed in this state. + LDContext contextWithEmptyKey = JsonSerialization.deserialize("{\"key\":\"\"}", LDContext.class); + assertTrue(contextWithEmptyKey.isValid()); + assertEquals("", contextWithEmptyKey.getKey()); + } + + @Test(expected=JsonIOException.class) + public void serializeInvalidContext() throws Exception { + JsonSerialization.serialize(LDContext.create("")); + } + + @Test + public void deserializeContextWithValidationError() throws Exception { + for (String json: new String[] { + "{\"kind\":\"\",\"key\":\"a\"}", + "{\"kind\":\"a\",\"key\":\"\"}", + "{\"kind\":\"kind\",\"key\":\"a\"}", + "{\"kind\":\"ørg\",\"key\":\"a\"}", + "{\"kind\":\"a\",\"key\":\"b\",\"name\":3}", + "{\"kind\":\"a\",\"key\":\"b\",\"anonymous\":\"x\"}", + "{\"kind\":\"a\",\"key\":\"b\",\"_meta\":\"x\"}", + + // invalid old-style user JSON + "{\"key\":null}", + "{\"key\":\"a\",\"name\":3}", + "{\"key\":\"a\",\"anonymous\":\"x\"}", + "{\"key\":\"a\",\"custom\":\"x\"}", + "{\"key\":\"a\",\"privateAttributeNames\":3}", + + "{}", + "" + }) { + try { + JsonSerialization.deserialize(json, LDContext.class); + fail("expected deserialization to fail, but it passed, for JSON: " + json); + } catch (SerializationException e) {} + } + } + + @Test(expected=SerializationException.class) + public void deserializeContextWithTypeError() throws Exception { + JsonSerialization.deserialize("{\"kind\":\"a\",\"key\":3}", LDContext.class); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java b/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java index 5f03611..ce9bf99 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java @@ -8,7 +8,7 @@ import com.google.gson.stream.JsonToken; import com.google.gson.stream.JsonWriter; import com.launchdarkly.sdk.EvaluationReason; -import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; import org.junit.Test; @@ -61,20 +61,20 @@ public void valueMapToJsonElementMap() { @Test public void complexObjectToJsonTree() { - LDUser user = new LDUser.Builder("userkey").name("name") - .custom("attr1", LDValue.ofNull()) - .custom("attr2", LDValue.of(true)) - .custom("attr3", LDValue.of(false)) - .custom("attr4", LDValue.of(0)) - .custom("attr5", LDValue.of(1)) - .custom("attr6", LDValue.of("")) - .custom("attr7", LDValue.of("x")) - .custom("attr8", JsonTestHelpers.nestedArrayValue()) - .custom("attr9", JsonTestHelpers.nestedObjectValue()) + LDContext context = LDContext.builder("key").name("name") + .set("attr1", LDValue.ofNull()) + .set("attr2", LDValue.of(true)) + .set("attr3", LDValue.of(false)) + .set("attr4", LDValue.of(0)) + .set("attr5", LDValue.of(1)) + .set("attr6", LDValue.of("")) + .set("attr7", LDValue.of("x")) + .set("attr8", JsonTestHelpers.nestedArrayValue()) + .set("attr9", JsonTestHelpers.nestedObjectValue()) .build(); - JsonElement j = JsonTestHelpers.configureGson().toJsonTree(user); + JsonElement j = JsonTestHelpers.configureGson().toJsonTree(context); String js = JsonTestHelpers.gson.toJson(j); - assertEquals(LDValue.parse(JsonSerialization.serialize(user)), LDValue.parse(js)); + assertEquals(LDValue.parse(JsonSerialization.serialize(context)), LDValue.parse(js)); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java index 95370da..09c0c27 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java @@ -29,7 +29,6 @@ public void minimalJsonEncoding() throws Exception { @Test public void defaultJsonEncodingWithoutPrivateAttributes() throws Exception { LDUser user = new LDUser.Builder("userkey") - .secondary("s") .ip("i") .email("e") .name("n") @@ -43,7 +42,6 @@ public void defaultJsonEncodingWithoutPrivateAttributes() throws Exception { .build(); LDValue expectedJson = LDValue.buildObject() .put("key", "userkey") - .put("secondary", "s") .put("ip", "i") .put("email", "e") .put("name", "n") diff --git a/src/test/java/com/launchdarkly/sdk/json/UserAttributeJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/UserAttributeJsonSerializationTest.java index 6ac6443..0420514 100644 --- a/src/test/java/com/launchdarkly/sdk/json/UserAttributeJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/UserAttributeJsonSerializationTest.java @@ -14,7 +14,7 @@ public class UserAttributeJsonSerializationTest extends BaseTest { public void userAttributeJsonSerializations() throws Exception { verifySerializeAndDeserialize(UserAttribute.NAME, "\"name\""); verifySerializeAndDeserialize(UserAttribute.forName("custom-attr"), "\"custom-attr\""); - + verifyDeserializeInvalidJson(UserAttribute.class, "3"); } }