Skip to content

Commit d717515

Browse files
fix: serialize booleans as native JSON in precomputed request body (#243)
* fix: serialize booleans as native JSON in precomputed request body The buildRequestBody() method was converting all non-numeric attribute values to strings, including booleans. This caused boolean attributes like "isPremium: true" to be sent as "isPremium: \"true\"" (string) instead of "isPremium: true" (native JSON boolean). This fix aligns the Android SDK with the JS and iOS SDKs, which preserve native boolean types in the wire format. Changes: - Add ContextAttributesSerializer utility for consistent serialization - Properly handle booleans as native JSON booleans in categorical attrs - Exclude null values from serialization (matching JS/iOS behavior) - Add comprehensive unit tests for wire format verification - Add precomputed-v1.json test data for integration tests * chore: remove test data file, use make test-data instead The precomputed-v1.json file should be fetched via `make test-data` rather than committed to the repository. * chore: bump version to 4.12.1 * Update eppo/src/test/java/cloud/eppo/android/ContextAttributesSerializerTest.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * test: add explicit null-valued attribute tests Cover both EppoValue.nullValue() and Java null in the attributes map to document that both are excluded from serialization output. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent ff5b1a2 commit d717515

4 files changed

Lines changed: 330 additions & 43 deletions

File tree

eppo/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ plugins {
77
}
88

99
group = "cloud.eppo"
10-
version = "4.12.0"
10+
version = "4.12.1"
1111

1212
android {
1313
buildFeatures.buildConfig true

eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java

Lines changed: 4 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import cloud.eppo.android.exceptions.MissingApplicationException;
1717
import cloud.eppo.android.exceptions.MissingSubjectKeyException;
1818
import cloud.eppo.android.exceptions.NotInitializedException;
19+
import cloud.eppo.android.util.ContextAttributesSerializer;
1920
import cloud.eppo.android.util.ObfuscationUtils;
2021
import cloud.eppo.android.util.Utils;
2122
import cloud.eppo.api.Attributes;
@@ -645,27 +646,7 @@ private String buildRequestUrl() {
645646
private String buildRequestBody() throws Exception {
646647
Map<String, Object> body = new HashMap<>();
647648
body.put("subject_key", subjectKey);
648-
649-
Map<String, Object> subjectAttrsMap = new HashMap<>();
650-
Map<String, Number> numericAttrs = new HashMap<>();
651-
Map<String, String> categoricalAttrs = new HashMap<>();
652-
653-
if (subjectAttributes != null) {
654-
for (String key : subjectAttributes.keySet()) {
655-
EppoValue value = subjectAttributes.get(key);
656-
if (value != null) {
657-
if (value.isNumeric()) {
658-
numericAttrs.put(key, value.doubleValue());
659-
} else {
660-
categoricalAttrs.put(key, value.stringValue());
661-
}
662-
}
663-
}
664-
}
665-
666-
subjectAttrsMap.put("numericAttributes", numericAttrs);
667-
subjectAttrsMap.put("categoricalAttributes", categoricalAttrs);
668-
body.put("subject_attributes", subjectAttrsMap);
649+
body.put("subject_attributes", ContextAttributesSerializer.serialize(subjectAttributes));
669650

670651
if (banditActions != null && !banditActions.isEmpty()) {
671652
// Transform banditActions to match the expected wire format with numericAttributes and
@@ -674,27 +655,8 @@ private String buildRequestBody() throws Exception {
674655
for (Map.Entry<String, Map<String, Attributes>> flagEntry : banditActions.entrySet()) {
675656
Map<String, Map<String, Object>> actionsForFlag = new HashMap<>();
676657
for (Map.Entry<String, Attributes> actionEntry : flagEntry.getValue().entrySet()) {
677-
Map<String, Object> actionAttrsMap = new HashMap<>();
678-
Map<String, Number> actionNumericAttrs = new HashMap<>();
679-
Map<String, String> actionCategoricalAttrs = new HashMap<>();
680-
681-
Attributes attrs = actionEntry.getValue();
682-
if (attrs != null) {
683-
for (String key : attrs.keySet()) {
684-
EppoValue value = attrs.get(key);
685-
if (value != null) {
686-
if (value.isNumeric()) {
687-
actionNumericAttrs.put(key, value.doubleValue());
688-
} else {
689-
actionCategoricalAttrs.put(key, value.stringValue());
690-
}
691-
}
692-
}
693-
}
694-
695-
actionAttrsMap.put("numericAttributes", actionNumericAttrs);
696-
actionAttrsMap.put("categoricalAttributes", actionCategoricalAttrs);
697-
actionsForFlag.put(actionEntry.getKey(), actionAttrsMap);
658+
actionsForFlag.put(
659+
actionEntry.getKey(), ContextAttributesSerializer.serialize(actionEntry.getValue()));
698660
}
699661
serializedBanditActions.put(flagEntry.getKey(), actionsForFlag);
700662
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package cloud.eppo.android.util;
2+
3+
import cloud.eppo.api.Attributes;
4+
import cloud.eppo.api.EppoValue;
5+
import java.util.HashMap;
6+
import java.util.Map;
7+
8+
/**
9+
* Utility class for serializing subject and action attributes to the wire format expected by the
10+
* precomputed flags API.
11+
*
12+
* <p>The API expects attributes to be separated into:
13+
*
14+
* <ul>
15+
* <li>numericAttributes: numbers (integers and doubles)
16+
* <li>categoricalAttributes: strings, booleans, and other non-numeric types
17+
* </ul>
18+
*
19+
* <p>This matches the behavior of the JS SDK and ensures consistent wire format across all Eppo
20+
* SDKs.
21+
*/
22+
public final class ContextAttributesSerializer {
23+
24+
private ContextAttributesSerializer() {
25+
// Prevent instantiation
26+
}
27+
28+
/**
29+
* Serializes attributes into the context attributes format expected by the API.
30+
*
31+
* @param attributes The attributes to serialize (can be null)
32+
* @return A map containing "numericAttributes" and "categoricalAttributes" keys
33+
*/
34+
public static Map<String, Object> serialize(Attributes attributes) {
35+
Map<String, Object> result = new HashMap<>();
36+
Map<String, Number> numericAttrs = new HashMap<>();
37+
Map<String, Object> categoricalAttrs = new HashMap<>();
38+
39+
if (attributes != null) {
40+
for (String key : attributes.keySet()) {
41+
EppoValue value = attributes.get(key);
42+
if (value != null && !value.isNull()) {
43+
if (value.isNumeric()) {
44+
numericAttrs.put(key, value.doubleValue());
45+
} else if (value.isBoolean()) {
46+
// Booleans should be serialized as native JSON booleans, not strings
47+
categoricalAttrs.put(key, value.booleanValue());
48+
} else {
49+
categoricalAttrs.put(key, value.stringValue());
50+
}
51+
}
52+
}
53+
}
54+
55+
result.put("numericAttributes", numericAttrs);
56+
result.put("categoricalAttributes", categoricalAttrs);
57+
return result;
58+
}
59+
}

0 commit comments

Comments
 (0)