From 71f3f8e0696140b992b32ff422e85f707eca77db Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Tue, 7 Jul 2026 14:59:09 -0500 Subject: [PATCH] feat: add LDValueConverter and LDContextEncoder to common --- .../launchdarkly/sdk/LDContextEncoder.java | 72 ++++++ .../launchdarkly/sdk/LDValueConverter.java | 111 ++++++++++ .../sdk/LDContextEncoderTest.java | 205 ++++++++++++++++++ .../sdk/LDValueConverterTest.java | 174 +++++++++++++++ 4 files changed, 562 insertions(+) create mode 100644 lib/shared/common/src/main/java/com/launchdarkly/sdk/LDContextEncoder.java create mode 100644 lib/shared/common/src/main/java/com/launchdarkly/sdk/LDValueConverter.java create mode 100644 lib/shared/common/src/test/java/com/launchdarkly/sdk/LDContextEncoderTest.java create mode 100644 lib/shared/common/src/test/java/com/launchdarkly/sdk/LDValueConverterTest.java diff --git a/lib/shared/common/src/main/java/com/launchdarkly/sdk/LDContextEncoder.java b/lib/shared/common/src/main/java/com/launchdarkly/sdk/LDContextEncoder.java new file mode 100644 index 00000000..0dc503b7 --- /dev/null +++ b/lib/shared/common/src/main/java/com/launchdarkly/sdk/LDContextEncoder.java @@ -0,0 +1,72 @@ +package com.launchdarkly.sdk; + +import java.util.HashMap; +import java.util.Map; + +/** + * Encodes an {@link LDContext} into a plain nested {@code Map} structure without + * round-tripping through JSON serialization. Leaf attribute values are converted by + * {@link LDValueConverter}. + *

+ * Output shape: + *

+ */ +public final class LDContextEncoder { + + private LDContextEncoder() { + } + + /** + * Encodes an {@link LDContext} into a plain nested {@code Map}. + * + * @param context the context to encode; may be {@code null} + * @return the encoded map; never {@code null} (an empty map is returned for invalid or null input) + */ + public static Map encode(LDContext context) { + if (context == null || !context.isValid()) { + return new HashMap<>(); + } + if (context.isMultiple()) { + Map map = new HashMap<>(); + map.put("kind", "multi"); + map.put("key", context.getFullyQualifiedKey()); + int count = context.getIndividualContextCount(); + for (int i = 0; i < count; i++) { + LDContext individual = context.getIndividualContext(i); + if (individual != null) { + // Mirror LaunchDarkly's standard context JSON: per-kind objects nested under a + // multi-kind context omit "kind" because it is already implied by the property key. + map.put(individual.getKind().toString(), encodeSingle(individual, false)); + } + } + return map; + } + return encodeSingle(context, true); + } + + private static Map encodeSingle(LDContext context, boolean includeKind) { + Map map = new HashMap<>(); + if (includeKind) { + map.put("kind", context.getKind().toString()); + } + map.put("key", context.getKey()); + if (context.getName() != null) { + map.put("name", context.getName()); + } + map.put("anonymous", context.isAnonymous()); + // Custom attribute values can be arbitrary JSON; convert each LDValue to a plain Java value + // (depth-capped) so nested objects/arrays are fully traversable. + for (String attribute : context.getCustomAttributeNames()) { + map.put(attribute, LDValueConverter.toJavaObject(context.getValue(attribute))); + } + return map; + } +} diff --git a/lib/shared/common/src/main/java/com/launchdarkly/sdk/LDValueConverter.java b/lib/shared/common/src/main/java/com/launchdarkly/sdk/LDValueConverter.java new file mode 100644 index 00000000..400fad65 --- /dev/null +++ b/lib/shared/common/src/main/java/com/launchdarkly/sdk/LDValueConverter.java @@ -0,0 +1,111 @@ +package com.launchdarkly.sdk; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Converts an {@link LDValue} tree into a tree of plain Java values + * ({@link String}, {@link Long}, {@link Double}, {@link Boolean}, {@link List}, {@link Map}, or + * {@code null}). + *

+ * Conversion is defensive: it never throws on malformed or pathological input. Numbers are decoded + * to {@link Long} when they are mathematically integral and within the IEEE-754 exact-integer range + * ({@code |value| <= 2^53}); otherwise they are decoded to {@link Double}. Whole numbers outside + * {@code ±2^53} cannot be represented exactly and are returned as the nearest {@link Double}. + * Conversion depth is capped (see {@link #MAX_DEPTH}); values nested more deeply than the cap are + * dropped (rendered as {@code null}) to bound stack usage on adversarial input. + *

+ * Object fields are stored in a {@link LinkedHashMap} to preserve insertion order, and all + * returned collections are unmodifiable. + */ +public final class LDValueConverter { + /** + * Maximum nesting depth converted before deeper values are dropped. + */ + public static final int MAX_DEPTH = 100; + + /** + * Largest magnitude of a whole number that a {@code double} can represent exactly. + */ + private static final double MAX_EXACT_INTEGER = 9007199254740992.0; // 2^53 + + private LDValueConverter() { + } + + /** + * Converts an {@link LDValue} to a plain Java value. + * + * @param value the value to convert; may be {@code null} + * @return the converted value, or {@code null} if the input is {@code null} or JSON null + */ + public static Object toJavaObject(LDValue value) { + return convert(value, 0); + } + + /** + * Converts an {@link LDValue} object to an unmodifiable {@code Map}. + * + * @param value the value to convert + * @return the converted map; {@code null} if {@code value} is not a JSON object + */ + public static Map toMap(LDValue value) { + if (value == null || value.getType() != LDValueType.OBJECT) { + return null; + } + Object converted = convert(value, 0); + if (converted instanceof Map) { + @SuppressWarnings("unchecked") + Map map = (Map) converted; + return map; + } + return null; + } + + private static Object convert(LDValue value, int depth) { + if (value == null || value.isNull()) { + return null; + } + if (depth >= MAX_DEPTH) { + return null; + } + + LDValueType type = value.getType(); + switch (type) { + case BOOLEAN: + return value.booleanValue(); + case NUMBER: + return convertNumber(value.doubleValue()); + case STRING: + return value.stringValue(); + case ARRAY: { + List list = new ArrayList<>(value.size()); + for (LDValue element : value.values()) { + list.add(convert(element, depth + 1)); + } + return Collections.unmodifiableList(list); + } + case OBJECT: { + // LinkedHashMap to preserve field order for deterministic output. + Map map = new LinkedHashMap<>(); + for (String key : value.keys()) { + map.put(key, convert(value.get(key), depth + 1)); + } + return Collections.unmodifiableMap(map); + } + case NULL: + default: + return null; + } + } + + private static Object convertNumber(double d) { + if (!Double.isNaN(d) && !Double.isInfinite(d) + && d == Math.rint(d) && Math.abs(d) <= MAX_EXACT_INTEGER) { + return (long) d; + } + return d; + } +} diff --git a/lib/shared/common/src/test/java/com/launchdarkly/sdk/LDContextEncoderTest.java b/lib/shared/common/src/test/java/com/launchdarkly/sdk/LDContextEncoderTest.java new file mode 100644 index 00000000..af5b9476 --- /dev/null +++ b/lib/shared/common/src/test/java/com/launchdarkly/sdk/LDContextEncoderTest.java @@ -0,0 +1,205 @@ +package com.launchdarkly.sdk; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; + +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +@SuppressWarnings("javadoc") +public class LDContextEncoderTest extends BaseTest { + @Test + public void nullContextReturnsEmptyMap() { + Map result = LDContextEncoder.encode(null); + assertThat(result.isEmpty(), is(true)); + } + + @Test + public void invalidContextReturnsEmptyMap() { + LDContext invalid = LDContext.create(""); + assertThat(invalid.isValid(), is(false)); + assertThat(LDContextEncoder.encode(invalid).isEmpty(), is(true)); + } + + @Test + public void singleKindContextIncludesKindKeyAndAnonymous() { + LDContext context = LDContext.builder("user-key").build(); + Map result = LDContextEncoder.encode(context); + assertThat(result.get("kind"), is((Object) "user")); + assertThat(result.get("key"), is((Object) "user-key")); + assertThat(result.containsKey("anonymous"), is(true)); + assertThat(result.get("anonymous"), is((Object) Boolean.FALSE)); + } + + @Test + public void singleKindContextIncludesNameWhenPresent() { + LDContext context = LDContext.builder("user-key").name("Bob").build(); + Map result = LDContextEncoder.encode(context); + assertThat(result.get("name"), is((Object) "Bob")); + } + + @Test + public void singleKindContextOmitsNameWhenNull() { + LDContext context = LDContext.builder("user-key").build(); + Map result = LDContextEncoder.encode(context); + assertThat(result.containsKey("name"), is(false)); + } + + @Test + public void anonymousAlwaysPresentEvenWhenFalse() { + LDContext context = LDContext.builder("key").anonymous(false).build(); + Map result = LDContextEncoder.encode(context); + assertThat(result.containsKey("anonymous"), is(true)); + assertThat(result.get("anonymous"), is((Object) Boolean.FALSE)); + } + + @Test + public void anonymousTrueIsPresentWhenSet() { + LDContext context = LDContext.builder("key").anonymous(true).build(); + Map result = LDContextEncoder.encode(context); + assertThat(result.get("anonymous"), is((Object) Boolean.TRUE)); + } + + @Test + public void singleKindContextIncludesCustomAttributes() { + LDContext context = LDContext.builder("user-key") + .set("tier", "gold") + .set("score", 42) + .build(); + Map result = LDContextEncoder.encode(context); + assertThat(result.get("tier"), is((Object) "gold")); + assertThat(result.get("score"), is((Object) 42L)); + } + + @Test + public void customAttributeObjectValueIsDecoded() { + LDContext context = LDContext.builder("user-key") + .set("address", LDValue.buildObject().put("city", "Oakland").build()) + .build(); + Map result = LDContextEncoder.encode(context); + @SuppressWarnings("unchecked") + Map address = (Map) result.get("address"); + assertThat(address, is(not(nullValue()))); + assertThat(address.get("city"), is((Object) "Oakland")); + } + + @Test + public void customAttributeArrayValueIsDecoded() { + LDContext context = LDContext.builder("user-key") + .set("tags", LDValue.buildArray().add("a").add("b").build()) + .build(); + Map result = LDContextEncoder.encode(context); + @SuppressWarnings("unchecked") + List tags = (List) result.get("tags"); + assertThat(tags, is(not(nullValue()))); + assertThat(tags.get(0), is((Object) "a")); + assertThat(tags.get(1), is((Object) "b")); + } + + @Test + public void multiKindContextHasKindMultiAndFullyQualifiedKey() { + LDContext multi = LDContext.createMulti( + LDContext.builder("user-key").build(), + LDContext.builder(ContextKind.of("org"), "org-key").build()); + Map result = LDContextEncoder.encode(multi); + assertThat(result.get("kind"), is((Object) "multi")); + assertThat(result.get("key"), is((Object) multi.getFullyQualifiedKey())); + } + + @Test + public void multiKindContextContainsPerKindObjects() { + LDContext multi = LDContext.createMulti( + LDContext.builder("user-key").name("Bob").build(), + LDContext.builder(ContextKind.of("org"), "org-key").set("tier", "gold").build()); + Map result = LDContextEncoder.encode(multi); + + @SuppressWarnings("unchecked") + Map userMap = (Map) result.get("user"); + assertThat(userMap, is(not(nullValue()))); + assertThat(userMap.get("key"), is((Object) "user-key")); + assertThat(userMap.get("name"), is((Object) "Bob")); + + @SuppressWarnings("unchecked") + Map orgMap = (Map) result.get("org"); + assertThat(orgMap, is(not(nullValue()))); + assertThat(orgMap.get("key"), is((Object) "org-key")); + assertThat(orgMap.get("tier"), is((Object) "gold")); + } + + @Test + public void multiKindPerKindObjectsOmitKind() { + LDContext multi = LDContext.createMulti( + LDContext.builder("user-key").build(), + LDContext.builder(ContextKind.of("org"), "org-key").build()); + Map result = LDContextEncoder.encode(multi); + + @SuppressWarnings("unchecked") + Map userMap = (Map) result.get("user"); + assertThat(userMap.containsKey("kind"), is(false)); + + @SuppressWarnings("unchecked") + Map orgMap = (Map) result.get("org"); + assertThat(orgMap.containsKey("kind"), is(false)); + } + + @Test + public void multiKindNestedContextsIncludeAnonymous() { + LDContext multi = LDContext.createMulti( + LDContext.builder("user-key").build(), + LDContext.builder(ContextKind.of("org"), "org-key").anonymous(true).build()); + Map result = LDContextEncoder.encode(multi); + + @SuppressWarnings("unchecked") + Map userMap = (Map) result.get("user"); + assertThat(userMap.containsKey("anonymous"), is(true)); + assertThat(userMap.get("anonymous"), is((Object) Boolean.FALSE)); + + @SuppressWarnings("unchecked") + Map orgMap = (Map) result.get("org"); + assertThat(orgMap.get("anonymous"), is((Object) Boolean.TRUE)); + } + + @Test + public void multiKindNestedContextIncludesNameOnlyWhenPresent() { + LDContext multi = LDContext.createMulti( + LDContext.builder("user-key").name("Alice").build(), + LDContext.builder(ContextKind.of("device"), "device-key").build()); + Map result = LDContextEncoder.encode(multi); + + @SuppressWarnings("unchecked") + Map userMap = (Map) result.get("user"); + assertThat(userMap.get("name"), is((Object) "Alice")); + + @SuppressWarnings("unchecked") + Map deviceMap = (Map) result.get("device"); + assertThat(deviceMap.containsKey("name"), is(false)); + } + + @Test + public void customKindContextIsEncoded() { + LDContext context = LDContext.builder(ContextKind.of("device"), "device-123").build(); + Map result = LDContextEncoder.encode(context); + assertThat(result.get("kind"), is((Object) "device")); + assertThat(result.get("key"), is((Object) "device-123")); + } + + @Test + public void nestedObjectCustomAttributeIsDeeplyTraversable() { + LDContext context = LDContext.builder("user-key") + .set("meta", LDValue.buildObject() + .put("level1", LDValue.buildObject().put("level2", "deep-value").build()) + .build()) + .build(); + Map result = LDContextEncoder.encode(context); + @SuppressWarnings("unchecked") + Map meta = (Map) result.get("meta"); + @SuppressWarnings("unchecked") + Map level1 = (Map) meta.get("level1"); + assertThat(level1.get("level2"), is((Object) "deep-value")); + } +} diff --git a/lib/shared/common/src/test/java/com/launchdarkly/sdk/LDValueConverterTest.java b/lib/shared/common/src/test/java/com/launchdarkly/sdk/LDValueConverterTest.java new file mode 100644 index 00000000..00a47d1c --- /dev/null +++ b/lib/shared/common/src/test/java/com/launchdarkly/sdk/LDValueConverterTest.java @@ -0,0 +1,174 @@ +package com.launchdarkly.sdk; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +@SuppressWarnings("javadoc") +public class LDValueConverterTest extends BaseTest { + @Test + public void nullAndJsonNullConvertToNull() { + assertThat(LDValueConverter.toJavaObject(null), is(nullValue())); + assertThat(LDValueConverter.toJavaObject(LDValue.ofNull()), is(nullValue())); + } + + @Test + public void integralNumberBecomesLong() { + Object converted = LDValueConverter.toJavaObject(LDValue.of(5)); + assertThat(converted, instanceOf(Long.class)); + assertThat(converted, is((Object) 5L)); + } + + @Test + public void fractionalNumberBecomesDouble() { + Object converted = LDValueConverter.toJavaObject(LDValue.of(5.5)); + assertThat(converted, instanceOf(Double.class)); + assertThat(converted, is((Object) 5.5)); + } + + @Test + public void integerBoundaryExactly2Pow53BecomesLong() { + double boundary = 9007199254740992.0; // 2^53 + Object converted = LDValueConverter.toJavaObject(LDValue.of(boundary)); + assertThat(converted, instanceOf(Long.class)); + assertThat(converted, is((Object) (long) boundary)); + } + + @Test + public void integerJustOutsideBoundaryBecomesDouble() { + // 2^53 + 1 cannot be represented exactly as a double, so the double value is 2^53 + 2. + // The key point is that the result is a Double, not a Long. + double beyondBoundary = 9007199254740994.0; // clearly > 2^53 + Object converted = LDValueConverter.toJavaObject(LDValue.of(beyondBoundary)); + assertThat(converted, instanceOf(Double.class)); + } + + @Test + public void nanBecomesDouble() { + Object converted = LDValueConverter.toJavaObject(LDValue.of(Double.NaN)); + assertThat(converted, instanceOf(Double.class)); + } + + @Test + public void infinityBecomesDouble() { + Object posInf = LDValueConverter.toJavaObject(LDValue.of(Double.POSITIVE_INFINITY)); + assertThat(posInf, instanceOf(Double.class)); + Object negInf = LDValueConverter.toJavaObject(LDValue.of(Double.NEGATIVE_INFINITY)); + assertThat(negInf, instanceOf(Double.class)); + } + + @Test + public void stringConvertsDirectly() { + assertThat(LDValueConverter.toJavaObject(LDValue.of("hi")), is((Object) "hi")); + } + + @Test + public void booleanConvertsDirectly() { + assertThat(LDValueConverter.toJavaObject(LDValue.of(true)), is((Object) Boolean.TRUE)); + assertThat(LDValueConverter.toJavaObject(LDValue.of(false)), is((Object) Boolean.FALSE)); + } + + @Test + public void nestedObjectAndArrayConvert() { + LDValue value = LDValue.parse("{\"a\":1,\"b\":[\"x\",2],\"c\":{\"d\":true}}"); + Map map = LDValueConverter.toMap(value); + assertThat(map.get("a"), is((Object) 1L)); + assertThat(((List) map.get("b")).get(0), is((Object) "x")); + assertThat(((List) map.get("b")).get(1), is((Object) 2L)); + assertThat(((Map) map.get("c")).get("d"), is((Object) Boolean.TRUE)); + } + + @Test + public void fieldOrderMatchesInputKeyIteration() { + // LDValueConverter uses LinkedHashMap so output key order equals the order value.keys() iterates. + LDValue value = LDValue.buildObject().put("z", 1).put("a", 2).put("m", 3).build(); + Map map = LDValueConverter.toMap(value); + List expectedKeys = new ArrayList<>(); + for (String key : value.keys()) { + expectedKeys.add(key); + } + List actualKeys = new ArrayList<>(map.keySet()); + assertThat(actualKeys, is(expectedKeys)); + } + + @Test + public void toMapReturnsNullForNonObject() { + assertThat(LDValueConverter.toMap(null), is(nullValue())); + assertThat(LDValueConverter.toMap(LDValue.ofNull()), is(nullValue())); + assertThat(LDValueConverter.toMap(LDValue.of("not-an-object")), is(nullValue())); + assertThat(LDValueConverter.toMap(LDValue.parse("[1,2,3]")), is(nullValue())); + assertThat(LDValueConverter.toMap(LDValue.of(42)), is(nullValue())); + } + + @Test + public void mapResultIsUnmodifiable() { + LDValue value = LDValue.parse("{\"a\":1}"); + Map map = LDValueConverter.toMap(value); + try { + map.put("x", "y"); + throw new AssertionError("Expected UnsupportedOperationException"); + } catch (UnsupportedOperationException e) { + // expected + } + } + + @Test + public void listResultIsUnmodifiable() { + LDValue value = LDValue.parse("[1,2,3]"); + Object result = LDValueConverter.toJavaObject(value); + assertThat(result, instanceOf(List.class)); + @SuppressWarnings("unchecked") + List list = (List) result; + try { + list.add("x"); + throw new AssertionError("Expected UnsupportedOperationException"); + } catch (UnsupportedOperationException e) { + // expected + } + } + + @Test + public void deeplyNestedInputDoesNotOverflowAndIsCapped() { + int depth = LDValueConverter.MAX_DEPTH + 50; + StringBuilder json = new StringBuilder(); + for (int i = 0; i < depth; i++) { + json.append('['); + } + json.append("1"); + for (int i = 0; i < depth; i++) { + json.append(']'); + } + // Should neither throw nor StackOverflow; the top level is still a List. + Object converted = LDValueConverter.toJavaObject(LDValue.parse(json.toString())); + assertThat(converted, instanceOf(List.class)); + // Walk down to MAX_DEPTH (still within cap at MAX_DEPTH-1), verify it holds a list. + Object current = converted; + for (int i = 0; i < LDValueConverter.MAX_DEPTH; i++) { + assertThat(current, instanceOf(List.class)); + current = ((List) current).get(0); + } + // After descending MAX_DEPTH times, the value is dropped and becomes null. + assertThat(current, is(nullValue())); + } + + @Test + public void largeIntegerConvertsToLong() { + Object converted = LDValueConverter.toJavaObject(LDValue.of(100)); + assertThat(converted, instanceOf(Long.class)); + assertThat(converted, is((Object) 100L)); + } + + @Test + public void negativeIntegerBoundaryBecomesLong() { + double boundary = -9007199254740992.0; // -2^53 + Object converted = LDValueConverter.toJavaObject(LDValue.of(boundary)); + assertThat(converted, instanceOf(Long.class)); + } +}