diff --git a/eppo/build.gradle b/eppo/build.gradle index 60914e84..4aabb135 100644 --- a/eppo/build.gradle +++ b/eppo/build.gradle @@ -7,7 +7,7 @@ plugins { } group = "cloud.eppo" -version = "4.12.0" +version = "4.12.1" android { buildFeatures.buildConfig true diff --git a/eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java b/eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java index 83ea0b49..d7d920e5 100644 --- a/eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java +++ b/eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java @@ -16,6 +16,7 @@ import cloud.eppo.android.exceptions.MissingApplicationException; import cloud.eppo.android.exceptions.MissingSubjectKeyException; import cloud.eppo.android.exceptions.NotInitializedException; +import cloud.eppo.android.util.ContextAttributesSerializer; import cloud.eppo.android.util.ObfuscationUtils; import cloud.eppo.android.util.Utils; import cloud.eppo.api.Attributes; @@ -645,27 +646,7 @@ private String buildRequestUrl() { private String buildRequestBody() throws Exception { Map body = new HashMap<>(); body.put("subject_key", subjectKey); - - Map subjectAttrsMap = new HashMap<>(); - Map numericAttrs = new HashMap<>(); - Map categoricalAttrs = new HashMap<>(); - - if (subjectAttributes != null) { - for (String key : subjectAttributes.keySet()) { - EppoValue value = subjectAttributes.get(key); - if (value != null) { - if (value.isNumeric()) { - numericAttrs.put(key, value.doubleValue()); - } else { - categoricalAttrs.put(key, value.stringValue()); - } - } - } - } - - subjectAttrsMap.put("numericAttributes", numericAttrs); - subjectAttrsMap.put("categoricalAttributes", categoricalAttrs); - body.put("subject_attributes", subjectAttrsMap); + body.put("subject_attributes", ContextAttributesSerializer.serialize(subjectAttributes)); if (banditActions != null && !banditActions.isEmpty()) { // Transform banditActions to match the expected wire format with numericAttributes and @@ -674,27 +655,8 @@ private String buildRequestBody() throws Exception { for (Map.Entry> flagEntry : banditActions.entrySet()) { Map> actionsForFlag = new HashMap<>(); for (Map.Entry actionEntry : flagEntry.getValue().entrySet()) { - Map actionAttrsMap = new HashMap<>(); - Map actionNumericAttrs = new HashMap<>(); - Map actionCategoricalAttrs = new HashMap<>(); - - Attributes attrs = actionEntry.getValue(); - if (attrs != null) { - for (String key : attrs.keySet()) { - EppoValue value = attrs.get(key); - if (value != null) { - if (value.isNumeric()) { - actionNumericAttrs.put(key, value.doubleValue()); - } else { - actionCategoricalAttrs.put(key, value.stringValue()); - } - } - } - } - - actionAttrsMap.put("numericAttributes", actionNumericAttrs); - actionAttrsMap.put("categoricalAttributes", actionCategoricalAttrs); - actionsForFlag.put(actionEntry.getKey(), actionAttrsMap); + actionsForFlag.put( + actionEntry.getKey(), ContextAttributesSerializer.serialize(actionEntry.getValue())); } serializedBanditActions.put(flagEntry.getKey(), actionsForFlag); } diff --git a/eppo/src/main/java/cloud/eppo/android/util/ContextAttributesSerializer.java b/eppo/src/main/java/cloud/eppo/android/util/ContextAttributesSerializer.java new file mode 100644 index 00000000..439666da --- /dev/null +++ b/eppo/src/main/java/cloud/eppo/android/util/ContextAttributesSerializer.java @@ -0,0 +1,59 @@ +package cloud.eppo.android.util; + +import cloud.eppo.api.Attributes; +import cloud.eppo.api.EppoValue; +import java.util.HashMap; +import java.util.Map; + +/** + * Utility class for serializing subject and action attributes to the wire format expected by the + * precomputed flags API. + * + *

The API expects attributes to be separated into: + * + *

    + *
  • numericAttributes: numbers (integers and doubles) + *
  • categoricalAttributes: strings, booleans, and other non-numeric types + *
+ * + *

This matches the behavior of the JS SDK and ensures consistent wire format across all Eppo + * SDKs. + */ +public final class ContextAttributesSerializer { + + private ContextAttributesSerializer() { + // Prevent instantiation + } + + /** + * Serializes attributes into the context attributes format expected by the API. + * + * @param attributes The attributes to serialize (can be null) + * @return A map containing "numericAttributes" and "categoricalAttributes" keys + */ + public static Map serialize(Attributes attributes) { + Map result = new HashMap<>(); + Map numericAttrs = new HashMap<>(); + Map categoricalAttrs = new HashMap<>(); + + if (attributes != null) { + for (String key : attributes.keySet()) { + EppoValue value = attributes.get(key); + if (value != null && !value.isNull()) { + if (value.isNumeric()) { + numericAttrs.put(key, value.doubleValue()); + } else if (value.isBoolean()) { + // Booleans should be serialized as native JSON booleans, not strings + categoricalAttrs.put(key, value.booleanValue()); + } else { + categoricalAttrs.put(key, value.stringValue()); + } + } + } + } + + result.put("numericAttributes", numericAttrs); + result.put("categoricalAttributes", categoricalAttrs); + return result; + } +} diff --git a/eppo/src/test/java/cloud/eppo/android/ContextAttributesSerializerTest.java b/eppo/src/test/java/cloud/eppo/android/ContextAttributesSerializerTest.java new file mode 100644 index 00000000..90c1a861 --- /dev/null +++ b/eppo/src/test/java/cloud/eppo/android/ContextAttributesSerializerTest.java @@ -0,0 +1,266 @@ +package cloud.eppo.android; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import cloud.eppo.android.util.ContextAttributesSerializer; +import cloud.eppo.api.Attributes; +import cloud.eppo.api.EppoValue; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** + * Tests for ContextAttributesSerializer wire format. + * + *

These tests ensure that attributes are serialized correctly to match the wire format expected + * by the precomputed flags API and to maintain consistency with other Eppo SDKs (JS, iOS). + */ +@RunWith(RobolectricTestRunner.class) +public class ContextAttributesSerializerTest { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + public void testSerializeWithNullAttributes() { + Map result = ContextAttributesSerializer.serialize(null); + + assertNotNull(result); + assertTrue(result.containsKey("numericAttributes")); + assertTrue(result.containsKey("categoricalAttributes")); + assertTrue(((Map) result.get("numericAttributes")).isEmpty()); + assertTrue(((Map) result.get("categoricalAttributes")).isEmpty()); + } + + @Test + public void testSerializeWithEmptyAttributes() { + Attributes attrs = new Attributes(); + Map result = ContextAttributesSerializer.serialize(attrs); + + assertNotNull(result); + assertTrue(((Map) result.get("numericAttributes")).isEmpty()); + assertTrue(((Map) result.get("categoricalAttributes")).isEmpty()); + } + + @Test + public void testNumericAttributesSeparation() { + Attributes attrs = new Attributes(); + attrs.put("age", EppoValue.valueOf(25)); + attrs.put("score", EppoValue.valueOf(99.5)); + attrs.put("country", EppoValue.valueOf("US")); + + Map result = ContextAttributesSerializer.serialize(attrs); + + @SuppressWarnings("unchecked") + Map numericAttrs = (Map) result.get("numericAttributes"); + @SuppressWarnings("unchecked") + Map categoricalAttrs = + (Map) result.get("categoricalAttributes"); + + assertEquals(2, numericAttrs.size()); + assertEquals(25.0, numericAttrs.get("age").doubleValue(), 0.001); + assertEquals(99.5, numericAttrs.get("score").doubleValue(), 0.001); + + assertEquals(1, categoricalAttrs.size()); + assertEquals("US", categoricalAttrs.get("country")); + } + + @Test + public void testBooleansSerializedAsNativeBooleans() { + Attributes attrs = new Attributes(); + attrs.put("isPremium", EppoValue.valueOf(true)); + attrs.put("hasNotifications", EppoValue.valueOf(false)); + + Map result = ContextAttributesSerializer.serialize(attrs); + + @SuppressWarnings("unchecked") + Map categoricalAttrs = + (Map) result.get("categoricalAttributes"); + + assertEquals(2, categoricalAttrs.size()); + + // Verify booleans are stored as Boolean objects, not Strings + assertTrue(categoricalAttrs.get("isPremium") instanceof Boolean); + assertTrue(categoricalAttrs.get("hasNotifications") instanceof Boolean); + + assertEquals(true, categoricalAttrs.get("isPremium")); + assertEquals(false, categoricalAttrs.get("hasNotifications")); + } + + @Test + public void testBooleansNotConvertedToStrings() { + Attributes attrs = new Attributes(); + attrs.put("isActive", EppoValue.valueOf(true)); + + Map result = ContextAttributesSerializer.serialize(attrs); + + @SuppressWarnings("unchecked") + Map categoricalAttrs = + (Map) result.get("categoricalAttributes"); + + // This is the key test: booleans should NOT be strings + assertFalse( + "Boolean should not be converted to String", + categoricalAttrs.get("isActive") instanceof String); + assertTrue( + "Boolean should remain as Boolean", categoricalAttrs.get("isActive") instanceof Boolean); + } + + @Test + public void testEppoNullValuesExcluded() { + Attributes attrs = new Attributes(); + attrs.put("validString", EppoValue.valueOf("test")); + attrs.put("validNumber", EppoValue.valueOf(42)); + attrs.put("nullValue", EppoValue.nullValue()); + + Map result = ContextAttributesSerializer.serialize(attrs); + + @SuppressWarnings("unchecked") + Map numericAttrs = (Map) result.get("numericAttributes"); + @SuppressWarnings("unchecked") + Map categoricalAttrs = + (Map) result.get("categoricalAttributes"); + + // EppoValue.nullValue() entries should be excluded from both maps + assertEquals(1, numericAttrs.size()); + assertEquals(1, categoricalAttrs.size()); + assertFalse(numericAttrs.containsKey("nullValue")); + assertFalse(categoricalAttrs.containsKey("nullValue")); + } + + @Test + public void testJavaNullValuesExcluded() { + Attributes attrs = new Attributes(); + attrs.put("validString", EppoValue.valueOf("test")); + attrs.put("javaNullValue", (EppoValue) null); + + Map result = ContextAttributesSerializer.serialize(attrs); + + @SuppressWarnings("unchecked") + Map numericAttrs = (Map) result.get("numericAttributes"); + @SuppressWarnings("unchecked") + Map categoricalAttrs = + (Map) result.get("categoricalAttributes"); + + // Java null entries should be excluded from both maps + assertEquals(0, numericAttrs.size()); + assertEquals(1, categoricalAttrs.size()); + assertFalse(numericAttrs.containsKey("javaNullValue")); + assertFalse(categoricalAttrs.containsKey("javaNullValue")); + } + + @Test + public void testStringAttributesInCategorical() { + Attributes attrs = new Attributes(); + attrs.put("country", EppoValue.valueOf("US")); + attrs.put("language", EppoValue.valueOf("en-US")); + attrs.put("platform", EppoValue.valueOf("android")); + + Map result = ContextAttributesSerializer.serialize(attrs); + + @SuppressWarnings("unchecked") + Map categoricalAttrs = + (Map) result.get("categoricalAttributes"); + + assertEquals(3, categoricalAttrs.size()); + assertEquals("US", categoricalAttrs.get("country")); + assertEquals("en-US", categoricalAttrs.get("language")); + assertEquals("android", categoricalAttrs.get("platform")); + } + + @Test + public void testMixedAttributeTypes() { + Attributes attrs = new Attributes(); + // Numeric + attrs.put("age", EppoValue.valueOf(30)); + attrs.put("lifetimeValue", EppoValue.valueOf(543.21)); + // Boolean + attrs.put("isPremium", EppoValue.valueOf(true)); + attrs.put("hasPushEnabled", EppoValue.valueOf(false)); + // String + attrs.put("country", EppoValue.valueOf("US")); + attrs.put("language", EppoValue.valueOf("en")); + // Null (should be excluded) + attrs.put("nullAttr", EppoValue.nullValue()); + + Map result = ContextAttributesSerializer.serialize(attrs); + + @SuppressWarnings("unchecked") + Map numericAttrs = (Map) result.get("numericAttributes"); + @SuppressWarnings("unchecked") + Map categoricalAttrs = + (Map) result.get("categoricalAttributes"); + + // Verify numeric attributes + assertEquals(2, numericAttrs.size()); + assertEquals(30.0, numericAttrs.get("age").doubleValue(), 0.001); + assertEquals(543.21, numericAttrs.get("lifetimeValue").doubleValue(), 0.001); + + // Verify categorical attributes (booleans + strings, excluding null) + assertEquals(4, categoricalAttrs.size()); + assertEquals(true, categoricalAttrs.get("isPremium")); + assertEquals(false, categoricalAttrs.get("hasPushEnabled")); + assertEquals("US", categoricalAttrs.get("country")); + assertEquals("en", categoricalAttrs.get("language")); + } + + @Test + public void testJsonSerializationFormat() throws Exception { + Attributes attrs = new Attributes(); + attrs.put("age", EppoValue.valueOf(25)); + attrs.put("isPremium", EppoValue.valueOf(true)); + attrs.put("country", EppoValue.valueOf("US")); + + Map result = ContextAttributesSerializer.serialize(attrs); + String json = objectMapper.writeValueAsString(result); + + // Verify the JSON contains native boolean (true, not "true") + assertTrue("JSON should contain native boolean true", json.contains(":true")); + assertFalse("JSON should not contain string \"true\"", json.contains(":\"true\"")); + + // Verify the JSON contains native number + assertTrue("JSON should contain native number", json.contains(":25")); + + // Verify the JSON contains string with quotes + assertTrue("JSON should contain quoted string", json.contains("\"US\"")); + } + + @Test + public void testWireFormatMatchesTestData() throws Exception { + // This test verifies the format matches what's in sdk-test-data/precomputed-v1.json + // The test data has: + // "subjectAttributes": { + // "categoricalAttributes": { "platform": "ios", "hasPushEnabled": false }, + // "numericAttributes": { "lastLoginDays": 3, "lifetimeValue": 543.21 } + // } + + Attributes attrs = new Attributes(); + attrs.put("platform", EppoValue.valueOf("ios")); + attrs.put("hasPushEnabled", EppoValue.valueOf(false)); + attrs.put("lastLoginDays", EppoValue.valueOf(3)); + attrs.put("lifetimeValue", EppoValue.valueOf(543.21)); + + Map result = ContextAttributesSerializer.serialize(attrs); + String json = objectMapper.writeValueAsString(result); + + // Verify structure + assertTrue(json.contains("\"numericAttributes\"")); + assertTrue(json.contains("\"categoricalAttributes\"")); + + // Verify boolean is native (false, not "false") + assertTrue("Boolean false should be native JSON boolean", json.contains(":false")); + assertFalse( + "Boolean should not be string \"false\"", + json.contains(":\"false\"") || json.contains("\"hasPushEnabled\":\"false\"")); + + // Verify numeric values + @SuppressWarnings("unchecked") + Map numericAttrs = (Map) result.get("numericAttributes"); + assertEquals(3.0, numericAttrs.get("lastLoginDays").doubleValue(), 0.001); + assertEquals(543.21, numericAttrs.get("lifetimeValue").doubleValue(), 0.001); + } +}