From b042b416a86843f7991b270d96e5ee2d33d5caba Mon Sep 17 00:00:00 2001 From: Heath Borders Date: Mon, 19 May 2025 13:39:55 -0500 Subject: [PATCH 1/2] Support serializing FlagConfigResponse Previous implementation didn't have a serializer `EppoValueSerializer` should only write one value: Previous implemenation could write multiple values (would always write a `null`) for anything that isn't an `ARRAY_OF_STRING` type --- src/main/java/cloud/eppo/api/EppoValue.java | 2 +- .../eppo/ufc/dto/adapters/EppoModule.java | 1 + .../ufc/dto/adapters/EppoValueSerializer.java | 31 ++- .../FlagConfigResponseDeserializer.java | 2 +- .../FlagConfigResponseSerializer.java | 260 ++++++++++++++++++ .../FlagConfigResponseDeserializerTest.java | 37 +++ 6 files changed, 317 insertions(+), 16 deletions(-) create mode 100644 src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseSerializer.java diff --git a/src/main/java/cloud/eppo/api/EppoValue.java b/src/main/java/cloud/eppo/api/EppoValue.java index aee8bae5..a366c771 100644 --- a/src/main/java/cloud/eppo/api/EppoValue.java +++ b/src/main/java/cloud/eppo/api/EppoValue.java @@ -6,7 +6,7 @@ import java.util.Objects; public class EppoValue { - protected final EppoValueType type; + public final EppoValueType type; protected Boolean boolValue; protected Double doubleValue; protected String stringValue; diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoModule.java b/src/main/java/cloud/eppo/ufc/dto/adapters/EppoModule.java index 7066377c..a50eced2 100644 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoModule.java +++ b/src/main/java/cloud/eppo/ufc/dto/adapters/EppoModule.java @@ -10,6 +10,7 @@ public class EppoModule { public static SimpleModule eppoModule() { SimpleModule module = new SimpleModule(); module.addDeserializer(FlagConfigResponse.class, new FlagConfigResponseDeserializer()); + module.addSerializer(FlagConfigResponse.class, new FlagConfigResponseSerializer()); module.addDeserializer( BanditParametersResponse.class, new BanditParametersResponseDeserializer()); module.addDeserializer(EppoValue.class, new EppoValueDeserializer()); diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueSerializer.java b/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueSerializer.java index 8bd10bd6..c5669f02 100644 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueSerializer.java +++ b/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueSerializer.java @@ -18,20 +18,23 @@ public EppoValueSerializer() { @Override public void serialize(EppoValue src, JsonGenerator jgen, SerializerProvider provider) throws IOException { - if (src.isBoolean()) { - jgen.writeBoolean(src.booleanValue()); - } - if (src.isNumeric()) { - jgen.writeNumber(src.doubleValue()); - } - if (src.isString()) { - jgen.writeString(src.stringValue()); - } - if (src.isStringArray()) { - String[] arr = src.stringArrayValue().toArray(new String[0]); - jgen.writeArray(arr, 0, arr.length); - } else { - jgen.writeNull(); + switch (src.type) { + case NULL: + jgen.writeNull(); + break; + case BOOLEAN: + jgen.writeBoolean(src.booleanValue()); + break; + case NUMBER: + jgen.writeNumber(src.doubleValue()); + break; + case STRING: + jgen.writeString(src.stringValue()); + break; + case ARRAY_OF_STRING: + String[] arr = src.stringArrayValue().toArray(new String[0]); + jgen.writeArray(arr, 0, arr.length); + break; } } } diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java b/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java index 7c48a50f..af06b623 100644 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java +++ b/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java @@ -135,7 +135,7 @@ private Set deserializeTargetingRules(JsonNode jsonNode) { Set conditions = new HashSet<>(); for (JsonNode conditionNode : ruleNode.get("conditions")) { String attribute = conditionNode.get("attribute").asText(); - String operatorKey = conditionNode.get("operator").asText(); + String operatorKey = conditionNode.get("operator").asText(null); OperatorType operator = OperatorType.fromString(operatorKey); if (operator == null) { log.warn("Unknown operator \"{}\"", operatorKey); diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseSerializer.java b/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseSerializer.java new file mode 100644 index 00000000..ea4bcdde --- /dev/null +++ b/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseSerializer.java @@ -0,0 +1,260 @@ +package cloud.eppo.ufc.dto.adapters; + +import static cloud.eppo.Utils.getISODate; +import static cloud.eppo.Utils.parseUtcISODateNode; + +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; + +import cloud.eppo.api.EppoValue; +import cloud.eppo.model.ShardRange; +import cloud.eppo.ufc.dto.Allocation; +import cloud.eppo.ufc.dto.BanditFlagVariation; +import cloud.eppo.ufc.dto.BanditReference; +import cloud.eppo.ufc.dto.FlagConfig; +import cloud.eppo.ufc.dto.FlagConfigResponse; +import cloud.eppo.ufc.dto.OperatorType; +import cloud.eppo.ufc.dto.Shard; +import cloud.eppo.ufc.dto.Split; +import cloud.eppo.ufc.dto.TargetingCondition; +import cloud.eppo.ufc.dto.TargetingRule; +import cloud.eppo.ufc.dto.Variation; +import cloud.eppo.ufc.dto.VariationType; + +/** + * Hand-rolled serializer so that we don't rely on annotations and method names, which can be + * unreliable when ProGuard minification is in-use and not configured to protect + * JSON-serialization-related classes and annotations. + */ +public class FlagConfigResponseSerializer extends StdSerializer { + private static final Logger log = LoggerFactory.getLogger(FlagConfigResponseSerializer.class); + private final EppoValueSerializer eppoValueSerializer = new EppoValueSerializer(); + + protected FlagConfigResponseSerializer(Class vc) { + super(vc); + } + + public FlagConfigResponseSerializer() { + this(null); + } + + @Override + public void serialize(FlagConfigResponse src, JsonGenerator jgen, SerializerProvider provider) + throws IOException, JacksonException { + jgen.writeStartObject(); + final FlagConfigResponse.Format format = src.getFormat(); + if (format != null) { + jgen.writeStringField("format", format.name()); + } + final Map flags = src.getFlags(); + if (flags != null) { + jgen.writeFieldName("flags"); + jgen.writeStartObject(); + for (Map.Entry entry : src.getFlags().entrySet()) { + jgen.writeFieldName(entry.getKey()); + serializeFlag(entry.getValue(), jgen, provider); + } + jgen.writeEndObject(); + } + final Map banditReferences = src.getBanditReferences(); + if (banditReferences != null) { + jgen.writeFieldName("banditReferences"); + jgen.writeStartObject(); + for (Map.Entry entry : banditReferences.entrySet()) { + jgen.writeFieldName(entry.getKey()); + serializeBanditReference(entry.getValue(), jgen); + } + jgen.writeEndObject(); + } + jgen.writeEndObject(); + } + + private void serializeFlag(FlagConfig flagConfig, JsonGenerator jgen, SerializerProvider provider) + throws IOException { + jgen.writeStartObject(); + jgen.writeStringField("key", flagConfig.getKey()); + jgen.writeBooleanField("enabled", flagConfig.isEnabled()); + jgen.writeNumberField("totalShards", flagConfig.getTotalShards()); + final VariationType variationType = flagConfig.getVariationType(); + if (variationType != null) { + jgen.writeStringField("variationType", variationType.value); + } + final Map variations = flagConfig.getVariations(); + if (variations != null) { + jgen.writeFieldName("variations"); + jgen.writeStartObject(); + for (Map.Entry entry : variations.entrySet()) { + jgen.writeFieldName(entry.getKey()); + serializeVariation(entry.getValue(), jgen); + } + jgen.writeEndObject(); + } + final List allocations = flagConfig.getAllocations(); + if (allocations != null) { + jgen.writeFieldName("allocations"); + jgen.writeStartArray(); + for (Allocation allocation : allocations) { + serializeAllocation(allocation, jgen, provider); + } + jgen.writeEndArray(); + } + jgen.writeEndObject(); + } + + private void serializeVariation(Variation variation, JsonGenerator jgen) + throws IOException { + jgen.writeStartObject(); + jgen.writeStringField("key", variation.getKey()); + jgen.writeObjectField("value", variation.getValue()); + jgen.writeEndObject(); + } + + private void serializeAllocation(Allocation allocation, JsonGenerator jgen, SerializerProvider provider) + throws IOException { + jgen.writeStartObject(); + jgen.writeStringField("key", allocation.getKey()); + final Set rules = allocation.getRules(); + if (rules != null) { + jgen.writeFieldName("rules"); + jgen.writeStartArray(); + for (TargetingRule rule : rules) { + serializeTargetingRule(rule, jgen, provider); + } + jgen.writeEndArray(); + } + final Date startAt = allocation.getStartAt(); + if (startAt != null) { + jgen.writeStringField("startAt", getISODate(startAt)); + } + final Date endAt = allocation.getEndAt(); + if (endAt != null) { + jgen.writeStringField("endAt", getISODate(endAt)); + } + final List splits = allocation.getSplits(); + if (splits != null) { + jgen.writeFieldName("splits"); + jgen.writeStartArray(); + for (Split split : splits) { + serializeSplit(split, jgen); + } + jgen.writeEndArray(); + } + jgen.writeBooleanField("doLog", allocation.doLog()); + + jgen.writeEndObject(); + } + + private void serializeTargetingRule(TargetingRule rule, JsonGenerator jgen, SerializerProvider provider) + throws IOException { + jgen.writeStartObject(); + final Set conditions = rule.getConditions(); + if (conditions != null) { + jgen.writeFieldName("conditions"); + jgen.writeStartArray(); + for (TargetingCondition condition : conditions) { + jgen.writeStartObject(); + jgen.writeStringField("attribute", condition.getAttribute()); + final OperatorType operator = condition.getOperator(); + if (operator != null) { + jgen.writeStringField("operator", operator.value); + } + final EppoValue value = condition.getValue(); + if (value != null) { + jgen.writeFieldName("value"); + eppoValueSerializer.serialize(value, jgen, provider); + } + + jgen.writeEndObject(); + } + jgen.writeEndArray(); + } + jgen.writeEndObject(); + } + + private void serializeSplit(Split split, JsonGenerator jgen) + throws IOException { + jgen.writeStartObject(); + jgen.writeStringField("variationKey", split.getVariationKey()); + final Set shards = split.getShards(); + if (shards != null) { + jgen.writeFieldName("shards"); + jgen.writeStartArray(); + for (Shard shard : shards) { + serializeShard(shard, jgen); + } + jgen.writeEndArray(); + } + Map extraLogging = split.getExtraLogging(); + if (extraLogging != null) { + jgen.writeFieldName("extraLogging"); + jgen.writeStartObject(); + for (Map.Entry extraLog : extraLogging.entrySet()) { + jgen.writeStringField(extraLog.getKey(), extraLog.getValue()); + } + jgen.writeEndObject(); + } + jgen.writeEndObject(); + } + + private void serializeShard(Shard shard, JsonGenerator jgen) + throws IOException { + jgen.writeStartObject(); + jgen.writeStringField("salt", shard.getSalt()); + final Set ranges = shard.getRanges(); + if (ranges != null) { + jgen.writeFieldName("ranges"); + jgen.writeStartArray(); + for (ShardRange range : ranges) { + jgen.writeStartObject(); + jgen.writeNumberField("start", range.getStart()); + jgen.writeNumberField("end", range.getEnd()); + jgen.writeEndObject(); + } + jgen.writeEndArray(); + } + jgen.writeEndObject(); + } + + private void serializeBanditReference(BanditReference banditReference, JsonGenerator jgen) + throws IOException { + jgen.writeStartObject(); + jgen.writeStringField("modelVersion", banditReference.getModelVersion()); + List flagVariations = banditReference.getFlagVariations(); + if (flagVariations != null) { + jgen.writeFieldName("flagVariations"); + jgen.writeStartArray(); + for (BanditFlagVariation flagVariation : flagVariations) { + jgen.writeStartObject(); + jgen.writeStringField("key", flagVariation.getBanditKey()); + jgen.writeStringField("flagKey", flagVariation.getFlagKey()); + jgen.writeStringField("allocationKey", flagVariation.getAllocationKey()); + jgen.writeStringField("variationKey", flagVariation.getVariationKey()); + jgen.writeStringField("variationValue", flagVariation.getVariationValue()); + jgen.writeEndObject(); + } + jgen.writeEndArray(); + } + jgen.writeEndObject(); + } +} diff --git a/src/test/java/cloud/eppo/ufc/deserializer/FlagConfigResponseDeserializerTest.java b/src/test/java/cloud/eppo/ufc/deserializer/FlagConfigResponseDeserializerTest.java index 24105543..7f3a08fe 100644 --- a/src/test/java/cloud/eppo/ufc/deserializer/FlagConfigResponseDeserializerTest.java +++ b/src/test/java/cloud/eppo/ufc/deserializer/FlagConfigResponseDeserializerTest.java @@ -9,6 +9,7 @@ import java.io.File; import java.io.FileReader; import java.io.IOException; +import java.io.StringReader; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -23,6 +24,21 @@ public void testDeserializePlainText() throws IOException { FileReader fileReader = new FileReader(testUfc); FlagConfigResponse configResponse = mapper.readValue(fileReader, FlagConfigResponse.class); + assertFlagConfigResponse(configResponse); + } + + @Test + public void testSerializePlainText() throws IOException { + File testUfc = new File("src/test/resources/flags-v1.json"); + FileReader fileReader = new FileReader(testUfc); + FlagConfigResponse initialConfigResponse = mapper.readValue(fileReader, FlagConfigResponse.class); + String initialConfigResponseJson = mapper.writeValueAsString(initialConfigResponse); + FlagConfigResponse rereadConfigResponse = mapper.readValue(new StringReader(initialConfigResponseJson), FlagConfigResponse.class); + + assertFlagConfigResponse(rereadConfigResponse); + } + + private void assertFlagConfigResponse(FlagConfigResponse configResponse) { assertTrue(configResponse.getFlags().size() >= 13); assertTrue(configResponse.getFlags().containsKey("empty_flag")); assertTrue(configResponse.getFlags().containsKey("disabled_flag")); @@ -114,4 +130,25 @@ public void testDeserializePlainText() throws IOException { assertEquals("off", offForAllSplit.getVariationKey()); assertEquals(0, offForAllSplit.getShards().size()); } + + @Test + public void testSerializePlainTextWithBandit() throws IOException { + File testUfc = new File("src/test/resources/static/initial-flag-config-with-bandit.json"); + FileReader fileReader = new FileReader(testUfc); + FlagConfigResponse initialConfigResponse = mapper.readValue(fileReader, FlagConfigResponse.class); + String initialConfigResponseJson = mapper.writeValueAsString(initialConfigResponse); + FlagConfigResponse rereadConfigResponse = mapper.readValue(new StringReader(initialConfigResponseJson), FlagConfigResponse.class); + + assertEquals(1, rereadConfigResponse.getBanditReferences().size()); + BanditReference bannerBanditReference = rereadConfigResponse.getBanditReferences().get("banner_bandit"); + assertEquals("v123", bannerBanditReference.getModelVersion()); + + assertEquals(1, bannerBanditReference.getFlagVariations().size()); + BanditFlagVariation flagVariation = bannerBanditReference.getFlagVariations().get(0); + assertEquals("banner_bandit", flagVariation.getBanditKey()); + assertEquals("banner_bandit_flag", flagVariation.getFlagKey()); + assertEquals("analysis", flagVariation.getAllocationKey()); + assertEquals("banner_bandit", flagVariation.getVariationKey()); + assertEquals("banner_bandit", flagVariation.getVariationValue()); + } } From 3d2f334014caf13cdba91c0de48e34d386e206a0 Mon Sep 17 00:00:00 2001 From: Heath Borders Date: Tue, 20 May 2025 16:09:13 -0500 Subject: [PATCH 2/2] Updated to use new EppoValue.getType() --- src/main/java/cloud/eppo/api/EppoValue.java | 2 +- .../ufc/dto/adapters/EppoValueSerializer.java | 44 ++++++++++++------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/main/java/cloud/eppo/api/EppoValue.java b/src/main/java/cloud/eppo/api/EppoValue.java index a366c771..aee8bae5 100644 --- a/src/main/java/cloud/eppo/api/EppoValue.java +++ b/src/main/java/cloud/eppo/api/EppoValue.java @@ -6,7 +6,7 @@ import java.util.Objects; public class EppoValue { - public final EppoValueType type; + protected final EppoValueType type; protected Boolean boolValue; protected Double doubleValue; protected String stringValue; diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueSerializer.java b/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueSerializer.java index c5669f02..61cdb6e7 100644 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueSerializer.java +++ b/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueSerializer.java @@ -1,6 +1,8 @@ package cloud.eppo.ufc.dto.adapters; import cloud.eppo.api.EppoValue; +import cloud.eppo.ufc.dto.EppoValueType; + import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.ser.std.StdSerializer; @@ -18,23 +20,31 @@ public EppoValueSerializer() { @Override public void serialize(EppoValue src, JsonGenerator jgen, SerializerProvider provider) throws IOException { - switch (src.type) { - case NULL: - jgen.writeNull(); - break; - case BOOLEAN: - jgen.writeBoolean(src.booleanValue()); - break; - case NUMBER: - jgen.writeNumber(src.doubleValue()); - break; - case STRING: - jgen.writeString(src.stringValue()); - break; - case ARRAY_OF_STRING: - String[] arr = src.stringArrayValue().toArray(new String[0]); - jgen.writeArray(arr, 0, arr.length); - break; + final EppoValueType type = src.getType(); + if (type == null) { + // this should never happen, but if it does, + // we need to write something so that we're valid JSON + // so a null value is safest. + jgen.writeNull(); + } else { + switch (src.getType()) { + case NULL: + jgen.writeNull(); + break; + case BOOLEAN: + jgen.writeBoolean(src.booleanValue()); + break; + case NUMBER: + jgen.writeNumber(src.doubleValue()); + break; + case STRING: + jgen.writeString(src.stringValue()); + break; + case ARRAY_OF_STRING: + String[] arr = src.stringArrayValue().toArray(new String[0]); + jgen.writeArray(arr, 0, arr.length); + break; + } } } }