From 71f3f8e0696140b992b32ff422e85f707eca77db Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Tue, 7 Jul 2026 14:59:09 -0500 Subject: [PATCH 1/3] 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)); + } +} From 76872ec6c8ec7b9e54c8b4785c6e73cafcb812b6 Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Wed, 8 Jul 2026 15:16:31 -0500 Subject: [PATCH 2/3] fix: preserve FQK in multi-kind contexts whose member kind is key --- .../launchdarkly/sdk/LDContextEncoder.java | 3 +- .../sdk/LDContextEncoderTest.java | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) 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 index 0dc503b7..26158d23 100644 --- a/lib/shared/common/src/main/java/com/launchdarkly/sdk/LDContextEncoder.java +++ b/lib/shared/common/src/main/java/com/launchdarkly/sdk/LDContextEncoder.java @@ -37,7 +37,6 @@ public static Map encode(LDContext context) { 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); @@ -47,6 +46,8 @@ public static Map encode(LDContext context) { map.put(individual.getKind().toString(), encodeSingle(individual, false)); } } + // Written after the loop so it always wins, even if a member kind is named "key". + map.put("key", context.getFullyQualifiedKey()); return map; } return encodeSingle(context, true); 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 index af5b9476..5b12ec52 100644 --- a/lib/shared/common/src/test/java/com/launchdarkly/sdk/LDContextEncoderTest.java +++ b/lib/shared/common/src/test/java/com/launchdarkly/sdk/LDContextEncoderTest.java @@ -2,6 +2,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.nullValue; @@ -188,6 +189,39 @@ public void customKindContextIsEncoded() { assertThat(result.get("key"), is((Object) "device-123")); } + @Test + public void multiKindWithKeyKindContextPreservesFQK() { + // "key"-kind member is last in the loop — FQK write after loop must still win. + LDContext multi = LDContext.createMulti( + LDContext.builder("user-key").build(), + LDContext.builder(ContextKind.of("key"), "key-key").build()); + Map result = LDContextEncoder.encode(multi); + assertThat(result.get("key"), instanceOf(String.class)); + assertThat(result.get("key"), is((Object) multi.getFullyQualifiedKey())); + } + + @Test + public void multiKindWithKeyKindFirstPreservesFQK() { + // "key"-kind member is first in the loop — ordering must not affect the outcome. + LDContext multi = LDContext.createMulti( + LDContext.builder(ContextKind.of("key"), "key-key").build(), + LDContext.builder("user-key").build()); + Map result = LDContextEncoder.encode(multi); + assertThat(result.get("key"), instanceOf(String.class)); + assertThat(result.get("key"), is((Object) multi.getFullyQualifiedKey())); + } + + @Test + public void multiKindWithKeyKindMemberDataIsOverwrittenByFQK() { + // Documents the known trade-off: the "key"-kind nested context is not accessible in the + // output because its map entry is overwritten by the FQK string. + LDContext multi = LDContext.createMulti( + LDContext.builder("user-key").build(), + LDContext.builder(ContextKind.of("key"), "key-key").name("ShouldNotAppear").build()); + Map result = LDContextEncoder.encode(multi); + assertThat(result.get("key"), instanceOf(String.class)); + } + @Test public void nestedObjectCustomAttributeIsDeeplyTraversable() { LDContext context = LDContext.builder("user-key") From 736ee0488ca9efad5070b648e730db87c92eecde Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Wed, 8 Jul 2026 15:33:04 -0500 Subject: [PATCH 3/3] refactor: use common LDValueConverter and LDContextEncoder in AI SDK --- .../server/ai/internal/AIConfigParser.java | 1 + .../sdk/server/ai/internal/Interpolator.java | 50 +------- .../server/ai/internal/LDValueConverter.java | 116 ------------------ .../ai/internal/LDValueConverterTest.java | 73 ----------- 4 files changed, 3 insertions(+), 237 deletions(-) delete mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/LDValueConverter.java delete mode 100644 lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDValueConverterTest.java diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java index 29be0506..40568454 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server.ai.internal; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueConverter; import com.launchdarkly.sdk.LDValueType; import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.JudgeConfiguration; import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Message; diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/Interpolator.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/Interpolator.java index 9fcee28f..edc81a11 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/Interpolator.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/Interpolator.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server.ai.internal; import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDContextEncoder; import com.launchdarkly.sdk.server.ai.internal.mustache.Mustache; import com.launchdarkly.sdk.server.ai.internal.mustache.Template; @@ -61,7 +62,7 @@ public String interpolate(String template, Map variables, LDCont merged.putAll(variables); } // ldctx is added last so it always wins over any caller-supplied "ldctx" entry. - merged.put("ldctx", contextToMap(context)); + merged.put("ldctx", LDContextEncoder.encode(context)); return render(template, merged); } @@ -83,51 +84,4 @@ private String render(String template, Map variables) { Template compiled = templateCache.computeIfAbsent(template, compiler::compile); return compiled.execute(variables); } - - /** - * Encodes the evaluation context directly into the nested map structure exposed to templates as - * {@code ldctx}, without round-tripping through JSON serialization. A single-kind context becomes - * a map of its attributes; a multi-kind context becomes - * {@code {"kind":"multi", "key":, : {...}}} with one nested map per - * individual context. - */ - private static Map contextToMap(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: the 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(), singleContextToMap(individual, false)); - } - } - return map; - } - return singleContextToMap(context, true); - } - - private static Map singleContextToMap(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 remain addressable from templates. - for (String attribute : context.getCustomAttributeNames()) { - map.put(attribute, LDValueConverter.toJavaObject(context.getValue(attribute))); - } - return map; - } } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/LDValueConverter.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/LDValueConverter.java deleted file mode 100644 index 249b2eed..00000000 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/LDValueConverter.java +++ /dev/null @@ -1,116 +0,0 @@ -package com.launchdarkly.sdk.server.ai.internal; - -import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.LDValueType; - -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}). - *

