Skip to content

fix: serialize booleans as native JSON in precomputed request body#243

Merged
leoromanovsky merged 5 commits intomainfrom
fix/precomputed-request-body-wire-format
Feb 6, 2026
Merged

fix: serialize booleans as native JSON in precomputed request body#243
leoromanovsky merged 5 commits intomainfrom
fix/precomputed-request-body-wire-format

Conversation

@leoromanovsky
Copy link
Member

@leoromanovsky leoromanovsky commented Feb 5, 2026

Motivation

The buildRequestBody() method in EppoPrecomputedClient was converting all non-numeric attribute values to strings, including booleans. This caused boolean attributes to be sent in the wrong wire format:

Before (incorrect):

{
  "categoricalAttributes": {
    "isPremium": "true",
    "hasPushEnabled": "false"
  }
}

After (correct):

{
  "categoricalAttributes": {
    "isPremium": true,
    "hasPushEnabled": false
  }
}

This fix aligns the Android SDK with the JS SDK and iOS SDK (see ios-sdk#95), which preserve native boolean types in the wire format.

The shared test data in sdk-test-data/configuration-wire/precomputed-v1.json confirms that booleans should be native JSON booleans:

"categoricalAttributes": {
  "hasPushEnabled": false,
  "buildNumber": 42
}

Changes

  • Add ContextAttributesSerializer utility class for consistent attribute serialization
  • Properly handle booleans as native JSON booleans in categorical attributes
  • Exclude null values from serialization (matching JS/iOS behavior)
  • Simplify buildRequestBody() by using the new utility
  • Add comprehensive unit tests for wire format verification
  • Add precomputed-v1.json test data for integration tests

Decisions

  • Created a dedicated utility class (ContextAttributesSerializer) for testability and reuse across subject attributes and bandit actions
  • Used Map<String, Object> for categorical attributes instead of Map<String, String> to allow native boolean values

Test plan

  • Run unit tests: ./gradlew :eppo:testDebugUnitTest
  • Run instrumented tests: ./gradlew :eppo:connectedDebugAndroidTest
  • Verify wire format matches expected output in real API calls

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
The precomputed-v1.json file should be fetched via `make test-data`
rather than committed to the repository.
@leoromanovsky leoromanovsky marked this pull request as ready for review February 5, 2026 22:53
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a bug where boolean attributes were incorrectly serialized as strings ("true", "false") instead of native JSON booleans (true, false) in the precomputed request body. This aligns the Android SDK's wire format with the JS and iOS SDKs.

Changes:

  • Introduced ContextAttributesSerializer utility for consistent attribute serialization across subject and bandit action attributes
  • Updated serialization to preserve booleans as native JSON types and exclude null values
  • Version bumped to 4.12.1

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
eppo/src/main/java/cloud/eppo/android/util/ContextAttributesSerializer.java New utility class that properly handles boolean serialization as native JSON types
eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java Refactored to use the new serializer utility, removing duplicated serialization logic
eppo/src/test/java/cloud/eppo/android/ContextAttributesSerializerTest.java Comprehensive unit tests verifying wire format correctness
eppo/build.gradle Version bump to 4.12.1

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…zerTest.java

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@leoromanovsky leoromanovsky requested a review from aarsilv February 6, 2026 13:41
Copy link
Contributor

@aarsilv aarsilv left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch and glad we have a shared test case now that exercises this.

Approving with two minor comments you can take or leave:

if (attributes != null) {
for (String key : attributes.keySet()) {
EppoValue value = attributes.get(key);
if (value != null && !value.isNull()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be adding null as the value if it is null?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We explicitly skip null in swift and javascript so I was matching that behavior.

for (String key : attributes.keySet()) {
EppoValue value = attributes.get(key);
if (value != null && !value.isNull()) {
if (value.isNumeric()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if the newly-added unrwap() could help simplify things here.

https://github.com/Eppo-exp/sdk-common-jdk/blob/main/src/main/java/cloud/eppo/api/EppoValue.java#L110

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, good suggestion, hadn't explore the common java repo.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't simplify the code for us because we need to know the variation type, would look like this:

  // We'd still need the same if/else chain to pick the VariationType
  if (value.isNumeric()) {
      numericAttrs.put(key, value.unwrap(VariationType.NUMERIC));   // Double
  } else if (value.isBoolean()) {
      categoricalAttrs.put(key, value.unwrap(VariationType.BOOLEAN)); // Boolean
  } else {
      categoricalAttrs.put(key, value.unwrap(VariationType.STRING));  // String
  }

Is that what you were trying to simplify?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be more helpful to augment the EppoValue in jdk common to auto detect the type:

public Object unwrap() {
  switch (this.type) {
    case BOOLEAN: return boolValue;
    case NUMBER: return doubleValue;
    case STRING: return stringValue;
    ...
  }
}

Then the serializer becomes:

if (value.isNumeric()) {
  numericAttrs.put(key, (Number) value.unwrap());
} else {
  categoricalAttrs.put(key, value.unwrap());
}

}

@Test
public void testBooleansNotConvertedToStrings() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💪

* by the precomputed flags API and to maintain consistency with other Eppo SDKs (JS, iOS).
*/
@RunWith(RobolectricTestRunner.class)
public class ContextAttributesSerializerTest {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that we pulled this out into it's own testable class. Since we're doing that, let's include a test for null valued attributes to cover that base.

Cover both EppoValue.nullValue() and Java null in the attributes map
to document that both are excluded from serialization output.
@leoromanovsky leoromanovsky merged commit d717515 into main Feb 6, 2026
4 checks passed
@leoromanovsky leoromanovsky deleted the fix/precomputed-request-body-wire-format branch February 6, 2026 18:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Comments