Skip to content

Commit 71f3f8e

Browse files
committed
feat: add LDValueConverter and LDContextEncoder to common
1 parent 29f31fc commit 71f3f8e

4 files changed

Lines changed: 562 additions & 0 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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+
map.put("key", context.getFullyQualifiedKey());
41+
int count = context.getIndividualContextCount();
42+
for (int i = 0; i < count; i++) {
43+
LDContext individual = context.getIndividualContext(i);
44+
if (individual != null) {
45+
// Mirror LaunchDarkly's standard context JSON: per-kind objects nested under a
46+
// multi-kind context omit "kind" because it is already implied by the property key.
47+
map.put(individual.getKind().toString(), encodeSingle(individual, false));
48+
}
49+
}
50+
return map;
51+
}
52+
return encodeSingle(context, true);
53+
}
54+
55+
private static Map<String, Object> encodeSingle(LDContext context, boolean includeKind) {
56+
Map<String, Object> map = new HashMap<>();
57+
if (includeKind) {
58+
map.put("kind", context.getKind().toString());
59+
}
60+
map.put("key", context.getKey());
61+
if (context.getName() != null) {
62+
map.put("name", context.getName());
63+
}
64+
map.put("anonymous", context.isAnonymous());
65+
// Custom attribute values can be arbitrary JSON; convert each LDValue to a plain Java value
66+
// (depth-capped) so nested objects/arrays are fully traversable.
67+
for (String attribute : context.getCustomAttributeNames()) {
68+
map.put(attribute, LDValueConverter.toJavaObject(context.getValue(attribute)));
69+
}
70+
return map;
71+
}
72+
}
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+
}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
package com.launchdarkly.sdk;
2+
3+
import static org.hamcrest.MatcherAssert.assertThat;
4+
import static org.hamcrest.Matchers.hasEntry;
5+
import static org.hamcrest.Matchers.is;
6+
import static org.hamcrest.Matchers.not;
7+
import static org.hamcrest.Matchers.nullValue;
8+
9+
import java.util.List;
10+
import java.util.Map;
11+
12+
import org.junit.Test;
13+
14+
@SuppressWarnings("javadoc")
15+
public class LDContextEncoderTest extends BaseTest {
16+
@Test
17+
public void nullContextReturnsEmptyMap() {
18+
Map<String, Object> result = LDContextEncoder.encode(null);
19+
assertThat(result.isEmpty(), is(true));
20+
}
21+
22+
@Test
23+
public void invalidContextReturnsEmptyMap() {
24+
LDContext invalid = LDContext.create("");
25+
assertThat(invalid.isValid(), is(false));
26+
assertThat(LDContextEncoder.encode(invalid).isEmpty(), is(true));
27+
}
28+
29+
@Test
30+
public void singleKindContextIncludesKindKeyAndAnonymous() {
31+
LDContext context = LDContext.builder("user-key").build();
32+
Map<String, Object> result = LDContextEncoder.encode(context);
33+
assertThat(result.get("kind"), is((Object) "user"));
34+
assertThat(result.get("key"), is((Object) "user-key"));
35+
assertThat(result.containsKey("anonymous"), is(true));
36+
assertThat(result.get("anonymous"), is((Object) Boolean.FALSE));
37+
}
38+
39+
@Test
40+
public void singleKindContextIncludesNameWhenPresent() {
41+
LDContext context = LDContext.builder("user-key").name("Bob").build();
42+
Map<String, Object> result = LDContextEncoder.encode(context);
43+
assertThat(result.get("name"), is((Object) "Bob"));
44+
}
45+
46+
@Test
47+
public void singleKindContextOmitsNameWhenNull() {
48+
LDContext context = LDContext.builder("user-key").build();
49+
Map<String, Object> result = LDContextEncoder.encode(context);
50+
assertThat(result.containsKey("name"), is(false));
51+
}
52+
53+
@Test
54+
public void anonymousAlwaysPresentEvenWhenFalse() {
55+
LDContext context = LDContext.builder("key").anonymous(false).build();
56+
Map<String, Object> result = LDContextEncoder.encode(context);
57+
assertThat(result.containsKey("anonymous"), is(true));
58+
assertThat(result.get("anonymous"), is((Object) Boolean.FALSE));
59+
}
60+
61+
@Test
62+
public void anonymousTrueIsPresentWhenSet() {
63+
LDContext context = LDContext.builder("key").anonymous(true).build();
64+
Map<String, Object> result = LDContextEncoder.encode(context);
65+
assertThat(result.get("anonymous"), is((Object) Boolean.TRUE));
66+
}
67+
68+
@Test
69+
public void singleKindContextIncludesCustomAttributes() {
70+
LDContext context = LDContext.builder("user-key")
71+
.set("tier", "gold")
72+
.set("score", 42)
73+
.build();
74+
Map<String, Object> result = LDContextEncoder.encode(context);
75+
assertThat(result.get("tier"), is((Object) "gold"));
76+
assertThat(result.get("score"), is((Object) 42L));
77+
}
78+
79+
@Test
80+
public void customAttributeObjectValueIsDecoded() {
81+
LDContext context = LDContext.builder("user-key")
82+
.set("address", LDValue.buildObject().put("city", "Oakland").build())
83+
.build();
84+
Map<String, Object> result = LDContextEncoder.encode(context);
85+
@SuppressWarnings("unchecked")
86+
Map<String, Object> address = (Map<String, Object>) result.get("address");
87+
assertThat(address, is(not(nullValue())));
88+
assertThat(address.get("city"), is((Object) "Oakland"));
89+
}
90+
91+
@Test
92+
public void customAttributeArrayValueIsDecoded() {
93+
LDContext context = LDContext.builder("user-key")
94+
.set("tags", LDValue.buildArray().add("a").add("b").build())
95+
.build();
96+
Map<String, Object> result = LDContextEncoder.encode(context);
97+
@SuppressWarnings("unchecked")
98+
List<Object> tags = (List<Object>) result.get("tags");
99+
assertThat(tags, is(not(nullValue())));
100+
assertThat(tags.get(0), is((Object) "a"));
101+
assertThat(tags.get(1), is((Object) "b"));
102+
}
103+
104+
@Test
105+
public void multiKindContextHasKindMultiAndFullyQualifiedKey() {
106+
LDContext multi = LDContext.createMulti(
107+
LDContext.builder("user-key").build(),
108+
LDContext.builder(ContextKind.of("org"), "org-key").build());
109+
Map<String, Object> result = LDContextEncoder.encode(multi);
110+
assertThat(result.get("kind"), is((Object) "multi"));
111+
assertThat(result.get("key"), is((Object) multi.getFullyQualifiedKey()));
112+
}
113+
114+
@Test
115+
public void multiKindContextContainsPerKindObjects() {
116+
LDContext multi = LDContext.createMulti(
117+
LDContext.builder("user-key").name("Bob").build(),
118+
LDContext.builder(ContextKind.of("org"), "org-key").set("tier", "gold").build());
119+
Map<String, Object> result = LDContextEncoder.encode(multi);
120+
121+
@SuppressWarnings("unchecked")
122+
Map<String, Object> userMap = (Map<String, Object>) result.get("user");
123+
assertThat(userMap, is(not(nullValue())));
124+
assertThat(userMap.get("key"), is((Object) "user-key"));
125+
assertThat(userMap.get("name"), is((Object) "Bob"));
126+
127+
@SuppressWarnings("unchecked")
128+
Map<String, Object> orgMap = (Map<String, Object>) result.get("org");
129+
assertThat(orgMap, is(not(nullValue())));
130+
assertThat(orgMap.get("key"), is((Object) "org-key"));
131+
assertThat(orgMap.get("tier"), is((Object) "gold"));
132+
}
133+
134+
@Test
135+
public void multiKindPerKindObjectsOmitKind() {
136+
LDContext multi = LDContext.createMulti(
137+
LDContext.builder("user-key").build(),
138+
LDContext.builder(ContextKind.of("org"), "org-key").build());
139+
Map<String, Object> result = LDContextEncoder.encode(multi);
140+
141+
@SuppressWarnings("unchecked")
142+
Map<String, Object> userMap = (Map<String, Object>) result.get("user");
143+
assertThat(userMap.containsKey("kind"), is(false));
144+
145+
@SuppressWarnings("unchecked")
146+
Map<String, Object> orgMap = (Map<String, Object>) result.get("org");
147+
assertThat(orgMap.containsKey("kind"), is(false));
148+
}
149+
150+
@Test
151+
public void multiKindNestedContextsIncludeAnonymous() {
152+
LDContext multi = LDContext.createMulti(
153+
LDContext.builder("user-key").build(),
154+
LDContext.builder(ContextKind.of("org"), "org-key").anonymous(true).build());
155+
Map<String, Object> result = LDContextEncoder.encode(multi);
156+
157+
@SuppressWarnings("unchecked")
158+
Map<String, Object> userMap = (Map<String, Object>) result.get("user");
159+
assertThat(userMap.containsKey("anonymous"), is(true));
160+
assertThat(userMap.get("anonymous"), is((Object) Boolean.FALSE));
161+
162+
@SuppressWarnings("unchecked")
163+
Map<String, Object> orgMap = (Map<String, Object>) result.get("org");
164+
assertThat(orgMap.get("anonymous"), is((Object) Boolean.TRUE));
165+
}
166+
167+
@Test
168+
public void multiKindNestedContextIncludesNameOnlyWhenPresent() {
169+
LDContext multi = LDContext.createMulti(
170+
LDContext.builder("user-key").name("Alice").build(),
171+
LDContext.builder(ContextKind.of("device"), "device-key").build());
172+
Map<String, Object> result = LDContextEncoder.encode(multi);
173+
174+
@SuppressWarnings("unchecked")
175+
Map<String, Object> userMap = (Map<String, Object>) result.get("user");
176+
assertThat(userMap.get("name"), is((Object) "Alice"));
177+
178+
@SuppressWarnings("unchecked")
179+
Map<String, Object> deviceMap = (Map<String, Object>) result.get("device");
180+
assertThat(deviceMap.containsKey("name"), is(false));
181+
}
182+
183+
@Test
184+
public void customKindContextIsEncoded() {
185+
LDContext context = LDContext.builder(ContextKind.of("device"), "device-123").build();
186+
Map<String, Object> result = LDContextEncoder.encode(context);
187+
assertThat(result.get("kind"), is((Object) "device"));
188+
assertThat(result.get("key"), is((Object) "device-123"));
189+
}
190+
191+
@Test
192+
public void nestedObjectCustomAttributeIsDeeplyTraversable() {
193+
LDContext context = LDContext.builder("user-key")
194+
.set("meta", LDValue.buildObject()
195+
.put("level1", LDValue.buildObject().put("level2", "deep-value").build())
196+
.build())
197+
.build();
198+
Map<String, Object> result = LDContextEncoder.encode(context);
199+
@SuppressWarnings("unchecked")
200+
Map<String, Object> meta = (Map<String, Object>) result.get("meta");
201+
@SuppressWarnings("unchecked")
202+
Map<String, Object> level1 = (Map<String, Object>) meta.get("level1");
203+
assertThat(level1.get("level2"), is((Object) "deep-value"));
204+
}
205+
}

0 commit comments

Comments
 (0)