From d89f40915db80155a5fa0a4e31b39961b7edb6e7 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Tue, 9 Dec 2025 10:25:21 -0500 Subject: [PATCH 1/3] parse configuration metadata --- .../java/cloud/eppo/api/Configuration.java | 99 ++++++++++++++++--- .../eppo/ufc/dto/FlagConfigResponse.java | 56 ++++++++--- .../FlagConfigResponseDeserializer.java | 18 +++- .../eppo/api/ConfigurationBuilderTest.java | 18 +++- 4 files changed, 158 insertions(+), 33 deletions(-) diff --git a/src/main/java/cloud/eppo/api/Configuration.java b/src/main/java/cloud/eppo/api/Configuration.java index 8634a3ac..a777c400 100644 --- a/src/main/java/cloud/eppo/api/Configuration.java +++ b/src/main/java/cloud/eppo/api/Configuration.java @@ -10,6 +10,7 @@ import java.io.*; import java.util.Arrays; import java.util.Collections; +import java.util.Date; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -62,6 +63,9 @@ public class Configuration { private final Map flags; private final Map bandits; private final boolean isConfigObfuscated; + private final String environmentName; + private final Date configFetchedAt; + private final Date configPublishedAt; @SuppressWarnings("unused") private final byte[] flagConfigJson; @@ -74,12 +78,18 @@ public class Configuration { Map banditReferences, Map bandits, boolean isConfigObfuscated, + String environmentName, + Date configFetchedAt, + Date configPublishedAt, byte[] flagConfigJson, byte[] banditParamsJson) { this.flags = flags; this.banditReferences = banditReferences; this.bandits = bandits; this.isConfigObfuscated = isConfigObfuscated; + this.environmentName = environmentName; + this.configFetchedAt = configFetchedAt; + this.configPublishedAt = configPublishedAt; // Graft the `forServer` boolean into the flagConfigJson' if (flagConfigJson != null && flagConfigJson.length != 0) { @@ -105,20 +115,36 @@ public static Configuration emptyConfig() { Collections.emptyMap(), Collections.emptyMap(), false, + null, + null, + null, emptyFlagsBytes, null); } @Override public String toString() { - return "Configuration{" + - "banditReferences=" + banditReferences + - ", flags=" + flags + - ", bandits=" + bandits + - ", isConfigObfuscated=" + isConfigObfuscated + - ", flagConfigJson=" + Arrays.toString(flagConfigJson) + - ", banditParamsJson=" + Arrays.toString(banditParamsJson) + - '}'; + return "Configuration{" + + "banditReferences=" + + banditReferences + + ", flags=" + + flags + + ", bandits=" + + bandits + + ", isConfigObfuscated=" + + isConfigObfuscated + + ", environmentName='" + + environmentName + + '\'' + + ", configFetchedAt=" + + configFetchedAt + + ", configPublishedAt=" + + configPublishedAt + + ", flagConfigJson=" + + Arrays.toString(flagConfigJson) + + ", banditParamsJson=" + + Arrays.toString(banditParamsJson) + + '}'; } @Override @@ -126,16 +152,28 @@ public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; Configuration that = (Configuration) o; return isConfigObfuscated == that.isConfigObfuscated - && Objects.equals(banditReferences, that.banditReferences) - && Objects.equals(flags, that.flags) - && Objects.equals(bandits, that.bandits) - && Objects.deepEquals(flagConfigJson, that.flagConfigJson) - && Objects.deepEquals(banditParamsJson, that.banditParamsJson); + && Objects.equals(banditReferences, that.banditReferences) + && Objects.equals(flags, that.flags) + && Objects.equals(bandits, that.bandits) + && Objects.equals(environmentName, that.environmentName) + && Objects.equals(configFetchedAt, that.configFetchedAt) + && Objects.equals(configPublishedAt, that.configPublishedAt) + && Objects.deepEquals(flagConfigJson, that.flagConfigJson) + && Objects.deepEquals(banditParamsJson, that.banditParamsJson); } @Override public int hashCode() { - return Objects.hash(banditReferences, flags, bandits, isConfigObfuscated, Arrays.hashCode(flagConfigJson), Arrays.hashCode(banditParamsJson)); + return Objects.hash( + banditReferences, + flags, + bandits, + isConfigObfuscated, + environmentName, + configFetchedAt, + configPublishedAt, + Arrays.hashCode(flagConfigJson), + Arrays.hashCode(banditParamsJson)); } public FlagConfig getFlag(String flagKey) { @@ -205,6 +243,18 @@ public Set getFlagKeys() { return flags == null ? Collections.emptySet() : flags.keySet(); } + public String getEnvironmentName() { + return environmentName; + } + + public Date getConfigFetchedAt() { + return configFetchedAt; + } + + public Date getConfigPublishedAt() { + return configPublishedAt; + } + public static Builder builder(byte[] flagJson) { return new Builder(flagJson); } @@ -226,6 +276,8 @@ public static class Builder { private Map bandits = Collections.emptyMap(); private final byte[] flagJson; private byte[] banditParamsJson; + private final String environmentName; + private final Date configPublishedAt; private static FlagConfigResponse parseFlagResponse(byte[] flagJson) { if (flagJson == null || flagJson.length == 0) { @@ -274,9 +326,16 @@ public Builder( log.warn("'flags' map missing in flag definition JSON"); flags = Collections.emptyMap(); banditReferences = Collections.emptyMap(); + environmentName = null; + configPublishedAt = null; } else { flags = Collections.unmodifiableMap(flagConfigResponse.getFlags()); banditReferences = Collections.unmodifiableMap(flagConfigResponse.getBanditReferences()); + + // Extract environment name and published at timestamp from the response + environmentName = flagConfigResponse.getEnvironmentName(); + configPublishedAt = flagConfigResponse.getCreatedAt(); + log.debug("Loaded {} flag definitions from flag definition JSON", flags.size()); } } @@ -337,8 +396,18 @@ public Builder banditParameters(byte[] banditParameterJson) { } public Configuration build() { + // Record the time when configuration is built/fetched + Date configFetchedAt = new Date(); return new Configuration( - flags, banditReferences, bandits, isConfigObfuscated, flagJson, banditParamsJson); + flags, + banditReferences, + bandits, + isConfigObfuscated, + environmentName, + configFetchedAt, + configPublishedAt, + flagJson, + banditParamsJson); } } } diff --git a/src/main/java/cloud/eppo/ufc/dto/FlagConfigResponse.java b/src/main/java/cloud/eppo/ufc/dto/FlagConfigResponse.java index cb9e35ba..118cffd9 100644 --- a/src/main/java/cloud/eppo/ufc/dto/FlagConfigResponse.java +++ b/src/main/java/cloud/eppo/ufc/dto/FlagConfigResponse.java @@ -1,5 +1,6 @@ package cloud.eppo.ufc.dto; +import java.util.Date; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; @@ -8,32 +9,53 @@ public class FlagConfigResponse { private final Map flags; private final Map banditReferences; private final Format format; + private final String environmentName; + private final Date createdAt; public FlagConfigResponse( Map flags, Map banditReferences, - Format dataFormat) { + Format dataFormat, + String environmentName, + Date createdAt) { this.flags = flags; this.banditReferences = banditReferences; - format = dataFormat; + this.format = dataFormat; + this.environmentName = environmentName; + this.createdAt = createdAt; + } + + public FlagConfigResponse( + Map flags, + Map banditReferences, + Format dataFormat) { + this(flags, banditReferences, dataFormat, null, null); } public FlagConfigResponse( Map flags, Map banditReferences) { - this(flags, banditReferences, Format.SERVER); + this(flags, banditReferences, Format.SERVER, null, null); } public FlagConfigResponse() { - this(new ConcurrentHashMap<>(), new ConcurrentHashMap<>(), Format.SERVER); + this(new ConcurrentHashMap<>(), new ConcurrentHashMap<>(), Format.SERVER, null, null); } @Override public String toString() { - return "FlagConfigResponse{" + - "flags=" + flags + - ", banditReferences=" + banditReferences + - ", format=" + format + - '}'; + return "FlagConfigResponse{" + + "flags=" + + flags + + ", banditReferences=" + + banditReferences + + ", format=" + + format + + ", environmentName='" + + environmentName + + '\'' + + ", createdAt=" + + createdAt + + '}'; } @Override @@ -41,13 +63,15 @@ public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; FlagConfigResponse that = (FlagConfigResponse) o; return Objects.equals(flags, that.flags) - && Objects.equals(banditReferences, that.banditReferences) - && format == that.format; + && Objects.equals(banditReferences, that.banditReferences) + && format == that.format + && Objects.equals(environmentName, that.environmentName) + && Objects.equals(createdAt, that.createdAt); } @Override public int hashCode() { - return Objects.hash(flags, banditReferences, format); + return Objects.hash(flags, banditReferences, format, environmentName, createdAt); } public Map getFlags() { @@ -62,6 +86,14 @@ public Format getFormat() { return format; } + public String getEnvironmentName() { + return environmentName; + } + + public Date getCreatedAt() { + return createdAt; + } + public enum Format { SERVER, CLIENT 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..e63aa1fe 100644 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java +++ b/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java @@ -5,7 +5,6 @@ import cloud.eppo.api.EppoValue; import cloud.eppo.model.ShardRange; import cloud.eppo.ufc.dto.*; -import com.fasterxml.jackson.core.JacksonException; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonNode; @@ -35,7 +34,7 @@ public FlagConfigResponseDeserializer() { @Override public FlagConfigResponse deserialize(JsonParser jp, DeserializationContext ctxt) - throws IOException, JacksonException { + throws IOException { JsonNode rootNode = jp.getCodec().readTree(jp); if (rootNode == null || !rootNode.isObject()) { @@ -55,6 +54,19 @@ public FlagConfigResponse deserialize(JsonParser jp, DeserializationContext ctxt ? FlagConfigResponse.Format.SERVER : FlagConfigResponse.Format.valueOf(formatNode.asText()); + // Parse environment name from environment object + String environmentName = null; + JsonNode environmentNode = rootNode.get("environment"); + if (environmentNode != null && environmentNode.isObject()) { + JsonNode nameNode = environmentNode.get("name"); + if (nameNode != null) { + environmentName = nameNode.asText(); + } + } + + // Parse createdAt + Date createdAt = parseUtcISODateNode(rootNode.get("createdAt")); + Map flags = new ConcurrentHashMap<>(); flagsNode @@ -81,7 +93,7 @@ public FlagConfigResponse deserialize(JsonParser jp, DeserializationContext ctxt } } - return new FlagConfigResponse(flags, banditReferences, dataFormat); + return new FlagConfigResponse(flags, banditReferences, dataFormat, environmentName, createdAt); } private FlagConfig deserializeFlag(JsonNode jsonNode) { diff --git a/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java b/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java index 54360033..471dc430 100644 --- a/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java +++ b/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java @@ -73,7 +73,16 @@ public void getFlagType_shouldReturnCorrectType() { // Create configuration with this flag Map flags = Collections.singletonMap("test-flag", flagConfig); Configuration config = - new Configuration(flags, Collections.emptyMap(), Collections.emptyMap(), false, null, null); + new Configuration( + flags, + Collections.emptyMap(), + Collections.emptyMap(), + false, + null, // environmentName + null, // configFetchedAt + null, // configPublishedAt + null, // flagConfigJson + null); // banditParamsJson // Test successful case assertEquals(VariationType.STRING, config.getFlagType("test-flag")); @@ -103,8 +112,11 @@ public void getFlagType_withObfuscatedConfig_shouldReturnCorrectType() { Collections.emptyMap(), Collections.emptyMap(), true, // obfuscated - null, - null); + null, // environmentName + null, // configFetchedAt + null, // configPublishedAt + null, // flagConfigJson + null); // banditParamsJson // Test successful case with obfuscated config assertEquals(VariationType.NUMERIC, config.getFlagType("test-flag")); From 0e6cc9b7205a821fc5388b042fbb6e9c609ce5d0 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Tue, 9 Dec 2025 10:40:15 -0500 Subject: [PATCH 2/3] appease linter --- src/main/java/cloud/eppo/api/EvaluationDetails.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/cloud/eppo/api/EvaluationDetails.java b/src/main/java/cloud/eppo/api/EvaluationDetails.java index ab211d7f..152ed2d1 100644 --- a/src/main/java/cloud/eppo/api/EvaluationDetails.java +++ b/src/main/java/cloud/eppo/api/EvaluationDetails.java @@ -24,7 +24,6 @@ public class EvaluationDetails { private final List unmatchedAllocations; private final List unevaluatedAllocations; - public EvaluationDetails( String environmentName, Date configFetchedAt, From dbe9ef8006b302ad4522e53fb82e70e042f53a40 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Tue, 9 Dec 2025 22:34:57 -0500 Subject: [PATCH 3/3] additional tests --- .../java/cloud/eppo/api/Configuration.java | 4 +- .../eppo/api/ConfigurationBuilderTest.java | 105 ++++++++++++++++++ .../FlagConfigResponseDeserializerTest.java | 67 +++++++++++ 3 files changed, 173 insertions(+), 3 deletions(-) diff --git a/src/main/java/cloud/eppo/api/Configuration.java b/src/main/java/cloud/eppo/api/Configuration.java index a777c400..a1fb674d 100644 --- a/src/main/java/cloud/eppo/api/Configuration.java +++ b/src/main/java/cloud/eppo/api/Configuration.java @@ -320,9 +320,7 @@ public Builder( boolean isConfigObfuscated) { this.isConfigObfuscated = isConfigObfuscated; this.flagJson = flagJson; - if (flagConfigResponse == null - || flagConfigResponse.getFlags() == null - || flagConfigResponse.getFlags().isEmpty()) { + if (flagConfigResponse == null || flagConfigResponse.getFlags() == null) { log.warn("'flags' map missing in flag definition JSON"); flags = Collections.emptyMap(); banditReferences = Collections.emptyMap(); diff --git a/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java b/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java index 471dc430..9be43ce3 100644 --- a/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java +++ b/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java @@ -9,8 +9,11 @@ import cloud.eppo.ufc.dto.adapters.EppoModule; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; +import java.text.SimpleDateFormat; import java.util.Collections; +import java.util.Date; import java.util.Map; +import java.util.TimeZone; import org.junit.jupiter.api.Test; public class ConfigurationBuilderTest { @@ -131,4 +134,106 @@ public void getFlagType_withEmptyConfig_shouldReturnNull() { Configuration config = Configuration.emptyConfig(); assertNull(config.getFlagType("any-flag")); } + + @Test + public void testEnvironmentNameParsedFromJson() { + // Environment name is nested inside an "environment" object + String json = + "{ \"flags\": {}, \"environment\": { \"name\": \"Production\" }, \"createdAt\": \"2024-01-01T00:00:00.000Z\" }"; + Configuration config = new Configuration.Builder(json.getBytes()).build(); + + assertEquals("Production", config.getEnvironmentName()); + } + + @Test + public void testEnvironmentNameNullWhenNotInJson() { + // When flags are present but no environment object + String json = "{ \"flags\": {} }"; + Configuration config = new Configuration.Builder(json.getBytes()).build(); + + assertNull(config.getEnvironmentName()); + } + + @Test + public void testConfigPublishedAtParsedFromCreatedAt() throws Exception { + String json = "{ \"flags\": {}, \"createdAt\": \"2024-04-17T19:40:53.716Z\" }"; + Configuration config = new Configuration.Builder(json.getBytes()).build(); + + // configPublishedAt should be set from the createdAt field in the JSON + Date publishedAt = config.getConfigPublishedAt(); + assertNotNull(publishedAt, "configPublishedAt should be set from createdAt"); + + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); + sdf.setTimeZone(TimeZone.getTimeZone("UTC")); + Date expectedDate = sdf.parse("2024-04-17T19:40:53.716Z"); + assertEquals(expectedDate, publishedAt); + } + + @Test + public void testConfigPublishedAtNullWhenCreatedAtNotInJson() { + String json = "{ \"flags\": {} }"; + Configuration config = new Configuration.Builder(json.getBytes()).build(); + + assertNull(config.getConfigPublishedAt()); + } + + @Test + public void testConfigFetchedAtSetOnBuild() throws InterruptedException { + String json = "{ \"flags\": {} }"; + + Date beforeBuild = new Date(); + // Small sleep to ensure time difference is measurable + Thread.sleep(10); + + Configuration config = new Configuration.Builder(json.getBytes()).build(); + + Thread.sleep(10); + Date afterBuild = new Date(); + + Date fetchedAt = config.getConfigFetchedAt(); + assertNotNull(fetchedAt, "configFetchedAt should be set when build() is called"); + + // fetchedAt should be between beforeBuild and afterBuild + assertFalse( + fetchedAt.before(beforeBuild), + "configFetchedAt should not be before the build was started"); + assertFalse( + fetchedAt.after(afterBuild), "configFetchedAt should not be after the build completed"); + } + + @Test + public void testAllMetadataFieldsTogether() throws Exception { + String json = + "{ \"flags\": {}, \"environment\": { \"name\": \"Staging\" }, \"createdAt\": \"2024-06-15T12:30:00.000Z\", \"format\": \"SERVER\" }"; + + Date beforeBuild = new Date(); + Configuration config = new Configuration.Builder(json.getBytes()).build(); + Date afterBuild = new Date(); + + // Verify environmentName + assertEquals("Staging", config.getEnvironmentName()); + + // Verify configPublishedAt (from createdAt) + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); + sdf.setTimeZone(TimeZone.getTimeZone("UTC")); + Date expectedPublishedAt = sdf.parse("2024-06-15T12:30:00.000Z"); + assertEquals(expectedPublishedAt, config.getConfigPublishedAt()); + + // Verify configFetchedAt is set at build time + assertNotNull(config.getConfigFetchedAt()); + assertFalse(config.getConfigFetchedAt().before(beforeBuild)); + assertFalse(config.getConfigFetchedAt().after(afterBuild)); + + // Verify obfuscation setting + assertFalse(config.isConfigObfuscated()); + } + + @Test + public void testEmptyConfigHasNullMetadata() { + Configuration config = Configuration.emptyConfig(); + + assertNull(config.getEnvironmentName()); + assertNull(config.getConfigFetchedAt()); + assertNull(config.getConfigPublishedAt()); + } } diff --git a/src/test/java/cloud/eppo/ufc/deserializer/FlagConfigResponseDeserializerTest.java b/src/test/java/cloud/eppo/ufc/deserializer/FlagConfigResponseDeserializerTest.java index 24105543..087491e6 100644 --- a/src/test/java/cloud/eppo/ufc/deserializer/FlagConfigResponseDeserializerTest.java +++ b/src/test/java/cloud/eppo/ufc/deserializer/FlagConfigResponseDeserializerTest.java @@ -9,9 +9,12 @@ import java.io.File; import java.io.FileReader; import java.io.IOException; +import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Date; import java.util.List; import java.util.Map; +import java.util.TimeZone; import org.junit.jupiter.api.Test; public class FlagConfigResponseDeserializerTest { @@ -114,4 +117,68 @@ public void testDeserializePlainText() throws IOException { assertEquals("off", offForAllSplit.getVariationKey()); assertEquals(0, offForAllSplit.getShards().size()); } + + @Test + public void testDeserializeCreatedAt() throws Exception { + File testUfc = new File("src/test/resources/flags-v1.json"); + FileReader fileReader = new FileReader(testUfc); + FlagConfigResponse configResponse = mapper.readValue(fileReader, FlagConfigResponse.class); + + // Verify createdAt is parsed correctly + Date createdAt = configResponse.getCreatedAt(); + assertNotNull(createdAt, "createdAt should be parsed from the JSON"); + + // The test file has "createdAt": "2024-04-17T19:40:53.716Z" + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); + sdf.setTimeZone(TimeZone.getTimeZone("UTC")); + Date expectedDate = sdf.parse("2024-04-17T19:40:53.716Z"); + assertEquals(expectedDate, createdAt); + } + + @Test + public void testDeserializeEnvironmentName() throws IOException { + // Test with environment.name present (nested structure) + String jsonWithEnv = + "{ \"flags\": {}, \"environment\": { \"name\": \"Production\" }, \"createdAt\": \"2024-01-01T00:00:00.000Z\" }"; + FlagConfigResponse configWithEnv = mapper.readValue(jsonWithEnv, FlagConfigResponse.class); + assertEquals("Production", configWithEnv.getEnvironmentName()); + + // Test without environment (should be null) + String jsonWithoutEnv = "{ \"flags\": {} }"; + FlagConfigResponse configWithoutEnv = + mapper.readValue(jsonWithoutEnv, FlagConfigResponse.class); + assertNull(configWithoutEnv.getEnvironmentName()); + + // Test with environment object but no name field + String jsonWithEmptyEnv = "{ \"flags\": {}, \"environment\": {} }"; + FlagConfigResponse configWithEmptyEnv = + mapper.readValue(jsonWithEmptyEnv, FlagConfigResponse.class); + assertNull(configWithEmptyEnv.getEnvironmentName()); + } + + @Test + public void testDeserializeFormat() throws IOException { + // Test SERVER format + String serverJson = "{ \"flags\": {}, \"format\": \"SERVER\" }"; + FlagConfigResponse serverConfig = mapper.readValue(serverJson, FlagConfigResponse.class); + assertEquals(FlagConfigResponse.Format.SERVER, serverConfig.getFormat()); + + // Test CLIENT format + String clientJson = "{ \"flags\": {}, \"format\": \"CLIENT\" }"; + FlagConfigResponse clientConfig = mapper.readValue(clientJson, FlagConfigResponse.class); + assertEquals(FlagConfigResponse.Format.CLIENT, clientConfig.getFormat()); + + // Test default (no format specified) - should default to SERVER + String noFormatJson = "{ \"flags\": {} }"; + FlagConfigResponse noFormatConfig = mapper.readValue(noFormatJson, FlagConfigResponse.class); + assertEquals(FlagConfigResponse.Format.SERVER, noFormatConfig.getFormat()); + } + + @Test + public void testDeserializeNullCreatedAt() throws IOException { + // Test without createdAt + String jsonWithoutCreatedAt = "{ \"flags\": {} }"; + FlagConfigResponse config = mapper.readValue(jsonWithoutCreatedAt, FlagConfigResponse.class); + assertNull(config.getCreatedAt()); + } }