Skip to content

Commit 9173763

Browse files
authored
feat: add LDValueConverter and LDContextEncoder to common (#187)
## Summary Adds two general-purpose, public utilities to the shared `common` module (`com.launchdarkly.sdk`) for converting LaunchDarkly data-model types into plain Java structures: - **`LDValueConverter`** — converts an `LDValue` tree into plain Java values (`String`, `Long`, `Double`, `Boolean`, `List`, `Map`, or `null`). - **`LDContextEncoder`** — encodes an `LDContext` into a plain nested `Map<String, Object>`, using `LDValueConverter` for leaf attribute values. Both are self-contained (no new dependencies) and live next to `LDValue` / `LDContext` so they can be shared across artifacts. This change is **additive only** — no existing types are modified. ## Where this will be used These utilities are being promoted into `common` so they can be shared rather than reimplemented per artifact. They aren't referenced within `common` itself yet — a follow-up change will wire them into the AI SDK (`server-ai`): - **`LDValueConverter`** will replace the AI SDK's internal copy used by its config parser to expose `model.parameters`, `model.custom`, and tool `parameters` / `customParameters` as plain Java maps on the public config surface (without leaking `LDValue`). - **`LDContextEncoder`** will replace the AI SDK's `Interpolator` context-encoding logic that builds the `ldctx` variable exposed to Mustache prompt templates (e.g. `{{ldctx.key}}`, `{{ldctx.name}}`). Because `common` is a separately published artifact, it must be released with these utilities before the AI SDK can depend on them; the follow-up then removes the AI SDK's local copies. Landing this on its own keeps the shared utilities and their tests decoupled from the AI SDK release. The encoder is intentionally general-purpose (not Mustache-specific), so it's reusable anywhere a context needs to be rendered into a generic nested structure. ### `LDValueConverter` ```java public static Object toJavaObject(LDValue value); // LDValue tree -> plain Java value public static Map<String, Object> toMap(LDValue value); // JSON object -> Map, else null public static final int MAX_DEPTH = 100; ``` - Conversion is defensive and never throws on malformed or pathological input. - Numbers decode to `Long` when they are mathematically integral and within the IEEE-754 exact-integer range (`|value| <= 2^53`); otherwise to `Double`. Whole numbers outside `±2^53` return the nearest `Double`. - Nesting depth is capped at `MAX_DEPTH`; values deeper than the cap are dropped (`null`) to bound stack usage on adversarial input. - Object fields use a `LinkedHashMap` to preserve insertion order; returned collections are unmodifiable. ### `LDContextEncoder` ```java public static Map<String, Object> encode(LDContext context); // never null ``` Encodes an `LDContext` into a nested map without round-tripping through JSON serialization: - A `null` or invalid context produces an empty map. - A single-kind context produces `kind`, `key`, `name` (only when non-null), `anonymous` (always present), and one entry per custom attribute. - A multi-kind context produces `{"kind":"multi", "key":<fullyQualifiedKey>, <kindName>:{...}, ...}`, where each per-kind nested map omits `kind` (it is implied by the property key), mirroring LaunchDarkly's standard context JSON shape. ## Test plan - [ ] `common` module build and tests pass - [ ] `LDValueConverterTest` — null/JSON-null handling, integral vs. fractional numbers, the `±2^53` boundary (inside and just outside), `NaN`/`Infinity`, strings/booleans, nested objects and arrays, field-order preservation, `toMap` returns null for non-objects, unmodifiable results, and depth-cap behavior on deeply nested input - [ ] `LDContextEncoderTest` — null/invalid context, single-kind (`kind`/`key`/`anonymous`, name present/omitted, `anonymous` true/false), custom attribute objects/arrays and deeply nested attributes, multi-kind (`kind:multi` + fully-qualified key, per-kind objects, `kind` omission on nested objects, nested `anonymous`/`name`), and custom context kinds <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Additive-only new public APIs in common with no changes to existing behavior; edge cases (depth cap, multi-kind `"key"` collision) are documented and tested. > > **Overview** > Adds two public utilities in `com.launchdarkly.sdk` for turning LaunchDarkly model types into plain Java structures without JSON round-trips. > > **`LDValueConverter`** walks an `LDValue` tree into `String`, `Long`, `Double`, `Boolean`, unmodifiable `List`/`Map`, or `null`, with integral numbers in ±2^53 as `Long`, a depth cap of 100, and defensive handling so conversion does not throw. > > **`LDContextEncoder`** maps an `LDContext` to nested `Map<String, Object>` matching standard context JSON (single-kind vs multi-kind, optional `name`, always `anonymous`, custom attrs via the converter). Null or invalid contexts yield an empty map; multi-kind output sets top-level `key` to the fully qualified key after per-kind entries so it wins over a member kind named `"key"` (documented trade-off in tests). > > Comprehensive unit tests cover both classes; no existing types are modified. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 76872ec. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 29f31fc commit 9173763

4 files changed

Lines changed: 597 additions & 0 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.launchdarkly.sdk;
2+
3+
import java.util.HashMap;
4+
import java.util.Map;
5+
6+
/**
7+
* Encodes an {@link LDContext} into a plain nested {@code Map<String, Object>} structure without
8+
* round-tripping through JSON serialization. Leaf attribute values are converted by
9+
* {@link LDValueConverter}.
10+
* <p>
11+
* Output shape:
12+
* <ul>
13+
* <li>A {@code null} or invalid context produces an empty map.</li>
14+
* <li>A single-kind context produces a map containing {@code kind}, {@code key},
15+
* {@code name} (only when non-null), {@code anonymous} (always present), and one entry for
16+
* each custom attribute.</li>
17+
* <li>A multi-kind context produces
18+
* {@code {"kind":"multi", "key":<fullyQualifiedKey>, <kindName>:{...}, ...}} where each
19+
* per-kind nested map omits {@code kind} (it is implied by the property key).</li>
20+
* </ul>
21+
*/
22+
public final class LDContextEncoder {
23+
24+
private LDContextEncoder() {
25+
}
26+
27+
/**
28+
* Encodes an {@link LDContext} into a plain nested {@code Map<String, Object>}.
29+
*
30+
* @param context the context to encode; may be {@code null}
31+
* @return the encoded map; never {@code null} (an empty map is returned for invalid or null input)
32+
*/
33+
public static Map<String, Object> encode(LDContext context) {
34+
if (context == null || !context.isValid()) {
35+
return new HashMap<>();
36+
}
37+
if (context.isMultiple()) {
38+
Map<String, Object> map = new HashMap<>();
39+
map.put("kind", "multi");
40+
int count = context.getIndividualContextCount();
41+
for (int i = 0; i < count; i++) {
42+
LDContext individual = context.getIndividualContext(i);
43+
if (individual != null) {
44+
// Mirror LaunchDarkly's standard context JSON: per-kind objects nested under a
45+
// multi-kind context omit "kind" because it is already implied by the property key.
46+
map.put(individual.getKind().toString(), encodeSingle(individual, false));
47+
}
48+
}
49+
// Written after the loop so it always wins, even if a member kind is named "key".
50+
map.put("key", context.getFullyQualifiedKey());
51+
return map;
52+
}
53+
return encodeSingle(context, true);
54+
}
55+
56+
private static Map<String, Object> encodeSingle(LDContext context, boolean includeKind) {
57+
Map<String, Object> map = new HashMap<>();
58+
if (includeKind) {
59+
map.put("kind", context.getKind().toString());
60+
}
61+
map.put("key", context.getKey());
62+
if (context.getName() != null) {
63+
map.put("name", context.getName());
64+
}
65+
map.put("anonymous", context.isAnonymous());
66+
// Custom attribute values can be arbitrary JSON; convert each LDValue to a plain Java value
67+
// (depth-capped) so nested objects/arrays are fully traversable.
68+
for (String attribute : context.getCustomAttributeNames()) {
69+
map.put(attribute, LDValueConverter.toJavaObject(context.getValue(attribute)));
70+
}
71+
return map;
72+
}
73+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package com.launchdarkly.sdk;
2+
3+
import java.util.ArrayList;
4+
import java.util.Collections;
5+
import java.util.LinkedHashMap;
6+
import java.util.List;
7+
import java.util.Map;
8+
9+
/**
10+
* Converts an {@link LDValue} tree into a tree of plain Java values
11+
* ({@link String}, {@link Long}, {@link Double}, {@link Boolean}, {@link List}, {@link Map}, or
12+
* {@code null}).
13+
* <p>
14+
* Conversion is defensive: it never throws on malformed or pathological input. Numbers are decoded
15+
* to {@link Long} when they are mathematically integral and within the IEEE-754 exact-integer range
16+
* ({@code |value| <= 2^53}); otherwise they are decoded to {@link Double}. Whole numbers outside
17+
* {@code ±2^53} cannot be represented exactly and are returned as the nearest {@link Double}.
18+
* Conversion depth is capped (see {@link #MAX_DEPTH}); values nested more deeply than the cap are
19+
* dropped (rendered as {@code null}) to bound stack usage on adversarial input.
20+
* <p>
21+
* Object fields are stored in a {@link LinkedHashMap} to preserve insertion order, and all
22+
* returned collections are unmodifiable.
23+
*/
24+
public final class LDValueConverter {
25+
/**
26+
* Maximum nesting depth converted before deeper values are dropped.
27+
*/
28+
public static final int MAX_DEPTH = 100;
29+
30+
/**
31+
* Largest magnitude of a whole number that a {@code double} can represent exactly.
32+
*/
33+
private static final double MAX_EXACT_INTEGER = 9007199254740992.0; // 2^53
34+
35+
private LDValueConverter() {
36+
}
37+
38+
/**
39+
* Converts an {@link LDValue} to a plain Java value.
40+
*
41+
* @param value the value to convert; may be {@code null}
42+
* @return the converted value, or {@code null} if the input is {@code null} or JSON null
43+
*/
44+
public static Object toJavaObject(LDValue value) {
45+
return convert(value, 0);
46+
}
47+
48+
/**
49+
* Converts an {@link LDValue} object to an unmodifiable {@code Map<String, Object>}.
50+
*
51+
* @param value the value to convert
52+
* @return the converted map; {@code null} if {@code value} is not a JSON object
53+
*/
54+
public static Map<String, Object> toMap(LDValue value) {
55+
if (value == null || value.getType() != LDValueType.OBJECT) {
56+
return null;
57+
}
58+
Object converted = convert(value, 0);
59+
if (converted instanceof Map) {
60+
@SuppressWarnings("unchecked")
61+
Map<String, Object> map = (Map<String, Object>) converted;
62+
return map;
63+
}
64+
return null;
65+
}
66+
67+
private static Object convert(LDValue value, int depth) {
68+
if (value == null || value.isNull()) {
69+
return null;
70+
}
71+
if (depth >= MAX_DEPTH) {
72+
return null;
73+
}
74+
75+
LDValueType type = value.getType();
76+
switch (type) {
77+
case BOOLEAN:
78+
return value.booleanValue();
79+
case NUMBER:
80+
return convertNumber(value.doubleValue());
81+
case STRING:
82+
return value.stringValue();
83+
case ARRAY: {
84+
List<Object> list = new ArrayList<>(value.size());
85+
for (LDValue element : value.values()) {
86+
list.add(convert(element, depth + 1));
87+
}
88+
return Collections.unmodifiableList(list);
89+
}
90+
case OBJECT: {
91+
// LinkedHashMap to preserve field order for deterministic output.
92+
Map<String, Object> map = new LinkedHashMap<>();
93+
for (String key : value.keys()) {
94+
map.put(key, convert(value.get(key), depth + 1));
95+
}
96+
return Collections.unmodifiableMap(map);
97+
}
98+
case NULL:
99+
default:
100+
return null;
101+
}
102+
}
103+
104+
private static Object convertNumber(double d) {
105+
if (!Double.isNaN(d) && !Double.isInfinite(d)
106+
&& d == Math.rint(d) && Math.abs(d) <= MAX_EXACT_INTEGER) {
107+
return (long) d;
108+
}
109+
return d;
110+
}
111+
}

0 commit comments

Comments
 (0)