- * This is used to expose AI Config {@code model.parameters} / {@code model.custom} and tool - * parameters to callers without leaking the {@link LDValue} type onto the public surface. - *

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

- * This class is an internal implementation detail and is not part of the supported API. - */ -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/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDValueConverterTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDValueConverterTest.java deleted file mode 100644 index 17872c97..00000000 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDValueConverterTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.launchdarkly.sdk.server.ai.internal; - -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 com.launchdarkly.sdk.LDValue; - -import java.util.List; -import java.util.Map; - -import org.junit.Test; - -@SuppressWarnings("javadoc") -public class LDValueConverterTest { - @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(100)); - assertThat(converted, instanceOf(Long.class)); - assertThat(converted, is((Object) 100L)); - } - - @Test - public void fractionalNumberBecomesDouble() { - Object converted = LDValueConverter.toJavaObject(LDValue.of(0.75)); - assertThat(converted, instanceOf(Double.class)); - assertThat(converted, is((Object) 0.75)); - } - - @Test - public void stringAndBooleanConvertDirectly() { - assertThat(LDValueConverter.toJavaObject(LDValue.of("hi")), is((Object) "hi")); - assertThat(LDValueConverter.toJavaObject(LDValue.of(true)), is((Object) Boolean.TRUE)); - } - - @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 toMapReturnsNullForNonObject() { - assertThat(LDValueConverter.toMap(LDValue.of("not-an-object")), is(nullValue())); - assertThat(LDValueConverter.toMap(LDValue.parse("[1,2,3]")), is(nullValue())); - } - - @Test - public void deeplyNestedInputDoesNotOverflowAndIsCapped() { - int depth = LDValueConverter.MAX_DEPTH + 50; - StringBuilder json = new StringBuilder(); - for (int i = 0; i < depth; i++) { - json.append('['); - } - 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)); - } -}