From 80d9336ebef4521edb3dcdd4e0072c20671f5223 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Mon, 9 Feb 2026 13:57:14 -0700 Subject: [PATCH 01/44] feat: request, response, client, factory --- .../eppo/http/EppoConfigurationClient.java | 24 +++++ .../eppo/http/EppoConfigurationRequest.java | 79 +++++++++++++++ .../http/EppoConfigurationRequestFactory.java | 63 ++++++++++++ .../eppo/http/EppoConfigurationResponse.java | 99 +++++++++++++++++++ .../parser/ConfigurationParseException.java | 29 ++++++ .../eppo/parser/ConfigurationParser.java | 58 +++++++++++ .../EppoConfigurationRequestFactoryTest.java | 73 ++++++++++++++ 7 files changed, 425 insertions(+) create mode 100644 src/main/java/cloud/eppo/http/EppoConfigurationClient.java create mode 100644 src/main/java/cloud/eppo/http/EppoConfigurationRequest.java create mode 100644 src/main/java/cloud/eppo/http/EppoConfigurationRequestFactory.java create mode 100644 src/main/java/cloud/eppo/http/EppoConfigurationResponse.java create mode 100644 src/main/java/cloud/eppo/parser/ConfigurationParseException.java create mode 100644 src/main/java/cloud/eppo/parser/ConfigurationParser.java create mode 100644 src/test/java/cloud/eppo/http/EppoConfigurationRequestFactoryTest.java diff --git a/src/main/java/cloud/eppo/http/EppoConfigurationClient.java b/src/main/java/cloud/eppo/http/EppoConfigurationClient.java new file mode 100644 index 00000000..ef6638ae --- /dev/null +++ b/src/main/java/cloud/eppo/http/EppoConfigurationClient.java @@ -0,0 +1,24 @@ +package cloud.eppo.http; + +import java.util.concurrent.CompletableFuture; + +/** + * Interface for HTTP client implementations used by the Eppo SDK. + * + *

Implementations of this interface handle all HTTP communication with Eppo's servers. The SDK + * provides a default implementation using OkHttp in the eppo-sdk-common module, but custom + * implementations can be provided for specialized use cases. + */ +public interface EppoConfigurationClient { + + /** + * Performs an asynchronous GET request. + * + *

For synchronous behavior, callers can use {@code .get()} or {@code .join()} on the returned + * CompletableFuture. + * + * @param request the request to execute + * @return a CompletableFuture that will complete with the response + */ + CompletableFuture get(EppoConfigurationRequest request); +} diff --git a/src/main/java/cloud/eppo/http/EppoConfigurationRequest.java b/src/main/java/cloud/eppo/http/EppoConfigurationRequest.java new file mode 100644 index 00000000..a6c3bbca --- /dev/null +++ b/src/main/java/cloud/eppo/http/EppoConfigurationRequest.java @@ -0,0 +1,79 @@ +package cloud.eppo.http; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Represents a configuration request to be executed by an {@link EppoConfigurationClient}. + * + *

This class is immutable. HTTP client implementations are responsible for combining the base + * URL, resource path, and query parameters using their library's URL building utilities. + */ +public final class EppoConfigurationRequest { + + private final String baseUrl; + private final String resourcePath; + private final Map queryParams; + private final String lastVersionId; + + /** + * Creates a new configuration request. + * + * @param baseUrl the base URL (e.g., "https://fscdn.eppo.cloud") + * @param resourcePath the resource path (e.g., "/api/flag-config/v1/config") + * @param queryParams query parameters to append to the URL + * @param lastVersionId the last known version ID for conditional requests, or null + */ + public EppoConfigurationRequest( + @NotNull String baseUrl, + @NotNull String resourcePath, + @NotNull Map queryParams, + @Nullable String lastVersionId) { + this.baseUrl = baseUrl; + this.resourcePath = resourcePath; + this.queryParams = Collections.unmodifiableMap(new LinkedHashMap<>(queryParams)); + this.lastVersionId = lastVersionId; + } + + /** + * Returns the base URL for the request. + * + * @return the base URL (e.g., "https://fscdn.eppo.cloud") + */ + public String getBaseUrl() { + return baseUrl; + } + + /** + * Returns the resource path for the request. + * + * @return the resource path (e.g., "/api/flag-config/v1/config") + */ + public String getResourcePath() { + return resourcePath; + } + + /** + * Returns the query parameters to append to the URL. + * + * @return an unmodifiable map of query parameters + */ + public Map getQueryParams() { + return queryParams; + } + + /** + * Returns the last known version identifier for conditional requests. + * + *

When set, the HTTP client should include an "If-None-Match" header with this value. If the + * server's current version matches, a 304 Not Modified response will be returned. + * + * @return the last version ID, or null if not set + */ + @Nullable public String getLastVersionId() { + return lastVersionId; + } +} diff --git a/src/main/java/cloud/eppo/http/EppoConfigurationRequestFactory.java b/src/main/java/cloud/eppo/http/EppoConfigurationRequestFactory.java new file mode 100644 index 00000000..1b43751b --- /dev/null +++ b/src/main/java/cloud/eppo/http/EppoConfigurationRequestFactory.java @@ -0,0 +1,63 @@ +package cloud.eppo.http; + +import cloud.eppo.Constants; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Factory for creating configuration requests. + * + *

This factory encapsulates the logic for constructing requests for fetching flag configurations + * and bandit parameters from the Eppo API. + */ +public class EppoConfigurationRequestFactory { + + private final String baseUrl; + private final Map sdkQueryParams; + + /** + * Creates a new request factory. + * + * @param baseUrl the base URL for API requests + * @param apiKey the API key to include in requests + * @param sdkName the SDK name to include in requests + * @param sdkVersion the SDK version to include in requests + */ + public EppoConfigurationRequestFactory( + @NotNull String baseUrl, + @NotNull String apiKey, + @NotNull String sdkName, + @NotNull String sdkVersion) { + this.baseUrl = baseUrl; + + Map params = new LinkedHashMap<>(); + params.put("apiKey", apiKey); + params.put("sdkName", sdkName); + params.put("sdkVersion", sdkVersion); + this.sdkQueryParams = Collections.unmodifiableMap(params); + } + + /** + * Creates a request for fetching flag configuration. + * + * @param lastVersionId optional version identifier for conditional fetch (304 Not Modified + * support). If the server's current version matches, a 304 response will be returned. + * @return the configured request + */ + public EppoConfigurationRequest createFlagConfigRequest(@Nullable String lastVersionId) { + return new EppoConfigurationRequest( + baseUrl, Constants.FLAG_CONFIG_ENDPOINT, sdkQueryParams, lastVersionId); + } + + /** + * Creates a request for fetching bandit parameters. + * + * @return the configured request + */ + public EppoConfigurationRequest createBanditParamsRequest() { + return new EppoConfigurationRequest(baseUrl, Constants.BANDIT_ENDPOINT, sdkQueryParams, null); + } +} diff --git a/src/main/java/cloud/eppo/http/EppoConfigurationResponse.java b/src/main/java/cloud/eppo/http/EppoConfigurationResponse.java new file mode 100644 index 00000000..5cc8f083 --- /dev/null +++ b/src/main/java/cloud/eppo/http/EppoConfigurationResponse.java @@ -0,0 +1,99 @@ +package cloud.eppo.http; + +/** + * Represents a configuration response from an {@link EppoConfigurationClient}. + * + *

This class is immutable and provides factory methods for common response types. + */ +public final class EppoConfigurationResponse { + + private final int statusCode; + private final String versionId; + private final byte[] body; + + private EppoConfigurationResponse(int statusCode, String versionId, byte[] body) { + this.statusCode = statusCode; + this.versionId = versionId; + this.body = body; + } + + /** + * Creates a successful response. + * + * @param statusCode the HTTP status code (2xx) + * @param versionId the version identifier for this configuration, or null if not present + * @param body the response body + * @return a new successful response + */ + public static EppoConfigurationResponse success(int statusCode, String versionId, byte[] body) { + return new EppoConfigurationResponse(statusCode, versionId, body); + } + + /** + * Creates a 304 Not Modified response. + * + * @param versionId the version identifier, or null if not present + * @return a new not modified response + */ + public static EppoConfigurationResponse notModified(String versionId) { + return new EppoConfigurationResponse(304, versionId, null); + } + + /** + * Creates an error response. + * + * @param statusCode the HTTP status code (4xx or 5xx) + * @param body the error response body, or null + * @return a new error response + */ + public static EppoConfigurationResponse error(int statusCode, byte[] body) { + return new EppoConfigurationResponse(statusCode, null, body); + } + + /** + * Returns the HTTP status code. + * + * @return the status code + */ + public int getStatusCode() { + return statusCode; + } + + /** + * Returns the version identifier for this configuration. + * + *

This can be used in subsequent requests to enable conditional fetching (304 Not Modified). + * + * @return the version ID, or null if not present + */ + public String getVersionId() { + return versionId; + } + + /** + * Returns the response body. + * + * @return the body bytes, or null for 304 responses + */ + public byte[] getBody() { + return body; + } + + /** + * Checks if this is a 304 Not Modified response. + * + * @return true if the status code is 304 + */ + public boolean isNotModified() { + return statusCode == 304; + } + + /** + * Checks if this is a successful response (2xx status code). + * + * @return true if the status code is between 200 and 299 + */ + public boolean isSuccessful() { + return statusCode >= 200 && statusCode < 300; + } +} diff --git a/src/main/java/cloud/eppo/parser/ConfigurationParseException.java b/src/main/java/cloud/eppo/parser/ConfigurationParseException.java new file mode 100644 index 00000000..552bde30 --- /dev/null +++ b/src/main/java/cloud/eppo/parser/ConfigurationParseException.java @@ -0,0 +1,29 @@ +package cloud.eppo.parser; + +/** + * Exception thrown when configuration parsing fails. + * + *

This exception is thrown by {@link ConfigurationParser} implementations when JSON + * deserialization or serialization fails. + */ +public class ConfigurationParseException extends RuntimeException { + + /** + * Creates a new parse exception with a message. + * + * @param message the error message + */ + public ConfigurationParseException(String message) { + super(message); + } + + /** + * Creates a new parse exception with a message and cause. + * + * @param message the error message + * @param cause the underlying cause + */ + public ConfigurationParseException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/cloud/eppo/parser/ConfigurationParser.java b/src/main/java/cloud/eppo/parser/ConfigurationParser.java new file mode 100644 index 00000000..3f228e56 --- /dev/null +++ b/src/main/java/cloud/eppo/parser/ConfigurationParser.java @@ -0,0 +1,58 @@ +package cloud.eppo.parser; + +import cloud.eppo.api.dto.BanditParameters; +import cloud.eppo.api.dto.FlagConfigResponse; +import java.util.Map; + +/** + * Interface for parsing configuration JSON responses. + * + *

Implementations of this interface handle deserialization of flag configuration and bandit + * parameters from raw JSON bytes. The SDK provides a default implementation using Jackson in the + * eppo-sdk-common module. + */ +public interface ConfigurationParser { + + /** + * Parses raw flag configuration JSON bytes. + * + * @param flagConfigJson raw JSON bytes for flag configuration + * @return parsed FlagConfigResponse containing flags, bandit references, format, etc. + * @throws ConfigurationParseException if parsing fails + */ + FlagConfigResponse parseFlagConfig(byte[] flagConfigJson) throws ConfigurationParseException; + + /** + * Parses raw bandit parameters JSON bytes. + * + * @param banditParamsJson raw JSON bytes for bandit parameters + * @return map of bandit key to BanditParameters + * @throws ConfigurationParseException if parsing fails + */ + Map parseBanditParams(byte[] banditParamsJson) + throws ConfigurationParseException; + + /** + * Serializes a FlagConfigResponse to JSON bytes. + * + *

This is used for caching and debugging purposes. + * + * @param flagConfigResponse the response to serialize + * @return JSON bytes representing the flag configuration + * @throws ConfigurationParseException if serialization fails + */ + byte[] serializeFlagConfig(FlagConfigResponse flagConfigResponse) + throws ConfigurationParseException; + + /** + * Serializes bandit parameters to JSON bytes. + * + *

This is used for caching and debugging purposes. + * + * @param banditParams map of bandit key to BanditParameters + * @return JSON bytes representing the bandit parameters + * @throws ConfigurationParseException if serialization fails + */ + byte[] serializeBanditParams(Map banditParams) + throws ConfigurationParseException; +} diff --git a/src/test/java/cloud/eppo/http/EppoConfigurationRequestFactoryTest.java b/src/test/java/cloud/eppo/http/EppoConfigurationRequestFactoryTest.java new file mode 100644 index 00000000..9d935612 --- /dev/null +++ b/src/test/java/cloud/eppo/http/EppoConfigurationRequestFactoryTest.java @@ -0,0 +1,73 @@ +package cloud.eppo.http; + +import static org.junit.jupiter.api.Assertions.*; + +import cloud.eppo.Constants; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class EppoConfigurationRequestFactoryTest { + + private static final String TEST_API_KEY = "test-api-key"; + private static final String TEST_SDK_NAME = "java-server-sdk"; + private static final String TEST_SDK_VERSION = "1.0.0"; + private EppoConfigurationRequestFactory factory; + + @BeforeEach + void setUp() { + factory = + new EppoConfigurationRequestFactory( + Constants.DEFAULT_BASE_URL, TEST_API_KEY, TEST_SDK_NAME, TEST_SDK_VERSION); + } + + @Test + void testCreateFlagConfigRequestWithoutVersionId() { + EppoConfigurationRequest request = factory.createFlagConfigRequest(null); + + assertNotNull(request); + assertEquals(Constants.DEFAULT_BASE_URL, request.getBaseUrl()); + assertEquals(Constants.FLAG_CONFIG_ENDPOINT, request.getResourcePath()); + assertEquals(TEST_API_KEY, request.getQueryParams().get("apiKey")); + assertEquals(TEST_SDK_NAME, request.getQueryParams().get("sdkName")); + assertEquals(TEST_SDK_VERSION, request.getQueryParams().get("sdkVersion")); + assertEquals(3, request.getQueryParams().size()); + assertNull(request.getLastVersionId()); + } + + @Test + void testCreateFlagConfigRequestWithVersionId() { + String versionId = "abc123"; + EppoConfigurationRequest request = factory.createFlagConfigRequest(versionId); + + assertNotNull(request); + assertEquals(Constants.DEFAULT_BASE_URL, request.getBaseUrl()); + assertEquals(Constants.FLAG_CONFIG_ENDPOINT, request.getResourcePath()); + assertEquals(versionId, request.getLastVersionId()); + } + + @Test + void testCreateBanditParamsRequest() { + EppoConfigurationRequest request = factory.createBanditParamsRequest(); + + assertNotNull(request); + assertEquals(Constants.DEFAULT_BASE_URL, request.getBaseUrl()); + assertEquals(Constants.BANDIT_ENDPOINT, request.getResourcePath()); + assertEquals(TEST_API_KEY, request.getQueryParams().get("apiKey")); + assertEquals(TEST_SDK_NAME, request.getQueryParams().get("sdkName")); + assertEquals(TEST_SDK_VERSION, request.getQueryParams().get("sdkVersion")); + assertEquals(3, request.getQueryParams().size()); + assertNull(request.getLastVersionId()); + } + + @Test + void testFactoryWithCustomBaseUrl() { + String customBaseUrl = "https://custom.example.com"; + EppoConfigurationRequestFactory customFactory = + new EppoConfigurationRequestFactory( + customBaseUrl, TEST_API_KEY, TEST_SDK_NAME, TEST_SDK_VERSION); + + EppoConfigurationRequest request = customFactory.createFlagConfigRequest(null); + assertEquals(customBaseUrl, request.getBaseUrl()); + assertEquals(Constants.FLAG_CONFIG_ENDPOINT, request.getResourcePath()); + } +} From 67876be6f6f9fc908c199cee50c53af57b377f91 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 11 Feb 2026 10:19:10 -0700 Subject: [PATCH 02/44] return BanditParamsResponse from the parser and create a config builder to take same --- .../java/cloud/eppo/api/Configuration.java | 11 ++++ .../eppo/parser/ConfigurationParser.java | 11 ++-- .../eppo/api/ConfigurationBuilderTest.java | 56 +++++++++++++++++++ 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/main/java/cloud/eppo/api/Configuration.java b/src/main/java/cloud/eppo/api/Configuration.java index c054432f..57071830 100644 --- a/src/main/java/cloud/eppo/api/Configuration.java +++ b/src/main/java/cloud/eppo/api/Configuration.java @@ -376,6 +376,17 @@ public Builder banditParametersFromConfig(Configuration currentConfig) { return this; } + public Builder banditParameters(BanditParametersResponse banditParametersResponse) { + if (banditParametersResponse == null || banditParametersResponse.getBandits() == null) { + log.debug("Bandit parameters response is null or has no bandits"); + bandits = Collections.emptyMap(); + return this; + } + bandits = Collections.unmodifiableMap(banditParametersResponse.getBandits()); + log.debug("Loaded {} bandit models from bandit parameters response", bandits.size()); + return this; + } + public Builder banditParameters(String banditParameterJson) { return banditParameters(banditParameterJson.getBytes()); } diff --git a/src/main/java/cloud/eppo/parser/ConfigurationParser.java b/src/main/java/cloud/eppo/parser/ConfigurationParser.java index 3f228e56..a220fa4d 100644 --- a/src/main/java/cloud/eppo/parser/ConfigurationParser.java +++ b/src/main/java/cloud/eppo/parser/ConfigurationParser.java @@ -1,8 +1,7 @@ package cloud.eppo.parser; -import cloud.eppo.api.dto.BanditParameters; +import cloud.eppo.api.dto.BanditParametersResponse; import cloud.eppo.api.dto.FlagConfigResponse; -import java.util.Map; /** * Interface for parsing configuration JSON responses. @@ -26,10 +25,10 @@ public interface ConfigurationParser { * Parses raw bandit parameters JSON bytes. * * @param banditParamsJson raw JSON bytes for bandit parameters - * @return map of bandit key to BanditParameters + * @return parsed BanditParametersResponse containing bandit models * @throws ConfigurationParseException if parsing fails */ - Map parseBanditParams(byte[] banditParamsJson) + BanditParametersResponse parseBanditParams(byte[] banditParamsJson) throws ConfigurationParseException; /** @@ -49,10 +48,10 @@ byte[] serializeFlagConfig(FlagConfigResponse flagConfigResponse) * *

This is used for caching and debugging purposes. * - * @param banditParams map of bandit key to BanditParameters + * @param banditParamsResponse the bandit parameters response to serialize * @return JSON bytes representing the bandit parameters * @throws ConfigurationParseException if serialization fails */ - byte[] serializeBanditParams(Map banditParams) + byte[] serializeBanditParams(BanditParametersResponse banditParamsResponse) throws ConfigurationParseException; } diff --git a/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java b/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java index ca6d63ec..432f6cee 100644 --- a/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java +++ b/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java @@ -3,6 +3,9 @@ import static cloud.eppo.Utils.getMD5Hex; import static org.junit.jupiter.api.Assertions.*; +import cloud.eppo.api.dto.BanditModelData; +import cloud.eppo.api.dto.BanditParameters; +import cloud.eppo.api.dto.BanditParametersResponse; import cloud.eppo.api.dto.FlagConfig; import cloud.eppo.api.dto.FlagConfigResponse; import cloud.eppo.api.dto.VariationType; @@ -12,6 +15,7 @@ import java.text.SimpleDateFormat; import java.util.Collections; import java.util.Date; +import java.util.HashMap; import java.util.Map; import java.util.TimeZone; import org.junit.jupiter.api.Test; @@ -238,4 +242,56 @@ public void testEmptyConfigHasNullMetadata() { assertNull(config.getConfigFetchedAt()); assertNull(config.getConfigPublishedAt()); } + + @Test + public void testBanditParametersFromNullResponse() { + String json = "{ \"flags\": {} }"; + Configuration config = + new Configuration.Builder(json.getBytes()) + .banditParameters((BanditParametersResponse) null) + .build(); + + // Should not throw and bandit should not be found + assertNull(config.getBanditParameters("any-bandit")); + } + + @Test + public void testBanditParametersFromResponseWithNullBandits() { + // Create response with null bandits map (simulating edge case) + BanditParametersResponse response = new BanditParametersResponse.Default(null); + + String json = "{ \"flags\": {} }"; + Configuration config = + new Configuration.Builder(json.getBytes()).banditParameters(response).build(); + + // Should not throw and bandit should not be found + assertNull(config.getBanditParameters("any-bandit")); + } + + @Test + public void testBanditParametersFromResponseWithMultipleBandits() { + BanditModelData mockModelData = + new BanditModelData.Default(0.0, 1.0, 0.1, Collections.emptyMap()); + + BanditParameters bandit1 = + new BanditParameters.Default("bandit-1", new Date(), "falcon", "v1", mockModelData); + BanditParameters bandit2 = + new BanditParameters.Default( + "bandit-2", new Date(), "contextual-bandit", "v2", mockModelData); + + Map banditsMap = new HashMap<>(); + banditsMap.put("bandit-1", bandit1); + banditsMap.put("bandit-2", bandit2); + BanditParametersResponse response = new BanditParametersResponse.Default(banditsMap); + + String json = "{ \"flags\": {} }"; + Configuration config = + new Configuration.Builder(json.getBytes()).banditParameters(response).build(); + + // Verify both bandits are accessible + assertNotNull(config.getBanditParameters("bandit-1")); + assertNotNull(config.getBanditParameters("bandit-2")); + assertEquals("falcon", config.getBanditParameters("bandit-1").getModelName()); + assertEquals("contextual-bandit", config.getBanditParameters("bandit-2").getModelName()); + } } From 1428354627a27af55deb53d9ab8f9688c8aa0a9d Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 11 Feb 2026 11:16:08 -0700 Subject: [PATCH 03/44] drop serialize methods --- .../eppo/parser/ConfigurationParser.java | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/src/main/java/cloud/eppo/parser/ConfigurationParser.java b/src/main/java/cloud/eppo/parser/ConfigurationParser.java index a220fa4d..be418cf8 100644 --- a/src/main/java/cloud/eppo/parser/ConfigurationParser.java +++ b/src/main/java/cloud/eppo/parser/ConfigurationParser.java @@ -30,28 +30,4 @@ public interface ConfigurationParser { */ BanditParametersResponse parseBanditParams(byte[] banditParamsJson) throws ConfigurationParseException; - - /** - * Serializes a FlagConfigResponse to JSON bytes. - * - *

This is used for caching and debugging purposes. - * - * @param flagConfigResponse the response to serialize - * @return JSON bytes representing the flag configuration - * @throws ConfigurationParseException if serialization fails - */ - byte[] serializeFlagConfig(FlagConfigResponse flagConfigResponse) - throws ConfigurationParseException; - - /** - * Serializes bandit parameters to JSON bytes. - * - *

This is used for caching and debugging purposes. - * - * @param banditParamsResponse the bandit parameters response to serialize - * @return JSON bytes representing the bandit parameters - * @throws ConfigurationParseException if serialization fails - */ - byte[] serializeBanditParams(BanditParametersResponse banditParamsResponse) - throws ConfigurationParseException; } From f3cd3620cf21fd1bb23b0b082c5497da32dae4c6 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 11 Feb 2026 12:08:28 -0700 Subject: [PATCH 04/44] update javadocs --- src/main/java/cloud/eppo/api/Configuration.java | 2 -- .../java/cloud/eppo/http/EppoConfigurationClient.java | 10 ++++++---- .../java/cloud/eppo/parser/ConfigurationParser.java | 7 ++++--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/main/java/cloud/eppo/api/Configuration.java b/src/main/java/cloud/eppo/api/Configuration.java index 57071830..2d7db847 100644 --- a/src/main/java/cloud/eppo/api/Configuration.java +++ b/src/main/java/cloud/eppo/api/Configuration.java @@ -378,12 +378,10 @@ public Builder banditParametersFromConfig(Configuration currentConfig) { public Builder banditParameters(BanditParametersResponse banditParametersResponse) { if (banditParametersResponse == null || banditParametersResponse.getBandits() == null) { - log.debug("Bandit parameters response is null or has no bandits"); bandits = Collections.emptyMap(); return this; } bandits = Collections.unmodifiableMap(banditParametersResponse.getBandits()); - log.debug("Loaded {} bandit models from bandit parameters response", bandits.size()); return this; } diff --git a/src/main/java/cloud/eppo/http/EppoConfigurationClient.java b/src/main/java/cloud/eppo/http/EppoConfigurationClient.java index ef6638ae..69bd49c8 100644 --- a/src/main/java/cloud/eppo/http/EppoConfigurationClient.java +++ b/src/main/java/cloud/eppo/http/EppoConfigurationClient.java @@ -3,11 +3,13 @@ import java.util.concurrent.CompletableFuture; /** - * Interface for HTTP client implementations used by the Eppo SDK. + * Defines the contract for configuration clients utilized by the Eppo SDK. * - *

Implementations of this interface handle all HTTP communication with Eppo's servers. The SDK - * provides a default implementation using OkHttp in the eppo-sdk-common module, but custom - * implementations can be provided for specialized use cases. + *

+ * Implementations of this interface are responsible for all interactions with configuration endpoint servers, + * whether hosted by Eppo or others. The SDK includes a default implementation using OKHttp (in the eppo-sdk-common module), + * but users can supply custom implementations to accommodate specialized needs, such as custom logging, + * authentication, or networking requirements. */ public interface EppoConfigurationClient { diff --git a/src/main/java/cloud/eppo/parser/ConfigurationParser.java b/src/main/java/cloud/eppo/parser/ConfigurationParser.java index be418cf8..1375f424 100644 --- a/src/main/java/cloud/eppo/parser/ConfigurationParser.java +++ b/src/main/java/cloud/eppo/parser/ConfigurationParser.java @@ -4,11 +4,12 @@ import cloud.eppo.api.dto.FlagConfigResponse; /** - * Interface for parsing configuration JSON responses. + * Defines the contract for parsing configuration JSON responses. * *

Implementations of this interface handle deserialization of flag configuration and bandit - * parameters from raw JSON bytes. The SDK provides a default implementation using Jackson in the - * eppo-sdk-common module. + * parameters from raw JSON bytes. The SDK includes a default implementation using Jackson (in the + * eppo-sdk-common module), but users can supply custom implementations to accommodate specialized + * needs. */ public interface ConfigurationParser { From d4161e91b883c29ba66b4d437b601abc4bcf89ba Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 11 Feb 2026 12:10:01 -0700 Subject: [PATCH 05/44] lint --- .../java/cloud/eppo/http/EppoConfigurationClient.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/cloud/eppo/http/EppoConfigurationClient.java b/src/main/java/cloud/eppo/http/EppoConfigurationClient.java index 69bd49c8..e7c2b688 100644 --- a/src/main/java/cloud/eppo/http/EppoConfigurationClient.java +++ b/src/main/java/cloud/eppo/http/EppoConfigurationClient.java @@ -5,11 +5,11 @@ /** * Defines the contract for configuration clients utilized by the Eppo SDK. * - *

- * Implementations of this interface are responsible for all interactions with configuration endpoint servers, - * whether hosted by Eppo or others. The SDK includes a default implementation using OKHttp (in the eppo-sdk-common module), - * but users can supply custom implementations to accommodate specialized needs, such as custom logging, - * authentication, or networking requirements. + *

Implementations of this interface are responsible for all interactions with configuration + * endpoint servers, whether hosted by Eppo or others. The SDK includes a default implementation + * using OKHttp (in the eppo-sdk-common module), but users can supply custom implementations to + * accommodate specialized needs, such as custom logging, authentication, or networking + * requirements. */ public interface EppoConfigurationClient { From 9808cd76b854aabcfe6e698fdc6a3a8798456e2f Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 11 Feb 2026 15:39:06 -0700 Subject: [PATCH 06/44] Add nullability annotations to HTTP and parser interfaces - EppoConfigurationRequest: @NotNull on getters - EppoConfigurationRequestFactory: @NotNull on factory method returns - EppoConfigurationResponse: @Nullable/@NotNull on constructor, factory methods, and getters - ConfigurationParser: @NotNull on method params and returns - EppoConfigurationClient: @NotNull on get() method --- .../eppo/http/EppoConfigurationClient.java | 4 +++- .../eppo/http/EppoConfigurationRequest.java | 3 +++ .../http/EppoConfigurationRequestFactory.java | 2 ++ .../eppo/http/EppoConfigurationResponse.java | 18 ++++++++++++++---- .../cloud/eppo/parser/ConfigurationParser.java | 8 ++++++-- 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/main/java/cloud/eppo/http/EppoConfigurationClient.java b/src/main/java/cloud/eppo/http/EppoConfigurationClient.java index e7c2b688..de6b17a5 100644 --- a/src/main/java/cloud/eppo/http/EppoConfigurationClient.java +++ b/src/main/java/cloud/eppo/http/EppoConfigurationClient.java @@ -1,6 +1,7 @@ package cloud.eppo.http; import java.util.concurrent.CompletableFuture; +import org.jetbrains.annotations.NotNull; /** * Defines the contract for configuration clients utilized by the Eppo SDK. @@ -22,5 +23,6 @@ public interface EppoConfigurationClient { * @param request the request to execute * @return a CompletableFuture that will complete with the response */ - CompletableFuture get(EppoConfigurationRequest request); + @NotNull + CompletableFuture get(@NotNull EppoConfigurationRequest request); } diff --git a/src/main/java/cloud/eppo/http/EppoConfigurationRequest.java b/src/main/java/cloud/eppo/http/EppoConfigurationRequest.java index a6c3bbca..0fe7628b 100644 --- a/src/main/java/cloud/eppo/http/EppoConfigurationRequest.java +++ b/src/main/java/cloud/eppo/http/EppoConfigurationRequest.java @@ -43,6 +43,7 @@ public EppoConfigurationRequest( * * @return the base URL (e.g., "https://fscdn.eppo.cloud") */ + @NotNull public String getBaseUrl() { return baseUrl; } @@ -52,6 +53,7 @@ public String getBaseUrl() { * * @return the resource path (e.g., "/api/flag-config/v1/config") */ + @NotNull public String getResourcePath() { return resourcePath; } @@ -61,6 +63,7 @@ public String getResourcePath() { * * @return an unmodifiable map of query parameters */ + @NotNull public Map getQueryParams() { return queryParams; } diff --git a/src/main/java/cloud/eppo/http/EppoConfigurationRequestFactory.java b/src/main/java/cloud/eppo/http/EppoConfigurationRequestFactory.java index 1b43751b..5f480aef 100644 --- a/src/main/java/cloud/eppo/http/EppoConfigurationRequestFactory.java +++ b/src/main/java/cloud/eppo/http/EppoConfigurationRequestFactory.java @@ -47,6 +47,7 @@ public EppoConfigurationRequestFactory( * support). If the server's current version matches, a 304 response will be returned. * @return the configured request */ + @NotNull public EppoConfigurationRequest createFlagConfigRequest(@Nullable String lastVersionId) { return new EppoConfigurationRequest( baseUrl, Constants.FLAG_CONFIG_ENDPOINT, sdkQueryParams, lastVersionId); @@ -57,6 +58,7 @@ public EppoConfigurationRequest createFlagConfigRequest(@Nullable String lastVer * * @return the configured request */ + @NotNull public EppoConfigurationRequest createBanditParamsRequest() { return new EppoConfigurationRequest(baseUrl, Constants.BANDIT_ENDPOINT, sdkQueryParams, null); } diff --git a/src/main/java/cloud/eppo/http/EppoConfigurationResponse.java b/src/main/java/cloud/eppo/http/EppoConfigurationResponse.java index 5cc8f083..156658da 100644 --- a/src/main/java/cloud/eppo/http/EppoConfigurationResponse.java +++ b/src/main/java/cloud/eppo/http/EppoConfigurationResponse.java @@ -1,5 +1,8 @@ package cloud.eppo.http; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + /** * Represents a configuration response from an {@link EppoConfigurationClient}. * @@ -11,7 +14,8 @@ public final class EppoConfigurationResponse { private final String versionId; private final byte[] body; - private EppoConfigurationResponse(int statusCode, String versionId, byte[] body) { + private EppoConfigurationResponse( + int statusCode, @Nullable String versionId, @Nullable byte[] body) { this.statusCode = statusCode; this.versionId = versionId; this.body = body; @@ -25,7 +29,9 @@ private EppoConfigurationResponse(int statusCode, String versionId, byte[] body) * @param body the response body * @return a new successful response */ - public static EppoConfigurationResponse success(int statusCode, String versionId, byte[] body) { + @NotNull + public static EppoConfigurationResponse success( + int statusCode, @Nullable String versionId, @NotNull byte[] body) { return new EppoConfigurationResponse(statusCode, versionId, body); } @@ -35,7 +41,8 @@ public static EppoConfigurationResponse success(int statusCode, String versionId * @param versionId the version identifier, or null if not present * @return a new not modified response */ - public static EppoConfigurationResponse notModified(String versionId) { + @NotNull + public static EppoConfigurationResponse notModified(@Nullable String versionId) { return new EppoConfigurationResponse(304, versionId, null); } @@ -46,7 +53,8 @@ public static EppoConfigurationResponse notModified(String versionId) { * @param body the error response body, or null * @return a new error response */ - public static EppoConfigurationResponse error(int statusCode, byte[] body) { + @NotNull + public static EppoConfigurationResponse error(int statusCode, @Nullable byte[] body) { return new EppoConfigurationResponse(statusCode, null, body); } @@ -66,6 +74,7 @@ public int getStatusCode() { * * @return the version ID, or null if not present */ + @Nullable public String getVersionId() { return versionId; } @@ -75,6 +84,7 @@ public String getVersionId() { * * @return the body bytes, or null for 304 responses */ + @Nullable public byte[] getBody() { return body; } diff --git a/src/main/java/cloud/eppo/parser/ConfigurationParser.java b/src/main/java/cloud/eppo/parser/ConfigurationParser.java index 1375f424..1e8f0b11 100644 --- a/src/main/java/cloud/eppo/parser/ConfigurationParser.java +++ b/src/main/java/cloud/eppo/parser/ConfigurationParser.java @@ -2,6 +2,7 @@ import cloud.eppo.api.dto.BanditParametersResponse; import cloud.eppo.api.dto.FlagConfigResponse; +import org.jetbrains.annotations.NotNull; /** * Defines the contract for parsing configuration JSON responses. @@ -20,7 +21,9 @@ public interface ConfigurationParser { * @return parsed FlagConfigResponse containing flags, bandit references, format, etc. * @throws ConfigurationParseException if parsing fails */ - FlagConfigResponse parseFlagConfig(byte[] flagConfigJson) throws ConfigurationParseException; + @NotNull + FlagConfigResponse parseFlagConfig(@NotNull byte[] flagConfigJson) + throws ConfigurationParseException; /** * Parses raw bandit parameters JSON bytes. @@ -29,6 +32,7 @@ public interface ConfigurationParser { * @return parsed BanditParametersResponse containing bandit models * @throws ConfigurationParseException if parsing fails */ - BanditParametersResponse parseBanditParams(byte[] banditParamsJson) + @NotNull + BanditParametersResponse parseBanditParams(@NotNull byte[] banditParamsJson) throws ConfigurationParseException; } From 29cbce5f9dbd3aeb617e66a33c13c7f335bbcb02 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 11 Feb 2026 15:42:44 -0700 Subject: [PATCH 07/44] lint --- .../cloud/eppo/http/EppoConfigurationClient.java | 3 +-- .../cloud/eppo/http/EppoConfigurationRequest.java | 9 +++------ .../http/EppoConfigurationRequestFactory.java | 6 ++---- .../eppo/http/EppoConfigurationResponse.java | 15 +++++---------- .../cloud/eppo/parser/ConfigurationParser.java | 6 ++---- 5 files changed, 13 insertions(+), 26 deletions(-) diff --git a/src/main/java/cloud/eppo/http/EppoConfigurationClient.java b/src/main/java/cloud/eppo/http/EppoConfigurationClient.java index de6b17a5..2644886f 100644 --- a/src/main/java/cloud/eppo/http/EppoConfigurationClient.java +++ b/src/main/java/cloud/eppo/http/EppoConfigurationClient.java @@ -23,6 +23,5 @@ public interface EppoConfigurationClient { * @param request the request to execute * @return a CompletableFuture that will complete with the response */ - @NotNull - CompletableFuture get(@NotNull EppoConfigurationRequest request); + @NotNull CompletableFuture get(@NotNull EppoConfigurationRequest request); } diff --git a/src/main/java/cloud/eppo/http/EppoConfigurationRequest.java b/src/main/java/cloud/eppo/http/EppoConfigurationRequest.java index 0fe7628b..49bb412a 100644 --- a/src/main/java/cloud/eppo/http/EppoConfigurationRequest.java +++ b/src/main/java/cloud/eppo/http/EppoConfigurationRequest.java @@ -43,8 +43,7 @@ public EppoConfigurationRequest( * * @return the base URL (e.g., "https://fscdn.eppo.cloud") */ - @NotNull - public String getBaseUrl() { + @NotNull public String getBaseUrl() { return baseUrl; } @@ -53,8 +52,7 @@ public String getBaseUrl() { * * @return the resource path (e.g., "/api/flag-config/v1/config") */ - @NotNull - public String getResourcePath() { + @NotNull public String getResourcePath() { return resourcePath; } @@ -63,8 +61,7 @@ public String getResourcePath() { * * @return an unmodifiable map of query parameters */ - @NotNull - public Map getQueryParams() { + @NotNull public Map getQueryParams() { return queryParams; } diff --git a/src/main/java/cloud/eppo/http/EppoConfigurationRequestFactory.java b/src/main/java/cloud/eppo/http/EppoConfigurationRequestFactory.java index 5f480aef..76875acc 100644 --- a/src/main/java/cloud/eppo/http/EppoConfigurationRequestFactory.java +++ b/src/main/java/cloud/eppo/http/EppoConfigurationRequestFactory.java @@ -47,8 +47,7 @@ public EppoConfigurationRequestFactory( * support). If the server's current version matches, a 304 response will be returned. * @return the configured request */ - @NotNull - public EppoConfigurationRequest createFlagConfigRequest(@Nullable String lastVersionId) { + @NotNull public EppoConfigurationRequest createFlagConfigRequest(@Nullable String lastVersionId) { return new EppoConfigurationRequest( baseUrl, Constants.FLAG_CONFIG_ENDPOINT, sdkQueryParams, lastVersionId); } @@ -58,8 +57,7 @@ public EppoConfigurationRequest createFlagConfigRequest(@Nullable String lastVer * * @return the configured request */ - @NotNull - public EppoConfigurationRequest createBanditParamsRequest() { + @NotNull public EppoConfigurationRequest createBanditParamsRequest() { return new EppoConfigurationRequest(baseUrl, Constants.BANDIT_ENDPOINT, sdkQueryParams, null); } } diff --git a/src/main/java/cloud/eppo/http/EppoConfigurationResponse.java b/src/main/java/cloud/eppo/http/EppoConfigurationResponse.java index 156658da..d886b673 100644 --- a/src/main/java/cloud/eppo/http/EppoConfigurationResponse.java +++ b/src/main/java/cloud/eppo/http/EppoConfigurationResponse.java @@ -29,8 +29,7 @@ private EppoConfigurationResponse( * @param body the response body * @return a new successful response */ - @NotNull - public static EppoConfigurationResponse success( + @NotNull public static EppoConfigurationResponse success( int statusCode, @Nullable String versionId, @NotNull byte[] body) { return new EppoConfigurationResponse(statusCode, versionId, body); } @@ -41,8 +40,7 @@ public static EppoConfigurationResponse success( * @param versionId the version identifier, or null if not present * @return a new not modified response */ - @NotNull - public static EppoConfigurationResponse notModified(@Nullable String versionId) { + @NotNull public static EppoConfigurationResponse notModified(@Nullable String versionId) { return new EppoConfigurationResponse(304, versionId, null); } @@ -53,8 +51,7 @@ public static EppoConfigurationResponse notModified(@Nullable String versionId) * @param body the error response body, or null * @return a new error response */ - @NotNull - public static EppoConfigurationResponse error(int statusCode, @Nullable byte[] body) { + @NotNull public static EppoConfigurationResponse error(int statusCode, @Nullable byte[] body) { return new EppoConfigurationResponse(statusCode, null, body); } @@ -74,8 +71,7 @@ public int getStatusCode() { * * @return the version ID, or null if not present */ - @Nullable - public String getVersionId() { + @Nullable public String getVersionId() { return versionId; } @@ -84,8 +80,7 @@ public String getVersionId() { * * @return the body bytes, or null for 304 responses */ - @Nullable - public byte[] getBody() { + @Nullable public byte[] getBody() { return body; } diff --git a/src/main/java/cloud/eppo/parser/ConfigurationParser.java b/src/main/java/cloud/eppo/parser/ConfigurationParser.java index 1e8f0b11..831aeb82 100644 --- a/src/main/java/cloud/eppo/parser/ConfigurationParser.java +++ b/src/main/java/cloud/eppo/parser/ConfigurationParser.java @@ -21,8 +21,7 @@ public interface ConfigurationParser { * @return parsed FlagConfigResponse containing flags, bandit references, format, etc. * @throws ConfigurationParseException if parsing fails */ - @NotNull - FlagConfigResponse parseFlagConfig(@NotNull byte[] flagConfigJson) + @NotNull FlagConfigResponse parseFlagConfig(@NotNull byte[] flagConfigJson) throws ConfigurationParseException; /** @@ -32,7 +31,6 @@ FlagConfigResponse parseFlagConfig(@NotNull byte[] flagConfigJson) * @return parsed BanditParametersResponse containing bandit models * @throws ConfigurationParseException if parsing fails */ - @NotNull - BanditParametersResponse parseBanditParams(@NotNull byte[] banditParamsJson) + @NotNull BanditParametersResponse parseBanditParams(@NotNull byte[] banditParamsJson) throws ConfigurationParseException; } From 74ff8fc993ad1e5b18d8d1b0cb95fea4ec9f476f Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 12 Feb 2026 12:38:55 -0700 Subject: [PATCH 08/44] Add json value unwrapping to Parser interface --- .../java/cloud/eppo/parser/ConfigurationParser.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/cloud/eppo/parser/ConfigurationParser.java b/src/main/java/cloud/eppo/parser/ConfigurationParser.java index 831aeb82..6ae2ea55 100644 --- a/src/main/java/cloud/eppo/parser/ConfigurationParser.java +++ b/src/main/java/cloud/eppo/parser/ConfigurationParser.java @@ -12,7 +12,7 @@ * eppo-sdk-common module), but users can supply custom implementations to accommodate specialized * needs. */ -public interface ConfigurationParser { +public interface ConfigurationParser { /** * Parses raw flag configuration JSON bytes. @@ -33,4 +33,13 @@ public interface ConfigurationParser { */ @NotNull BanditParametersResponse parseBanditParams(@NotNull byte[] banditParamsJson) throws ConfigurationParseException; + + /** + * Unwraps a JSON value to the appropriate JSONFlagType. + * + * @param jsonValue the encoded JSON value + * @return the parsed JSON value + * @throws ConfigurationParseException if unwrapping fails + */ + @NotNull JSONFlagType parseJsonValue(@NotNull String jsonValue) throws ConfigurationParseException; } From cfe4edafe9b6b0510c5e489f67bd878122f3c86e Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Mon, 9 Feb 2026 15:24:14 -0700 Subject: [PATCH 09/44] common module extraction with default parser and http client --- build.gradle | 6 +- eppo-sdk-common/build.gradle | 136 ++++++++++ .../eppo/JacksonConfigurationParser.java | 104 ++++++++ .../java/cloud/eppo/OkHttpEppoClient.java | 136 ++++++++++ .../BanditParametersResponseDeserializer.java | 174 +++++++++++++ .../eppo/ufc/dto/adapters/DateSerializer.java | 29 +++ .../eppo/ufc/dto/adapters/EppoModule.java | 21 ++ .../dto/adapters/EppoValueDeserializer.java | 61 +++++ .../ufc/dto/adapters/EppoValueSerializer.java | 37 +++ .../FlagConfigResponseDeserializer.java | 237 ++++++++++++++++++ .../eppo/JacksonConfigurationParserTest.java | 122 +++++++++ .../java/cloud/eppo/OkHttpEppoClientTest.java | 196 +++++++++++++++ settings.gradle | 2 + src/test/resources/flags-v1.json | 4 + 14 files changed, 1262 insertions(+), 3 deletions(-) create mode 100644 eppo-sdk-common/build.gradle create mode 100644 eppo-sdk-common/src/main/java/cloud/eppo/JacksonConfigurationParser.java create mode 100644 eppo-sdk-common/src/main/java/cloud/eppo/OkHttpEppoClient.java create mode 100644 eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/BanditParametersResponseDeserializer.java create mode 100644 eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/DateSerializer.java create mode 100644 eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/EppoModule.java create mode 100644 eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueDeserializer.java create mode 100644 eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueSerializer.java create mode 100644 eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java create mode 100644 eppo-sdk-common/src/test/java/cloud/eppo/JacksonConfigurationParserTest.java create mode 100644 eppo-sdk-common/src/test/java/cloud/eppo/OkHttpEppoClientTest.java diff --git a/build.gradle b/build.gradle index b2b3e40e..6117f296 100644 --- a/build.gradle +++ b/build.gradle @@ -75,7 +75,7 @@ publishing { publications { maven(MavenPublication) { groupId = 'cloud.eppo' - artifactId = 'sdk-common-jvm' + artifactId = 'eppo-sdk-framework' from components.java artifact testJar // Include the test-jar in the published artifacts @@ -88,8 +88,8 @@ publishing { } } pom { - name = 'Eppo JVM SDK shared library' - description = 'Eppo SDK for JVM shared library' + name = 'Eppo SDK Framework' + description = 'Core framework for Eppo JVM SDKs' url = 'https://github.com/Eppo-exp/sdk-common-jvm' licenses { license { diff --git a/eppo-sdk-common/build.gradle b/eppo-sdk-common/build.gradle new file mode 100644 index 00000000..34f23436 --- /dev/null +++ b/eppo-sdk-common/build.gradle @@ -0,0 +1,136 @@ +plugins { + id 'java-library' + id 'maven-publish' + id "com.diffplug.spotless" version "6.13.0" +} + +group = 'cloud.eppo' +version = '4.0.0-SNAPSHOT' + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +// Create a combined JAR that includes both this module and the framework +jar { + // Include classes from the root framework module + from(project(':').sourceSets.main.output) + // This module's classes take precedence over framework classes + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +// Create combined sources JAR +tasks.register('sourcesJar', Jar) { + archiveClassifier.set('sources') + from sourceSets.main.allSource + from project(':').sourceSets.main.allSource + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +// Create combined javadoc JAR +tasks.register('javadocJar', Jar) { + archiveClassifier.set('javadoc') + from javadoc + from project(':').tasks.javadoc +} + +dependencies { + // Depend on the core SDK module for interfaces and DTOs + api project(':') + + // OkHttp 5 for HTTP client implementation + implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.14' + + // Jackson for JSON parsing (already in parent, but explicit for this module) + implementation 'com.fasterxml.jackson.core:jackson-databind:2.20.1' + + // Logging + implementation 'org.slf4j:slf4j-api:2.0.17' + + // Test dependencies + testImplementation platform('org.junit:junit-bom:5.14.1') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'com.squareup.okhttp3:mockwebserver:5.0.0-alpha.14' + testImplementation 'com.google.truth:truth:1.4.5' +} + +test { + useJUnitPlatform() + testLogging { + events "started", "passed", "skipped", "failed" + exceptionFormat "full" + showExceptions true + showCauses true + showStackTraces true + } +} + +spotless { + ratchetFrom 'origin/main' + + format 'misc', { + target '*.gradle', '.gitattributes', '.gitignore' + + trimTrailingWhitespace() + indentWithSpaces(2) + endWithNewline() + } + java { + googleJavaFormat('1.7') + formatAnnotations() + } +} + +javadoc { + failOnError = false + options.addStringOption('Xdoclint:none', '-quiet') + options.addBooleanOption('failOnError', false) + if (JavaVersion.current().isJava9Compatible()) { + options.addBooleanOption('html5', true) + } +} + +publishing { + publications { + maven(MavenPublication) { + groupId = 'cloud.eppo' + artifactId = 'sdk-common-jvm' + + from components.java + artifact sourcesJar + artifact javadocJar + + pom { + name = 'Eppo SDK Common JVM' + description = 'Complete Eppo SDK for JVM with default HTTP client and configuration parser' + url = 'https://github.com/Eppo-exp/sdk-common-jvm' + licenses { + license { + name = 'MIT License' + url = 'http://www.opensource.org/licenses/mit-license.php' + } + } + developers { + developer { + name = 'Eppo' + email = 'https://geteppo.com' + } + } + scm { + connection = 'scm:git:git://github.com/Eppo-exp/sdk-common-jvm.git' + developerConnection = 'scm:git:ssh://github.com/Eppo-exp/sdk-common-jvm.git' + url = 'https://github.com/Eppo-exp/sdk-common-jvm' + } + } + + // Remove the framework dependency from POM since it's bundled in the JAR + pom.withXml { + asNode().dependencies.dependency.findAll { + it.artifactId.text() == 'eppo-sdk-framework' + }.each { it.parent().remove(it) } + } + } + } +} diff --git a/eppo-sdk-common/src/main/java/cloud/eppo/JacksonConfigurationParser.java b/eppo-sdk-common/src/main/java/cloud/eppo/JacksonConfigurationParser.java new file mode 100644 index 00000000..822a43ec --- /dev/null +++ b/eppo-sdk-common/src/main/java/cloud/eppo/JacksonConfigurationParser.java @@ -0,0 +1,104 @@ +package cloud.eppo; + +import cloud.eppo.api.dto.BanditParameters; +import cloud.eppo.api.dto.BanditParametersResponse; +import cloud.eppo.api.dto.FlagConfigResponse; +import cloud.eppo.parser.ConfigurationParseException; +import cloud.eppo.parser.ConfigurationParser; +import cloud.eppo.ufc.dto.adapters.EppoModule; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default implementation of {@link ConfigurationParser} using Jackson. + * + *

This parser uses Jackson's ObjectMapper with custom deserializers for Eppo's configuration + * format. The deserializers are hand-rolled to avoid reliance on annotations and method names, + * which can be unreliable when ProGuard minification is in use. + */ +public class JacksonConfigurationParser implements ConfigurationParser { + private static final Logger log = LoggerFactory.getLogger(JacksonConfigurationParser.class); + + private final ObjectMapper objectMapper; + + /** Creates a new parser with the default ObjectMapper configuration. */ + public JacksonConfigurationParser() { + this(createDefaultObjectMapper()); + } + + /** + * Creates a new parser with a custom ObjectMapper. + * + *

Note: The provided ObjectMapper must be configured with {@link EppoModule#eppoModule()} for + * proper deserialization of Eppo configuration types. + * + * @param objectMapper the ObjectMapper instance to use + */ + public JacksonConfigurationParser(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + private static ObjectMapper createDefaultObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(EppoModule.eppoModule()); + return mapper; + } + + @Override + public FlagConfigResponse parseFlagConfig(byte[] flagConfigJson) + throws ConfigurationParseException { + try { + log.debug("Parsing flag configuration, {} bytes", flagConfigJson.length); + return objectMapper.readValue(flagConfigJson, FlagConfigResponse.class); + } catch (IOException e) { + throw new ConfigurationParseException("Failed to parse flag configuration", e); + } + } + + @Override + public Map parseBanditParams(byte[] banditParamsJson) + throws ConfigurationParseException { + try { + log.debug("Parsing bandit parameters, {} bytes", banditParamsJson.length); + BanditParametersResponse response = + objectMapper.readValue(banditParamsJson, BanditParametersResponse.class); + return response.getBandits(); + } catch (IOException e) { + throw new ConfigurationParseException("Failed to parse bandit parameters", e); + } + } + + @Override + public byte[] serializeFlagConfig(FlagConfigResponse flagConfigResponse) + throws ConfigurationParseException { + try { + log.debug("Serializing flag configuration"); + return objectMapper.writeValueAsBytes(flagConfigResponse); + } catch (JsonProcessingException e) { + throw new ConfigurationParseException("Failed to serialize flag configuration", e); + } + } + + @Override + public byte[] serializeBanditParams(Map banditParams) + throws ConfigurationParseException { + try { + log.debug("Serializing bandit parameters"); + BanditParametersResponse response = + new BanditParametersResponse.Default(castBanditMap(banditParams)); + return objectMapper.writeValueAsBytes(response); + } catch (JsonProcessingException e) { + throw new ConfigurationParseException("Failed to serialize bandit parameters", e); + } + } + + @SuppressWarnings("unchecked") + private Map castBanditMap( + Map banditParams) { + return (Map) banditParams; + } +} diff --git a/eppo-sdk-common/src/main/java/cloud/eppo/OkHttpEppoClient.java b/eppo-sdk-common/src/main/java/cloud/eppo/OkHttpEppoClient.java new file mode 100644 index 00000000..ae51a557 --- /dev/null +++ b/eppo-sdk-common/src/main/java/cloud/eppo/OkHttpEppoClient.java @@ -0,0 +1,136 @@ +package cloud.eppo; + +import cloud.eppo.http.EppoConfigurationClient; +import cloud.eppo.http.EppoConfigurationRequest; +import cloud.eppo.http.EppoConfigurationResponse; +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default implementation of {@link EppoConfigurationClient} using OkHttp 5. + * + *

This client handles HTTP communication with Eppo's configuration servers, supporting + * conditional requests via If-None-Match headers for efficient caching. + */ +public class OkHttpEppoClient implements EppoConfigurationClient { + private static final Logger log = LoggerFactory.getLogger(OkHttpEppoClient.class); + private static final String ETAG_HEADER = "ETag"; + private static final String IF_NONE_MATCH_HEADER = "If-None-Match"; + + private final OkHttpClient client; + + /** Creates a new OkHttp client with default timeouts (10 seconds for connect and read). */ + public OkHttpEppoClient() { + this(buildDefaultClient()); + } + + /** + * Creates a new OkHttp client with a custom OkHttpClient instance. + * + *

Use this constructor when you need custom timeouts, interceptors, or other OkHttp + * configurations. + * + * @param client the OkHttpClient instance to use + */ + public OkHttpEppoClient(OkHttpClient client) { + this.client = client; + } + + private static OkHttpClient buildDefaultClient() { + return new OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build(); + } + + @Override + public CompletableFuture get(EppoConfigurationRequest request) { + CompletableFuture future = new CompletableFuture<>(); + Request httpRequest = buildRequest(request); + + client + .newCall(httpRequest) + .enqueue( + new Callback() { + @Override + public void onResponse(@NotNull Call call, @NotNull Response response) { + try { + EppoConfigurationResponse configResponse = handleResponse(response); + future.complete(configResponse); + } catch (Exception e) { + future.completeExceptionally(e); + } finally { + response.close(); + } + } + + @Override + public void onFailure(@NotNull Call call, @NotNull IOException e) { + log.error("HTTP request failed: {}", e.getMessage(), e); + future.completeExceptionally( + new RuntimeException( + "Unable to fetch from URL " + redactUrl(httpRequest.url()), e)); + } + }); + + return future; + } + + private Request buildRequest(EppoConfigurationRequest request) { + HttpUrl.Builder urlBuilder = + HttpUrl.parse(request.getBaseUrl() + request.getResourcePath()).newBuilder(); + + for (Map.Entry param : request.getQueryParams().entrySet()) { + urlBuilder.addQueryParameter(param.getKey(), param.getValue()); + } + + Request.Builder requestBuilder = new Request.Builder().url(urlBuilder.build()); + + // Add conditional request header if we have a previous version + String lastVersionId = request.getLastVersionId(); + if (lastVersionId != null && !lastVersionId.isEmpty()) { + requestBuilder.header(IF_NONE_MATCH_HEADER, lastVersionId); + } + + return requestBuilder.build(); + } + + private EppoConfigurationResponse handleResponse(Response response) throws IOException { + int statusCode = response.code(); + String versionId = response.header(ETAG_HEADER); + + if (statusCode == 304) { + log.debug("Configuration not modified (304)"); + return EppoConfigurationResponse.notModified(versionId); + } + + if (response.isSuccessful()) { + ResponseBody body = response.body(); + byte[] bodyBytes = body != null ? body.bytes() : new byte[0]; + log.debug("Configuration fetched successfully, {} bytes", bodyBytes.length); + return EppoConfigurationResponse.success(statusCode, versionId, bodyBytes); + } + + // Error response + ResponseBody body = response.body(); + byte[] errorBytes = body != null ? body.bytes() : null; + log.warn("Configuration fetch failed with status {}", statusCode); + return EppoConfigurationResponse.error(statusCode, errorBytes); + } + + private String redactUrl(HttpUrl url) { + return url.toString().replaceAll("apiKey=[^&]*", "apiKey="); + } +} diff --git a/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/BanditParametersResponseDeserializer.java b/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/BanditParametersResponseDeserializer.java new file mode 100644 index 00000000..3bc750f2 --- /dev/null +++ b/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/BanditParametersResponseDeserializer.java @@ -0,0 +1,174 @@ +package cloud.eppo.ufc.dto.adapters; + +import cloud.eppo.api.dto.BanditCategoricalAttributeCoefficients; +import cloud.eppo.api.dto.BanditCoefficients; +import cloud.eppo.api.dto.BanditModelData; +import cloud.eppo.api.dto.BanditNumericAttributeCoefficients; +import cloud.eppo.api.dto.BanditParameters; +import cloud.eppo.api.dto.BanditParametersResponse; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import java.io.IOException; +import java.time.Instant; +import java.util.Date; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class BanditParametersResponseDeserializer + extends StdDeserializer { + private static final Logger log = + LoggerFactory.getLogger(BanditParametersResponseDeserializer.class); + + // Note: public default constructor is required by Jackson + public BanditParametersResponseDeserializer() { + this(null); + } + + protected BanditParametersResponseDeserializer(Class vc) { + super(vc); + } + + @Override + public BanditParametersResponse deserialize( + JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { + JsonNode rootNode = jsonParser.getCodec().readTree(jsonParser); + if (rootNode == null || !rootNode.isObject()) { + log.warn("no top-level JSON object"); + return new BanditParametersResponse.Default(); + } + + JsonNode banditsNode = rootNode.get("bandits"); + if (banditsNode == null || !banditsNode.isObject()) { + log.warn("no root-level bandits object"); + return new BanditParametersResponse.Default(); + } + + Map bandits = new HashMap<>(); + banditsNode + .iterator() + .forEachRemaining( + banditNode -> { + String banditKey = banditNode.get("banditKey").asText(); + String updatedAtStr = banditNode.get("updatedAt").asText(); + Instant instant = Instant.parse(updatedAtStr); + Date updatedAt = Date.from(instant); + String modelName = banditNode.get("modelName").asText(); + String modelVersion = banditNode.get("modelVersion").asText(); + JsonNode modelDataNode = banditNode.get("modelData"); + double gamma = modelDataNode.get("gamma").asDouble(); + double defaultActionScore = modelDataNode.get("defaultActionScore").asDouble(); + double actionProbabilityFloor = + modelDataNode.get("actionProbabilityFloor").asDouble(); + JsonNode coefficientsNode = modelDataNode.get("coefficients"); + Map coefficients = new HashMap<>(); + Iterator> coefficientIterator = coefficientsNode.fields(); + coefficientIterator.forEachRemaining( + field -> { + BanditCoefficients actionCoefficients = + this.parseActionCoefficientsNode(field.getValue()); + coefficients.put(field.getKey(), actionCoefficients); + }); + + BanditModelData modelData = + new BanditModelData.Default( + gamma, defaultActionScore, actionProbabilityFloor, coefficients); + BanditParameters parameters = + new BanditParameters.Default( + banditKey, updatedAt, modelName, modelVersion, modelData); + bandits.put(banditKey, parameters); + }); + + return new BanditParametersResponse.Default(bandits); + } + + private BanditCoefficients parseActionCoefficientsNode(JsonNode actionCoefficientsNode) { + String actionKey = actionCoefficientsNode.get("actionKey").asText(); + Double intercept = actionCoefficientsNode.get("intercept").asDouble(); + + JsonNode subjectNumericAttributeCoefficientsNode = + actionCoefficientsNode.get("subjectNumericCoefficients"); + Map subjectNumericAttributeCoefficients = + this.parseNumericAttributeCoefficientsArrayNode(subjectNumericAttributeCoefficientsNode); + JsonNode subjectCategoricalAttributeCoefficientsNode = + actionCoefficientsNode.get("subjectCategoricalCoefficients"); + Map subjectCategoricalAttributeCoefficients = + this.parseCategoricalAttributeCoefficientsArrayNode( + subjectCategoricalAttributeCoefficientsNode); + + JsonNode actionNumericAttributeCoefficientsNode = + actionCoefficientsNode.get("actionNumericCoefficients"); + Map actionNumericAttributeCoefficients = + this.parseNumericAttributeCoefficientsArrayNode(actionNumericAttributeCoefficientsNode); + JsonNode actionCategoricalAttributeCoefficientsNode = + actionCoefficientsNode.get("actionCategoricalCoefficients"); + Map actionCategoricalAttributeCoefficients = + this.parseCategoricalAttributeCoefficientsArrayNode( + actionCategoricalAttributeCoefficientsNode); + + return new BanditCoefficients.Default( + actionKey, + intercept, + subjectNumericAttributeCoefficients, + subjectCategoricalAttributeCoefficients, + actionNumericAttributeCoefficients, + actionCategoricalAttributeCoefficients); + } + + private Map + parseNumericAttributeCoefficientsArrayNode(JsonNode numericAttributeCoefficientsArrayNode) { + Map numericAttributeCoefficients = new HashMap<>(); + numericAttributeCoefficientsArrayNode + .iterator() + .forEachRemaining( + numericAttributeCoefficientsNode -> { + String attributeKey = numericAttributeCoefficientsNode.get("attributeKey").asText(); + Double coefficient = numericAttributeCoefficientsNode.get("coefficient").asDouble(); + Double missingValueCoefficient = + numericAttributeCoefficientsNode.get("missingValueCoefficient").asDouble(); + BanditNumericAttributeCoefficients coefficients = + new BanditNumericAttributeCoefficients.Default( + attributeKey, coefficient, missingValueCoefficient); + numericAttributeCoefficients.put(attributeKey, coefficients); + }); + + return numericAttributeCoefficients; + } + + private Map + parseCategoricalAttributeCoefficientsArrayNode( + JsonNode categoricalAttributeCoefficientsArrayNode) { + Map categoricalAttributeCoefficients = + new HashMap<>(); + categoricalAttributeCoefficientsArrayNode + .iterator() + .forEachRemaining( + categoricalAttributeCoefficientsNode -> { + String attributeKey = + categoricalAttributeCoefficientsNode.get("attributeKey").asText(); + Double missingValueCoefficient = + categoricalAttributeCoefficientsNode.get("missingValueCoefficient").asDouble(); + + Map valueCoefficients = new HashMap<>(); + JsonNode valuesNode = categoricalAttributeCoefficientsNode.get("valueCoefficients"); + Iterator> coefficientIterator = valuesNode.fields(); + coefficientIterator.forEachRemaining( + field -> { + String value = field.getKey(); + Double coefficient = field.getValue().asDouble(); + valueCoefficients.put(value, coefficient); + }); + + BanditCategoricalAttributeCoefficients coefficients = + new BanditCategoricalAttributeCoefficients.Default( + attributeKey, missingValueCoefficient, valueCoefficients); + categoricalAttributeCoefficients.put(attributeKey, coefficients); + }); + + return categoricalAttributeCoefficients; + } +} diff --git a/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/DateSerializer.java b/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/DateSerializer.java new file mode 100644 index 00000000..2ea23176 --- /dev/null +++ b/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/DateSerializer.java @@ -0,0 +1,29 @@ +package cloud.eppo.ufc.dto.adapters; + +import static cloud.eppo.Utils.getISODate; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import java.io.IOException; +import java.util.Date; + +/** + * This adapter for Date allows gson to serialize to UTC ISO 8601 (vs. its default of local + * timezone) + */ +public class DateSerializer extends StdSerializer { + protected DateSerializer(Class t) { + super(t); + } + + public DateSerializer() { + this(null); + } + + @Override + public void serialize(Date value, JsonGenerator jgen, SerializerProvider provider) + throws IOException { + jgen.writeString(getISODate(value)); + } +} diff --git a/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/EppoModule.java b/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/EppoModule.java new file mode 100644 index 00000000..8237aaf7 --- /dev/null +++ b/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/EppoModule.java @@ -0,0 +1,21 @@ +package cloud.eppo.ufc.dto.adapters; + +import cloud.eppo.api.EppoValue; +import cloud.eppo.api.dto.BanditParametersResponse; +import cloud.eppo.api.dto.FlagConfigResponse; +import com.fasterxml.jackson.databind.module.SimpleModule; +import java.util.Date; + +public class EppoModule { + public static SimpleModule eppoModule() { + SimpleModule module = new SimpleModule(); + module.addDeserializer(FlagConfigResponse.class, new FlagConfigResponseDeserializer()); + module.addDeserializer( + BanditParametersResponse.class, new BanditParametersResponseDeserializer()); + module.addDeserializer(EppoValue.class, new EppoValueDeserializer()); + module.addSerializer(EppoValue.class, new EppoValueSerializer()); + module.addSerializer(Date.class, new DateSerializer()); + // TODO: add bandit deserializer + return module; + } +} diff --git a/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueDeserializer.java b/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueDeserializer.java new file mode 100644 index 00000000..09ec5b36 --- /dev/null +++ b/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueDeserializer.java @@ -0,0 +1,61 @@ +package cloud.eppo.ufc.dto.adapters; + +import cloud.eppo.api.EppoValue; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class EppoValueDeserializer extends StdDeserializer { + private static final Logger log = LoggerFactory.getLogger(EppoValueDeserializer.class); + + protected EppoValueDeserializer(Class vc) { + super(vc); + } + + public EppoValueDeserializer() { + this(null); + } + + @Override + public EppoValue deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException { + return deserializeNode(jp.getCodec().readTree(jp)); + } + + public EppoValue deserializeNode(JsonNode node) { + EppoValue result; + if (node == null || node.isNull()) { + result = EppoValue.nullValue(); + } else if (node.isArray()) { + List stringArray = new ArrayList<>(); + for (JsonNode arrayElement : node) { + if (arrayElement.isValueNode() && arrayElement.isTextual()) { + stringArray.add(arrayElement.asText()); + } else { + log.warn( + "only Strings are supported for array-valued values; received: {}", arrayElement); + } + } + result = EppoValue.valueOf(stringArray); + } else if (node.isValueNode()) { + if (node.isBoolean()) { + result = EppoValue.valueOf(node.asBoolean()); + } else if (node.isNumber()) { + result = EppoValue.valueOf(node.doubleValue()); + } else { + result = EppoValue.valueOf(node.textValue()); + } + } else { + // If here, we don't know what to do; fail to null with a warning + log.warn("Unexpected JSON for parsing a value: {}", node); + result = EppoValue.nullValue(); + } + + return result; + } +} diff --git a/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueSerializer.java b/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueSerializer.java new file mode 100644 index 00000000..8bd10bd6 --- /dev/null +++ b/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueSerializer.java @@ -0,0 +1,37 @@ +package cloud.eppo.ufc.dto.adapters; + +import cloud.eppo.api.EppoValue; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import java.io.IOException; + +public class EppoValueSerializer extends StdSerializer { + protected EppoValueSerializer(Class t) { + super(t); + } + + public EppoValueSerializer() { + this(null); + } + + @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(); + } + } +} diff --git a/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java b/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java new file mode 100644 index 00000000..737fcfad --- /dev/null +++ b/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java @@ -0,0 +1,237 @@ +package cloud.eppo.ufc.dto.adapters; + +import static cloud.eppo.Utils.parseUtcISODateNode; + +import cloud.eppo.api.EppoValue; +import cloud.eppo.api.dto.Allocation; +import cloud.eppo.api.dto.BanditFlagVariation; +import cloud.eppo.api.dto.BanditReference; +import cloud.eppo.api.dto.FlagConfig; +import cloud.eppo.api.dto.FlagConfigResponse; +import cloud.eppo.api.dto.OperatorType; +import cloud.eppo.api.dto.Shard; +import cloud.eppo.api.dto.Split; +import cloud.eppo.api.dto.TargetingCondition; +import cloud.eppo.api.dto.TargetingRule; +import cloud.eppo.api.dto.Variation; +import cloud.eppo.api.dto.VariationType; +import cloud.eppo.model.ShardRange; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import java.io.IOException; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Hand-rolled deserializer 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-deserialization-related classes and annotations. + */ +public class FlagConfigResponseDeserializer extends StdDeserializer { + private static final Logger log = LoggerFactory.getLogger(FlagConfigResponseDeserializer.class); + private final EppoValueDeserializer eppoValueDeserializer = new EppoValueDeserializer(); + + protected FlagConfigResponseDeserializer(Class vc) { + super(vc); + } + + public FlagConfigResponseDeserializer() { + this(null); + } + + @Override + public FlagConfigResponse deserialize(JsonParser jp, DeserializationContext ctxt) + throws IOException { + JsonNode rootNode = jp.getCodec().readTree(jp); + + if (rootNode == null || !rootNode.isObject()) { + log.warn("no top-level JSON object"); + return new FlagConfigResponse.Default(); + } + JsonNode flagsNode = rootNode.get("flags"); + if (flagsNode == null || !flagsNode.isObject()) { + log.warn("no root-level flags object"); + return new FlagConfigResponse.Default(); + } + + // Default is to assume that the config is not obfuscated. + JsonNode formatNode = rootNode.get("format"); + FlagConfigResponse.Format dataFormat = + formatNode == null + ? 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 + .fields() + .forEachRemaining( + field -> { + FlagConfig flagConfig = deserializeFlag(field.getValue()); + flags.put(field.getKey(), flagConfig); + }); + + Map banditReferences = new ConcurrentHashMap<>(); + if (rootNode.has("banditReferences")) { + JsonNode banditReferencesNode = rootNode.get("banditReferences"); + if (!banditReferencesNode.isObject()) { + log.warn("root-level banditReferences property is present but not a JSON object"); + } else { + banditReferencesNode + .fields() + .forEachRemaining( + field -> { + BanditReference banditReference = deserializeBanditReference(field.getValue()); + banditReferences.put(field.getKey(), banditReference); + }); + } + } + + return new FlagConfigResponse.Default( + flags, banditReferences, dataFormat, environmentName, createdAt); + } + + private FlagConfig deserializeFlag(JsonNode jsonNode) { + String key = jsonNode.get("key").asText(); + boolean enabled = jsonNode.get("enabled").asBoolean(); + int totalShards = jsonNode.get("totalShards").asInt(); + VariationType variationType = VariationType.fromString(jsonNode.get("variationType").asText()); + Map variations = deserializeVariations(jsonNode.get("variations")); + List allocations = deserializeAllocations(jsonNode.get("allocations")); + + return new FlagConfig.Default( + key, enabled, totalShards, variationType, variations, allocations); + } + + private Map deserializeVariations(JsonNode jsonNode) { + Map variations = new HashMap<>(); + if (jsonNode == null) { + return variations; + } + for (Iterator> it = jsonNode.fields(); it.hasNext(); ) { + Map.Entry entry = it.next(); + String key = entry.getValue().get("key").asText(); + EppoValue value = eppoValueDeserializer.deserializeNode(entry.getValue().get("value")); + variations.put(entry.getKey(), new Variation.Default(key, value)); + } + return variations; + } + + private List deserializeAllocations(JsonNode jsonNode) { + List allocations = new ArrayList<>(); + if (jsonNode == null) { + return allocations; + } + for (JsonNode allocationNode : jsonNode) { + String key = allocationNode.get("key").asText(); + Set rules = deserializeTargetingRules(allocationNode.get("rules")); + Date startAt = parseUtcISODateNode(allocationNode.get("startAt")); + Date endAt = parseUtcISODateNode(allocationNode.get("endAt")); + List splits = deserializeSplits(allocationNode.get("splits")); + boolean doLog = allocationNode.get("doLog").asBoolean(); + allocations.add(new Allocation.Default(key, rules, startAt, endAt, splits, doLog)); + } + return allocations; + } + + private Set deserializeTargetingRules(JsonNode jsonNode) { + Set targetingRules = new HashSet<>(); + if (jsonNode == null || !jsonNode.isArray()) { + return targetingRules; + } + for (JsonNode ruleNode : jsonNode) { + Set conditions = new HashSet<>(); + for (JsonNode conditionNode : ruleNode.get("conditions")) { + String attribute = conditionNode.get("attribute").asText(); + String operatorKey = conditionNode.get("operator").asText(); + OperatorType operator = OperatorType.fromString(operatorKey); + if (operator == null) { + log.warn("Unknown operator \"{}\"", operatorKey); + continue; + } + EppoValue value = eppoValueDeserializer.deserializeNode(conditionNode.get("value")); + conditions.add(new TargetingCondition.Default(operator, attribute, value)); + } + targetingRules.add(new TargetingRule.Default(conditions)); + } + + return targetingRules; + } + + private List deserializeSplits(JsonNode jsonNode) { + List splits = new ArrayList<>(); + if (jsonNode == null || !jsonNode.isArray()) { + return splits; + } + for (JsonNode splitNode : jsonNode) { + String variationKey = splitNode.get("variationKey").asText(); + Set shards = deserializeShards(splitNode.get("shards")); + Map extraLogging = new HashMap<>(); + JsonNode extraLoggingNode = splitNode.get("extraLogging"); + if (extraLoggingNode != null && extraLoggingNode.isObject()) { + for (Iterator> it = extraLoggingNode.fields(); it.hasNext(); ) { + Map.Entry entry = it.next(); + extraLogging.put(entry.getKey(), entry.getValue().asText()); + } + } + splits.add(new Split.Default(variationKey, shards, extraLogging)); + } + + return splits; + } + + private Set deserializeShards(JsonNode jsonNode) { + Set shards = new HashSet<>(); + if (jsonNode == null || !jsonNode.isArray()) { + return shards; + } + for (JsonNode shardNode : jsonNode) { + String salt = shardNode.get("salt").asText(); + Set ranges = new HashSet<>(); + for (JsonNode rangeNode : shardNode.get("ranges")) { + int start = rangeNode.get("start").asInt(); + int end = rangeNode.get("end").asInt(); + ranges.add(new ShardRange(start, end)); + } + shards.add(new Shard.Default(salt, ranges)); + } + return shards; + } + + private BanditReference deserializeBanditReference(JsonNode jsonNode) { + String modelVersion = jsonNode.get("modelVersion").asText(); + List flagVariations = new ArrayList<>(); + JsonNode flagVariationsNode = jsonNode.get("flagVariations"); + if (flagVariationsNode != null && flagVariationsNode.isArray()) { + for (JsonNode flagVariationNode : flagVariationsNode) { + String banditKey = flagVariationNode.get("key").asText(); + String flagKey = flagVariationNode.get("flagKey").asText(); + String allocationKey = flagVariationNode.get("allocationKey").asText(); + String variationKey = flagVariationNode.get("variationKey").asText(); + String variationValue = flagVariationNode.get("variationValue").asText(); + BanditFlagVariation flagVariation = + new BanditFlagVariation.Default( + banditKey, flagKey, allocationKey, variationKey, variationValue); + flagVariations.add(flagVariation); + } + } + return new BanditReference.Default(modelVersion, flagVariations); + } +} diff --git a/eppo-sdk-common/src/test/java/cloud/eppo/JacksonConfigurationParserTest.java b/eppo-sdk-common/src/test/java/cloud/eppo/JacksonConfigurationParserTest.java new file mode 100644 index 00000000..8a710343 --- /dev/null +++ b/eppo-sdk-common/src/test/java/cloud/eppo/JacksonConfigurationParserTest.java @@ -0,0 +1,122 @@ +package cloud.eppo; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import cloud.eppo.api.dto.BanditParameters; +import cloud.eppo.api.dto.FlagConfig; +import cloud.eppo.api.dto.FlagConfigResponse; +import cloud.eppo.api.dto.VariationType; +import cloud.eppo.parser.ConfigurationParseException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class JacksonConfigurationParserTest { + private JacksonConfigurationParser parser; + + @BeforeEach + public void setUp() { + parser = new JacksonConfigurationParser(); + } + + @Test + public void testParseFlagConfig() throws IOException { + byte[] flagConfigJson = loadTestResource("shared/ufc/flags-v1.json"); + + FlagConfigResponse response = parser.parseFlagConfig(flagConfigJson); + + assertNotNull(response); + assertThat(response.getFlags()).isNotEmpty(); + assertThat(response.getFlags()).containsKey("kill-switch"); + assertThat(response.getFlags()).containsKey("numeric_flag"); + + FlagConfig killSwitch = response.getFlags().get("kill-switch"); + assertEquals("kill-switch", killSwitch.getKey()); + assertTrue(killSwitch.isEnabled()); + assertEquals(VariationType.BOOLEAN, killSwitch.getVariationType()); + assertEquals("Test", response.getEnvironmentName()); + assertNotNull(response.getCreatedAt()); + assertEquals(FlagConfigResponse.Format.SERVER, response.getFormat()); + } + + @Test + public void testParseBanditParams() throws IOException { + byte[] banditParamsJson = loadTestResource("shared/ufc/bandit-models-v1.json"); + + Map bandits = parser.parseBanditParams(banditParamsJson); + + assertNotNull(bandits); + assertThat(bandits).containsKey("banner_bandit"); + + BanditParameters bannerBandit = bandits.get("banner_bandit"); + assertEquals("banner_bandit", bannerBandit.getBanditKey()); + assertEquals("falcon", bannerBandit.getModelName()); + assertEquals("123", bannerBandit.getModelVersion()); + assertNotNull(bannerBandit.getUpdatedAt()); + assertNotNull(bannerBandit.getModelData()); + assertEquals(1.0, bannerBandit.getModelData().getGamma(), 0.001); + } + + @Test + public void testParseFlagConfigInvalidJson() { + byte[] invalidJson = "not valid json".getBytes(); + + ConfigurationParseException exception = + assertThrows(ConfigurationParseException.class, () -> parser.parseFlagConfig(invalidJson)); + + assertThat(exception.getMessage()).contains("Failed to parse flag configuration"); + assertNotNull(exception.getCause()); + } + + @Test + public void testParseBanditParamsInvalidJson() { + byte[] invalidJson = "{invalid}".getBytes(); + + ConfigurationParseException exception = + assertThrows( + ConfigurationParseException.class, () -> parser.parseBanditParams(invalidJson)); + + assertThat(exception.getMessage()).contains("Failed to parse bandit parameters"); + assertNotNull(exception.getCause()); + } + + @Test + public void testParseFlagConfigEmptyFlags() { + byte[] emptyFlagsJson = "{\"flags\": {}}".getBytes(); + + FlagConfigResponse response = parser.parseFlagConfig(emptyFlagsJson); + + assertNotNull(response); + assertThat(response.getFlags()).isEmpty(); + } + + @Test + public void testParseBanditParamsEmptyBandits() { + byte[] emptyBanditsJson = "{\"bandits\": {}}".getBytes(); + + Map bandits = parser.parseBanditParams(emptyBanditsJson); + + assertNotNull(bandits); + assertThat(bandits).isEmpty(); + } + + @Test + public void testParseFlagConfigWithBanditReferences() throws IOException { + byte[] flagConfigJson = loadTestResource("shared/ufc/bandit-flags-v1.json"); + + FlagConfigResponse response = parser.parseFlagConfig(flagConfigJson); + + assertNotNull(response.getBanditReferences()); + assertThat(response.getBanditReferences()).isNotEmpty(); + } + + private byte[] loadTestResource(String relativePath) throws IOException { + // Test resources are in the root project, so we need to go up from eppo-sdk-common + Path path = Path.of("../src/test/resources", relativePath); + return Files.readAllBytes(path); + } +} diff --git a/eppo-sdk-common/src/test/java/cloud/eppo/OkHttpEppoClientTest.java b/eppo-sdk-common/src/test/java/cloud/eppo/OkHttpEppoClientTest.java new file mode 100644 index 00000000..6d343f0e --- /dev/null +++ b/eppo-sdk-common/src/test/java/cloud/eppo/OkHttpEppoClientTest.java @@ -0,0 +1,196 @@ +package cloud.eppo; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import cloud.eppo.http.EppoConfigurationRequest; +import cloud.eppo.http.EppoConfigurationResponse; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class OkHttpEppoClientTest { + private MockWebServer mockWebServer; + private OkHttpEppoClient client; + private String baseUrl; + + @BeforeEach + public void setUp() throws IOException { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + baseUrl = mockWebServer.url("").toString(); + // Remove trailing slash if present + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.substring(0, baseUrl.length() - 1); + } + client = new OkHttpEppoClient(); + } + + @AfterEach + public void tearDown() throws IOException { + mockWebServer.shutdown(); + } + + @Test + public void testSuccessfulGet() throws ExecutionException, InterruptedException { + String responseBody = "{\"flags\": {}}"; + mockWebServer.enqueue( + new MockResponse().setResponseCode(200).setHeader("ETag", "v1").setBody(responseBody)); + + EppoConfigurationRequest request = createRequest(null); + CompletableFuture future = client.get(request); + EppoConfigurationResponse response = future.get(); + + assertTrue(response.isSuccessful()); + assertFalse(response.isNotModified()); + assertEquals(200, response.getStatusCode()); + assertEquals("v1", response.getVersionId()); + assertThat(new String(response.getBody())).isEqualTo(responseBody); + } + + @Test + public void testNotModifiedResponse() throws ExecutionException, InterruptedException { + mockWebServer.enqueue(new MockResponse().setResponseCode(304).setHeader("ETag", "v1")); + + EppoConfigurationRequest request = createRequest("v1"); + CompletableFuture future = client.get(request); + EppoConfigurationResponse response = future.get(); + + assertTrue(response.isNotModified()); + assertFalse(response.isSuccessful()); + assertEquals(304, response.getStatusCode()); + assertEquals("v1", response.getVersionId()); + assertNull(response.getBody()); + } + + @Test + public void testConditionalRequestSendsIfNoneMatchHeader() + throws ExecutionException, InterruptedException, InterruptedException { + mockWebServer.enqueue(new MockResponse().setResponseCode(304).setHeader("ETag", "v1")); + + EppoConfigurationRequest request = createRequest("v1"); + client.get(request).get(); + + RecordedRequest recordedRequest = mockWebServer.takeRequest(); + assertEquals("v1", recordedRequest.getHeader("If-None-Match")); + } + + @Test + public void testNoIfNoneMatchHeaderWhenNoVersionId() + throws ExecutionException, InterruptedException, InterruptedException { + mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + EppoConfigurationRequest request = createRequest(null); + client.get(request).get(); + + RecordedRequest recordedRequest = mockWebServer.takeRequest(); + assertNull(recordedRequest.getHeader("If-None-Match")); + } + + @Test + public void testQueryParametersAreIncluded() + throws ExecutionException, InterruptedException, InterruptedException { + mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + EppoConfigurationRequest request = createRequest(null); + client.get(request).get(); + + RecordedRequest recordedRequest = mockWebServer.takeRequest(); + String path = recordedRequest.getPath(); + assertThat(path).contains("apiKey=test-key"); + assertThat(path).contains("sdkName=test-sdk"); + assertThat(path).contains("sdkVersion=1.0.0"); + } + + @Test + public void testErrorResponse() throws ExecutionException, InterruptedException { + String errorBody = "Internal Server Error"; + mockWebServer.enqueue(new MockResponse().setResponseCode(500).setBody(errorBody)); + + EppoConfigurationRequest request = createRequest(null); + CompletableFuture future = client.get(request); + EppoConfigurationResponse response = future.get(); + + assertFalse(response.isSuccessful()); + assertFalse(response.isNotModified()); + assertEquals(500, response.getStatusCode()); + assertThat(new String(response.getBody())).isEqualTo(errorBody); + } + + @Test + public void testForbiddenResponse() throws ExecutionException, InterruptedException { + mockWebServer.enqueue(new MockResponse().setResponseCode(403).setBody("Forbidden")); + + EppoConfigurationRequest request = createRequest(null); + CompletableFuture future = client.get(request); + EppoConfigurationResponse response = future.get(); + + assertFalse(response.isSuccessful()); + assertEquals(403, response.getStatusCode()); + } + + @Test + public void testConnectionFailure() throws IOException { + mockWebServer.shutdown(); + + EppoConfigurationRequest request = createRequest(null); + CompletableFuture future = client.get(request); + + ExecutionException exception = assertThrows(ExecutionException.class, future::get); + assertThat(exception.getCause()).isInstanceOf(RuntimeException.class); + assertThat(exception.getCause().getMessage()).contains("Unable to fetch from URL"); + } + + @Test + public void testApiKeyRedactedInErrorMessage() throws IOException { + mockWebServer.shutdown(); + + EppoConfigurationRequest request = createRequest(null); + CompletableFuture future = client.get(request); + + ExecutionException exception = assertThrows(ExecutionException.class, future::get); + String errorMessage = exception.getCause().getMessage(); + + assertThat(errorMessage).doesNotContain("test-key"); + assertThat(errorMessage).contains("apiKey="); + } + + @Test + public void testETagExtractedFromResponse() throws ExecutionException, InterruptedException { + mockWebServer.enqueue( + new MockResponse().setResponseCode(200).setHeader("ETag", "\"abc123\"").setBody("{}")); + + EppoConfigurationRequest request = createRequest(null); + EppoConfigurationResponse response = client.get(request).get(); + + assertEquals("\"abc123\"", response.getVersionId()); + } + + @Test + public void testNoETagInResponse() throws ExecutionException, InterruptedException { + mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + EppoConfigurationRequest request = createRequest(null); + EppoConfigurationResponse response = client.get(request).get(); + + assertNull(response.getVersionId()); + } + + private EppoConfigurationRequest createRequest(String lastVersionId) { + Map queryParams = new HashMap<>(); + queryParams.put("apiKey", "test-key"); + queryParams.put("sdkName", "test-sdk"); + queryParams.put("sdkVersion", "1.0.0"); + + return new EppoConfigurationRequest( + baseUrl, "/api/flag-config/v1/config", queryParams, lastVersionId); + } +} diff --git a/settings.gradle b/settings.gradle index abaa2d4e..3eeab904 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,7 @@ rootProject.name = 'sdk-common-jvm' +include 'eppo-sdk-common' + dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { diff --git a/src/test/resources/flags-v1.json b/src/test/resources/flags-v1.json index 0db3b974..dad6ceb4 100644 --- a/src/test/resources/flags-v1.json +++ b/src/test/resources/flags-v1.json @@ -1,5 +1,9 @@ { "createdAt": "2024-04-17T19:40:53.716Z", + "environment": { + "name": "Test" + }, + "format": "SERVER", "flags": { "empty_flag": { "key": "empty_flag", From 99d57e2bd0831b789992dc536cdbefc14ecddcd9 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Tue, 10 Feb 2026 12:35:12 -0700 Subject: [PATCH 10/44] build deps --- eppo-sdk-common/build.gradle | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/eppo-sdk-common/build.gradle b/eppo-sdk-common/build.gradle index 34f23436..5eabf1fc 100644 --- a/eppo-sdk-common/build.gradle +++ b/eppo-sdk-common/build.gradle @@ -36,16 +36,11 @@ tasks.register('javadocJar', Jar) { } dependencies { - // Depend on the core SDK module for interfaces and DTOs api project(':') - // OkHttp 5 for HTTP client implementation - implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.14' - - // Jackson for JSON parsing (already in parent, but explicit for this module) + implementation 'com.squareup.okhttp3:okhttp:5.2.0' implementation 'com.fasterxml.jackson.core:jackson-databind:2.20.1' - // Logging implementation 'org.slf4j:slf4j-api:2.0.17' // Test dependencies From 2a86c3d9528026db14b55ac4d32e992358bb96bb Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 11 Feb 2026 15:16:02 -0700 Subject: [PATCH 11/44] adust to parser interface --- .../eppo/JacksonConfigurationParser.java | 39 +------------------ .../eppo/JacksonConfigurationParserTest.java | 11 ++++-- 2 files changed, 10 insertions(+), 40 deletions(-) diff --git a/eppo-sdk-common/src/main/java/cloud/eppo/JacksonConfigurationParser.java b/eppo-sdk-common/src/main/java/cloud/eppo/JacksonConfigurationParser.java index 822a43ec..d100e374 100644 --- a/eppo-sdk-common/src/main/java/cloud/eppo/JacksonConfigurationParser.java +++ b/eppo-sdk-common/src/main/java/cloud/eppo/JacksonConfigurationParser.java @@ -1,15 +1,12 @@ package cloud.eppo; -import cloud.eppo.api.dto.BanditParameters; import cloud.eppo.api.dto.BanditParametersResponse; import cloud.eppo.api.dto.FlagConfigResponse; import cloud.eppo.parser.ConfigurationParseException; import cloud.eppo.parser.ConfigurationParser; import cloud.eppo.ufc.dto.adapters.EppoModule; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; -import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -60,45 +57,13 @@ public FlagConfigResponse parseFlagConfig(byte[] flagConfigJson) } @Override - public Map parseBanditParams(byte[] banditParamsJson) + public BanditParametersResponse parseBanditParams(byte[] banditParamsJson) throws ConfigurationParseException { try { log.debug("Parsing bandit parameters, {} bytes", banditParamsJson.length); - BanditParametersResponse response = - objectMapper.readValue(banditParamsJson, BanditParametersResponse.class); - return response.getBandits(); + return objectMapper.readValue(banditParamsJson, BanditParametersResponse.class); } catch (IOException e) { throw new ConfigurationParseException("Failed to parse bandit parameters", e); } } - - @Override - public byte[] serializeFlagConfig(FlagConfigResponse flagConfigResponse) - throws ConfigurationParseException { - try { - log.debug("Serializing flag configuration"); - return objectMapper.writeValueAsBytes(flagConfigResponse); - } catch (JsonProcessingException e) { - throw new ConfigurationParseException("Failed to serialize flag configuration", e); - } - } - - @Override - public byte[] serializeBanditParams(Map banditParams) - throws ConfigurationParseException { - try { - log.debug("Serializing bandit parameters"); - BanditParametersResponse response = - new BanditParametersResponse.Default(castBanditMap(banditParams)); - return objectMapper.writeValueAsBytes(response); - } catch (JsonProcessingException e) { - throw new ConfigurationParseException("Failed to serialize bandit parameters", e); - } - } - - @SuppressWarnings("unchecked") - private Map castBanditMap( - Map banditParams) { - return (Map) banditParams; - } } diff --git a/eppo-sdk-common/src/test/java/cloud/eppo/JacksonConfigurationParserTest.java b/eppo-sdk-common/src/test/java/cloud/eppo/JacksonConfigurationParserTest.java index 8a710343..9c56270a 100644 --- a/eppo-sdk-common/src/test/java/cloud/eppo/JacksonConfigurationParserTest.java +++ b/eppo-sdk-common/src/test/java/cloud/eppo/JacksonConfigurationParserTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.*; import cloud.eppo.api.dto.BanditParameters; +import cloud.eppo.api.dto.BanditParametersResponse; import cloud.eppo.api.dto.FlagConfig; import cloud.eppo.api.dto.FlagConfigResponse; import cloud.eppo.api.dto.VariationType; @@ -47,9 +48,11 @@ public void testParseFlagConfig() throws IOException { public void testParseBanditParams() throws IOException { byte[] banditParamsJson = loadTestResource("shared/ufc/bandit-models-v1.json"); - Map bandits = parser.parseBanditParams(banditParamsJson); + BanditParametersResponse banditsResponse = parser.parseBanditParams(banditParamsJson); - assertNotNull(bandits); + assertNotNull(banditsResponse); + assertNotNull(banditsResponse.getBandits()); + Map bandits = banditsResponse.getBandits(); assertThat(bandits).containsKey("banner_bandit"); BanditParameters bannerBandit = bandits.get("banner_bandit"); @@ -98,8 +101,10 @@ public void testParseFlagConfigEmptyFlags() { public void testParseBanditParamsEmptyBandits() { byte[] emptyBanditsJson = "{\"bandits\": {}}".getBytes(); - Map bandits = parser.parseBanditParams(emptyBanditsJson); + BanditParametersResponse banditsResponse = parser.parseBanditParams(emptyBanditsJson); + Map bandits = banditsResponse.getBandits(); + assertNotNull(banditsResponse); assertNotNull(bandits); assertThat(bandits).isEmpty(); } From 3db6b187045954e9157cfb13f57f37b89d0e0691 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 11 Feb 2026 15:56:14 -0700 Subject: [PATCH 12/44] temp: exlcude duplicates --- eppo-sdk-common/build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/eppo-sdk-common/build.gradle b/eppo-sdk-common/build.gradle index 5eabf1fc..dfff8f78 100644 --- a/eppo-sdk-common/build.gradle +++ b/eppo-sdk-common/build.gradle @@ -33,6 +33,9 @@ tasks.register('javadocJar', Jar) { archiveClassifier.set('javadoc') from javadoc from project(':').tasks.javadoc + + // Temporary fix until the clean-up is down and jackson parsing is removed from the framework. + duplicatesStrategy = DuplicatesStrategy.EXCLUDE } dependencies { From 2637c4eaf1a8c8c366cec7737d0cc935f7fc2f30 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 12 Feb 2026 12:47:48 -0700 Subject: [PATCH 13/44] implement unwrapper --- .../cloud/eppo/JacksonConfigurationParser.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/eppo-sdk-common/src/main/java/cloud/eppo/JacksonConfigurationParser.java b/eppo-sdk-common/src/main/java/cloud/eppo/JacksonConfigurationParser.java index d100e374..ce06dc86 100644 --- a/eppo-sdk-common/src/main/java/cloud/eppo/JacksonConfigurationParser.java +++ b/eppo-sdk-common/src/main/java/cloud/eppo/JacksonConfigurationParser.java @@ -5,8 +5,10 @@ import cloud.eppo.parser.ConfigurationParseException; import cloud.eppo.parser.ConfigurationParser; import cloud.eppo.ufc.dto.adapters.EppoModule; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; +import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -17,7 +19,7 @@ * format. The deserializers are hand-rolled to avoid reliance on annotations and method names, * which can be unreliable when ProGuard minification is in use. */ -public class JacksonConfigurationParser implements ConfigurationParser { +public class JacksonConfigurationParser implements ConfigurationParser { private static final Logger log = LoggerFactory.getLogger(JacksonConfigurationParser.class); private final ObjectMapper objectMapper; @@ -66,4 +68,14 @@ public BanditParametersResponse parseBanditParams(byte[] banditParamsJson) throw new ConfigurationParseException("Failed to parse bandit parameters", e); } } + + @Override + public @NotNull JsonNode parseJsonValue(@NotNull String jsonValue) + throws ConfigurationParseException { + try { + return objectMapper.readTree(jsonValue); + } catch (IOException e) { + throw new ConfigurationParseException("Failed to parse JSON value", e); + } + } } From 8ce04fbb20b4bd2fea08f8dc2076d26a4cb462ca Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 12 Feb 2026 12:57:24 -0700 Subject: [PATCH 14/44] j8 --- .../test/java/cloud/eppo/JacksonConfigurationParserTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/eppo-sdk-common/src/test/java/cloud/eppo/JacksonConfigurationParserTest.java b/eppo-sdk-common/src/test/java/cloud/eppo/JacksonConfigurationParserTest.java index 9c56270a..2315e20f 100644 --- a/eppo-sdk-common/src/test/java/cloud/eppo/JacksonConfigurationParserTest.java +++ b/eppo-sdk-common/src/test/java/cloud/eppo/JacksonConfigurationParserTest.java @@ -12,6 +12,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -121,7 +122,7 @@ public void testParseFlagConfigWithBanditReferences() throws IOException { private byte[] loadTestResource(String relativePath) throws IOException { // Test resources are in the root project, so we need to go up from eppo-sdk-common - Path path = Path.of("../src/test/resources", relativePath); + Path path = Paths.get("../src/test/resources", relativePath); return Files.readAllBytes(path); } } From faed7dce4363b77ec079d3274eeabe47f5d83d79 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Tue, 10 Feb 2026 14:22:05 -0700 Subject: [PATCH 15/44] Optionally use ConfigurationParser and ConfigurationRequestClients in the ConfigurationRequestor --- build.gradle | 3 + eppo-sdk-common/build.gradle | 4 +- src/main/java/cloud/eppo/BaseEppoClient.java | 58 +- .../cloud/eppo/ConfigurationRequestor.java | 231 ++++- .../cloud/eppo/BaseEppoClientBanditTest.java | 6 +- .../eppo/ConfigurationRequestorTest.java | 927 ++++++++++++------ .../cloud/eppo/ProfileBaseEppoClientTest.java | 2 + 7 files changed, 913 insertions(+), 318 deletions(-) diff --git a/build.gradle b/build.gradle index 6117f296..28ec2ea3 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,9 @@ dependencies { } testImplementation 'net.bytebuddy:byte-buddy:1.18.1' // Use the latest available version testImplementation 'org.mockito:mockito-inline:4.11.0' + + // Use common http/parser implementations in tests. + testImplementation project(':eppo-sdk-common') } test { diff --git a/eppo-sdk-common/build.gradle b/eppo-sdk-common/build.gradle index dfff8f78..b849fa64 100644 --- a/eppo-sdk-common/build.gradle +++ b/eppo-sdk-common/build.gradle @@ -41,7 +41,7 @@ tasks.register('javadocJar', Jar) { dependencies { api project(':') - implementation 'com.squareup.okhttp3:okhttp:5.2.0' + implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'com.fasterxml.jackson.core:jackson-databind:2.20.1' implementation 'org.slf4j:slf4j-api:2.0.17' @@ -50,7 +50,7 @@ dependencies { testImplementation platform('org.junit:junit-bom:5.14.1') testImplementation 'org.junit.jupiter:junit-jupiter' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - testImplementation 'com.squareup.okhttp3:mockwebserver:5.0.0-alpha.14' + testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' testImplementation 'com.google.truth:truth:1.4.5' } diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index 6ae670f1..1932e9e8 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -9,11 +9,13 @@ import cloud.eppo.api.dto.FlagConfig; import cloud.eppo.api.dto.VariationType; import cloud.eppo.cache.AssignmentCacheEntry; +import cloud.eppo.http.EppoConfigurationClient; +import cloud.eppo.http.EppoConfigurationRequestFactory; import cloud.eppo.logging.Assignment; import cloud.eppo.logging.AssignmentLogger; import cloud.eppo.logging.BanditAssignment; import cloud.eppo.logging.BanditLogger; -import com.fasterxml.jackson.databind.JsonNode; +import cloud.eppo.parser.ConfigurationParser; import java.util.HashMap; import java.util.Map; import java.util.Timer; @@ -49,6 +51,37 @@ public class BaseEppoClient { /** @noinspection FieldMayBeFinal */ private static EppoHttpClient httpClientOverride = null; + protected BaseEppoClient( + @NotNull String apiKey, + @NotNull String sdkName, + @NotNull String sdkVersion, + @Nullable String apiBaseUrl, + @Nullable AssignmentLogger assignmentLogger, + @Nullable BanditLogger banditLogger, + @Nullable IConfigurationStore configurationStore, + boolean isGracefulMode, + boolean expectObfuscatedConfig, + boolean supportBandits, + @Nullable CompletableFuture initialConfiguration, + @Nullable IAssignmentCache assignmentCache, + @Nullable IAssignmentCache banditAssignmentCache) { + this( + apiKey, + sdkName, + sdkVersion, + apiBaseUrl, + assignmentLogger, + banditLogger, + configurationStore, + isGracefulMode, + expectObfuscatedConfig, + supportBandits, + initialConfiguration, + assignmentCache, + banditAssignmentCache, + null, + null); + } // It is important that the bandit assignment cache expire with a short-enough TTL to last about // one user session. // The recommended is 10 minutes (per @Sven) @@ -65,7 +98,9 @@ protected BaseEppoClient( boolean supportBandits, @Nullable CompletableFuture initialConfiguration, @Nullable IAssignmentCache assignmentCache, - @Nullable IAssignmentCache banditAssignmentCache) { + @Nullable IAssignmentCache banditAssignmentCache, + @Nullable ConfigurationParser configurationParser, + @Nullable EppoConfigurationClient eppoConfigurationClient) { if (apiBaseUrl == null) { apiBaseUrl = Constants.DEFAULT_BASE_URL; @@ -74,15 +109,28 @@ protected BaseEppoClient( this.assignmentCache = assignmentCache; this.banditAssignmentCache = banditAssignmentCache; - EppoHttpClient httpClient = - buildHttpClient(apiBaseUrl, new SDKKey(apiKey), sdkName, sdkVersion); + SDKKey sdkKey = new SDKKey(apiKey); + ApiEndpoints endpointHelper = new ApiEndpoints(sdkKey, apiBaseUrl); + String effectiveBaseUrl = endpointHelper.getBaseUrl(); + + EppoHttpClient httpClient = buildHttpClient(apiBaseUrl, sdkKey, sdkName, sdkVersion); this.configurationStore = configurationStore != null ? configurationStore : new ConfigurationStore(); + EppoConfigurationRequestFactory requestFactory = + new EppoConfigurationRequestFactory( + effectiveBaseUrl, sdkKey.getToken(), sdkName, sdkVersion); + // For now, the configuration is only obfuscated for Android clients requestor = new ConfigurationRequestor( - this.configurationStore, httpClient, expectObfuscatedConfig, supportBandits); + this.configurationStore, + httpClient, + expectObfuscatedConfig, + supportBandits, + configurationParser, + eppoConfigurationClient, + requestFactory); initialConfigFuture = initialConfiguration != null ? requestor.setInitialConfiguration(initialConfiguration) diff --git a/src/main/java/cloud/eppo/ConfigurationRequestor.java b/src/main/java/cloud/eppo/ConfigurationRequestor.java index ce965c89..da256ba9 100644 --- a/src/main/java/cloud/eppo/ConfigurationRequestor.java +++ b/src/main/java/cloud/eppo/ConfigurationRequestor.java @@ -1,11 +1,21 @@ package cloud.eppo; import cloud.eppo.api.Configuration; +import cloud.eppo.api.dto.BanditParameters; +import cloud.eppo.api.dto.FlagConfigResponse; import cloud.eppo.callback.CallbackManager; +import cloud.eppo.http.EppoConfigurationClient; +import cloud.eppo.http.EppoConfigurationRequest; +import cloud.eppo.http.EppoConfigurationRequestFactory; +import cloud.eppo.http.EppoConfigurationResponse; +import cloud.eppo.parser.ConfigurationParseException; +import cloud.eppo.parser.ConfigurationParser; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.function.Consumer; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,6 +26,14 @@ public class ConfigurationRequestor { private final IConfigurationStore configurationStore; private final boolean supportBandits; + // Optional custom implementations + @Nullable private final ConfigurationParser configurationParser; + @Nullable private final EppoConfigurationClient eppoConfigurationClient; + @NotNull private final EppoConfigurationRequestFactory requestFactory; + + // Track version IDs for conditional requests (used with EppoConfigurationClient) + private String lastFlagConfigVersionId = null; + private CompletableFuture remoteFetchFuture = null; private CompletableFuture configurationFuture = null; private boolean initialConfigSet = false; @@ -26,10 +44,16 @@ public ConfigurationRequestor( @NotNull IConfigurationStore configurationStore, @NotNull EppoHttpClient client, boolean expectObfuscatedConfig, - boolean supportBandits) { + boolean supportBandits, + @Nullable ConfigurationParser configurationParser, + @Nullable EppoConfigurationClient eppoConfigurationClient, + @NotNull EppoConfigurationRequestFactory requestFactory) { this.configurationStore = configurationStore; this.client = client; this.supportBandits = supportBandits; + this.configurationParser = configurationParser; + this.eppoConfigurationClient = eppoConfigurationClient; + this.requestFactory = requestFactory; } // Synchronously set the initial configuration. @@ -86,18 +110,97 @@ void fetchAndSaveFromRemote() { // Reuse the `lastConfig` as its bandits may be useful Configuration lastConfig = configurationStore.getConfiguration(); - byte[] flagConfigurationJsonBytes = client.get(Constants.FLAG_CONFIG_ENDPOINT); - Configuration.Builder configBuilder = - Configuration.builder(flagConfigurationJsonBytes).banditParametersFromConfig(lastConfig); + byte[] flagConfigurationJsonBytes; + FlagConfigResponse flagConfigResponse = null; + + // Use EppoConfigurationClient if available, otherwise fall back to EppoHttpClient + if (eppoConfigurationClient != null) { + EppoConfigurationRequest flagRequest = + requestFactory.createFlagConfigRequest(lastFlagConfigVersionId); + EppoConfigurationResponse flagResponse; + try { + flagResponse = eppoConfigurationClient.get(flagRequest).get(); + } catch (InterruptedException | ExecutionException e) { + log.error("Config fetch interrupted", e); + throw new RuntimeException(e); + } + + if (flagResponse.isNotModified()) { + log.debug("Flag configuration not modified"); + return; + } + + if (!flagResponse.isSuccessful()) { + throw new RuntimeException( + "Failed to fetch flag configuration. Status: " + flagResponse.getStatusCode()); + } + + lastFlagConfigVersionId = flagResponse.getVersionId(); + flagConfigurationJsonBytes = flagResponse.getBody(); + } else { + flagConfigurationJsonBytes = client.get(Constants.FLAG_CONFIG_ENDPOINT); + } + + // Use ConfigurationParser if available, otherwise use Configuration.builder + Configuration.Builder configBuilder; + if (configurationParser != null) { + try { + flagConfigResponse = configurationParser.parseFlagConfig(flagConfigurationJsonBytes); + configBuilder = + new Configuration.Builder(flagConfigurationJsonBytes, flagConfigResponse) + .banditParametersFromConfig(lastConfig); + } catch (ConfigurationParseException e) { + log.error("Failed to parse flag configuration", e); + throw new RuntimeException(e); + } + } else { + configBuilder = + Configuration.builder(flagConfigurationJsonBytes).banditParametersFromConfig(lastConfig); + } if (supportBandits && configBuilder.requiresUpdatedBanditModels()) { - byte[] banditParametersJsonBytes = client.get(Constants.BANDIT_ENDPOINT); - configBuilder.banditParameters(banditParametersJsonBytes); + byte[] banditParametersJsonBytes = fetchBanditParameters(); + if (banditParametersJsonBytes != null) { + if (configurationParser != null) { + try { + Map bandits = + configurationParser.parseBanditParams(banditParametersJsonBytes); + // Note: Configuration.Builder handles bandits directly from bytes for now + configBuilder.banditParameters(banditParametersJsonBytes); + } catch (ConfigurationParseException e) { + log.error("Failed to parse bandit parameters", e); + throw new RuntimeException(e); + } + } else { + configBuilder.banditParameters(banditParametersJsonBytes); + } + } } saveConfigurationAndNotify(configBuilder.build()).join(); } + /** Fetches bandit parameters using the appropriate client. */ + private byte[] fetchBanditParameters() { + if (eppoConfigurationClient != null) { + EppoConfigurationRequest banditRequest = requestFactory.createBanditParamsRequest(); + EppoConfigurationResponse banditResponse; + try { + banditResponse = eppoConfigurationClient.get(banditRequest).get(); + } catch (InterruptedException | ExecutionException e) { + log.error("Bandit fetch interrupted", e); + throw new RuntimeException(e); + } + + if (banditResponse.isSuccessful() && banditResponse.getBody() != null) { + return banditResponse.getBody(); + } + return null; + } else { + return client.get(Constants.BANDIT_ENDPOINT); + } + } + /** Loads configuration asynchronously from the API server, off-thread. */ CompletableFuture fetchAndSaveFromRemoteAsync() { log.debug("Fetching configuration from API server"); @@ -109,37 +212,105 @@ CompletableFuture fetchAndSaveFromRemoteAsync() { remoteFetchFuture = null; } - remoteFetchFuture = - client - .getAsync(Constants.FLAG_CONFIG_ENDPOINT) - .thenCompose( - flagConfigJsonBytes -> { - synchronized (this) { - Configuration.Builder configBuilder = - Configuration.builder(flagConfigJsonBytes) - .banditParametersFromConfig( - lastConfig); // possibly reuse last bandit models loaded. - - if (supportBandits && configBuilder.requiresUpdatedBanditModels()) { - byte[] banditParametersJsonBytes; - try { - banditParametersJsonBytes = - client.getAsync(Constants.BANDIT_ENDPOINT).get(); - } catch (InterruptedException | ExecutionException e) { - log.error("Error fetching from remote: " + e.getMessage()); - throw new RuntimeException(e); + // Use EppoConfigurationClient if available, otherwise fall back to EppoHttpClient + if (eppoConfigurationClient != null) { + EppoConfigurationRequest flagRequest = + requestFactory.createFlagConfigRequest(lastFlagConfigVersionId); + + remoteFetchFuture = + eppoConfigurationClient + .get(flagRequest) + .thenCompose( + flagResponse -> { + synchronized (this) { + if (flagResponse.isNotModified()) { + log.debug("Flag configuration not modified"); + return CompletableFuture.completedFuture(null); } - if (banditParametersJsonBytes != null) { - configBuilder.banditParameters(banditParametersJsonBytes); + + if (!flagResponse.isSuccessful()) { + throw new RuntimeException( + "Failed to fetch flag configuration. Status: " + + flagResponse.getStatusCode()); } + + lastFlagConfigVersionId = flagResponse.getVersionId(); + byte[] flagConfigJsonBytes = flagResponse.getBody(); + + return buildAndSaveConfiguration(flagConfigJsonBytes, lastConfig); + } + }); + } else { + remoteFetchFuture = + client + .getAsync(Constants.FLAG_CONFIG_ENDPOINT) + .thenCompose( + flagConfigJsonBytes -> { + synchronized (this) { + return buildAndSaveConfiguration(flagConfigJsonBytes, lastConfig); } + }); + } - return saveConfigurationAndNotify(configBuilder.build()); - } - }); return remoteFetchFuture; } + /** Builds configuration from flag bytes and saves it. Used by async fetch. */ + private CompletableFuture buildAndSaveConfiguration( + byte[] flagConfigJsonBytes, Configuration lastConfig) { + Configuration.Builder configBuilder; + + // Use ConfigurationParser if available + if (configurationParser != null) { + try { + FlagConfigResponse flagConfigResponse = + configurationParser.parseFlagConfig(flagConfigJsonBytes); + configBuilder = + new Configuration.Builder(flagConfigJsonBytes, flagConfigResponse) + .banditParametersFromConfig(lastConfig); + } catch (ConfigurationParseException e) { + log.error("Failed to parse flag configuration", e); + throw new RuntimeException(e); + } + } else { + configBuilder = + Configuration.builder(flagConfigJsonBytes).banditParametersFromConfig(lastConfig); + } + + if (supportBandits && configBuilder.requiresUpdatedBanditModels()) { + byte[] banditParametersJsonBytes = fetchBanditParametersAsync(); + if (banditParametersJsonBytes != null) { + configBuilder.banditParameters(banditParametersJsonBytes); + } + } + + return saveConfigurationAndNotify(configBuilder.build()); + } + + /** Fetches bandit parameters synchronously (used within async flow). */ + private byte[] fetchBanditParametersAsync() { + if (eppoConfigurationClient != null) { + EppoConfigurationRequest banditRequest = requestFactory.createBanditParamsRequest(); + try { + EppoConfigurationResponse banditResponse = eppoConfigurationClient.get(banditRequest).get(); + if (banditResponse.isSuccessful() && banditResponse.getBody() != null) { + return banditResponse.getBody(); + } + return null; + } catch (InterruptedException | ExecutionException e) { + log.error("Error fetching bandit parameters: " + e.getMessage()); + throw new RuntimeException(e); + } + } else { + try { + return client.getAsync(Constants.BANDIT_ENDPOINT).get(); + } catch (InterruptedException | ExecutionException e) { + log.error("Error fetching from remote: " + e.getMessage()); + throw new RuntimeException(e); + } + } + } + private CompletableFuture saveConfigurationAndNotify(Configuration configuration) { CompletableFuture saveFuture = configurationStore.saveConfiguration(configuration); return saveFuture.thenRun( diff --git a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java index 873804d5..9adbbb0e 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java @@ -78,7 +78,9 @@ public static void initClient() { null, new AbstractAssignmentCache(assignmentCache) {}, new ExpiringInMemoryAssignmentCache( - banditAssignmentCache, 50, TimeUnit.MILLISECONDS) {}); + banditAssignmentCache, 50, TimeUnit.MILLISECONDS) {}, + null, + null); eppoClient.loadConfiguration(); @@ -107,6 +109,8 @@ private BaseEppoClient initClientWithData( true, initialConfig, null, + null, + null, null); } diff --git a/src/test/java/cloud/eppo/ConfigurationRequestorTest.java b/src/test/java/cloud/eppo/ConfigurationRequestorTest.java index 67fbdec4..37a305c8 100644 --- a/src/test/java/cloud/eppo/ConfigurationRequestorTest.java +++ b/src/test/java/cloud/eppo/ConfigurationRequestorTest.java @@ -3,10 +3,16 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.*; import cloud.eppo.api.Configuration; +import cloud.eppo.api.dto.FlagConfigResponse; +import cloud.eppo.http.EppoConfigurationClient; +import cloud.eppo.http.EppoConfigurationRequest; +import cloud.eppo.http.EppoConfigurationRequestFactory; +import cloud.eppo.http.EppoConfigurationResponse; +import cloud.eppo.parser.ConfigurationParser; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -18,365 +24,726 @@ import java.util.function.Consumer; import org.apache.commons.io.FileUtils; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mockito.Mockito; public class ConfigurationRequestorTest { - private final File initialFlagConfigFile = + + // ==================== Shared Test Fixtures ==================== + + private static final File INITIAL_FLAG_CONFIG_FILE = new File("src/test/resources/static/initial-flag-config.json"); - private final File differentFlagConfigFile = + private static final File DIFFERENT_FLAG_CONFIG_FILE = new File("src/test/resources/static/boolean-flag.json"); - @Test - public void testInitialConfigurationFuture() throws IOException { - IConfigurationStore configStore = Mockito.spy(new ConfigurationStore()); - EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); + private static EppoConfigurationRequestFactory createTestRequestFactory() { + return new EppoConfigurationRequestFactory( + "https://test.eppo.cloud", "test-api-key", "java", "1.0.0"); + } - ConfigurationRequestor requestor = - new ConfigurationRequestor(configStore, mockHttpClient, false, true); + private static byte[] loadInitialFlagConfig() throws IOException { + return FileUtils.readFileToByteArray(INITIAL_FLAG_CONFIG_FILE); + } - CompletableFuture futureConfig = new CompletableFuture<>(); - byte[] flagConfig = FileUtils.readFileToByteArray(initialFlagConfigFile); + private static String loadInitialFlagConfigString() throws IOException { + return FileUtils.readFileToString(INITIAL_FLAG_CONFIG_FILE, StandardCharsets.UTF_8); + } - requestor.setInitialConfiguration(futureConfig); + // ==================== Initial Configuration Tests ==================== + + @Nested + class InitialConfigurationTests { + private IConfigurationStore configStore; + private EppoHttpClient mockHttpClient; + private ConfigurationRequestor requestor; + + @BeforeEach + void setUp() { + configStore = Mockito.spy(new ConfigurationStore()); + mockHttpClient = mock(EppoHttpClient.class); + requestor = + new ConfigurationRequestor( + configStore, mockHttpClient, false, true, null, null, createTestRequestFactory()); + } - // verify config is empty to start - assertTrue(configStore.getConfiguration().isEmpty()); - assertEquals(Collections.emptySet(), configStore.getConfiguration().getFlagKeys()); - Mockito.verify(configStore, Mockito.times(0)).saveConfiguration(any()); + @Test + void testInitialConfigurationFuture() throws IOException { + CompletableFuture futureConfig = new CompletableFuture<>(); + byte[] flagConfig = loadInitialFlagConfig(); - futureConfig.complete(Configuration.builder(flagConfig).build()); + requestor.setInitialConfiguration(futureConfig); - assertFalse(configStore.getConfiguration().isEmpty()); - assertFalse(configStore.getConfiguration().getFlagKeys().isEmpty()); - Mockito.verify(configStore, Mockito.times(1)).saveConfiguration(any()); - assertNotNull(configStore.getConfiguration().getFlag("numeric_flag")); - } + // verify config is empty to start + assertTrue(configStore.getConfiguration().isEmpty()); + assertEquals(Collections.emptySet(), configStore.getConfiguration().getFlagKeys()); + verify(configStore, times(0)).saveConfiguration(any()); - @Test - public void testInitialConfigurationDoesntClobberFetch() throws IOException { - IConfigurationStore configStore = Mockito.spy(new ConfigurationStore()); - EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); + futureConfig.complete(Configuration.builder(flagConfig).build()); - ConfigurationRequestor requestor = - new ConfigurationRequestor(configStore, mockHttpClient, false, true); + assertFalse(configStore.getConfiguration().isEmpty()); + assertFalse(configStore.getConfiguration().getFlagKeys().isEmpty()); + verify(configStore, times(1)).saveConfiguration(any()); + assertNotNull(configStore.getConfiguration().getFlag("numeric_flag")); + } - CompletableFuture initialConfigFuture = new CompletableFuture<>(); - String flagConfig = FileUtils.readFileToString(initialFlagConfigFile, StandardCharsets.UTF_8); - CompletableFuture configFetchFuture = new CompletableFuture<>(); - String fetchedFlagConfig = - FileUtils.readFileToString(differentFlagConfigFile, StandardCharsets.UTF_8); + @Test + void testInitialConfigurationDoesntClobberFetch() throws IOException { + CompletableFuture initialConfigFuture = new CompletableFuture<>(); + String flagConfig = loadInitialFlagConfigString(); + CompletableFuture configFetchFuture = new CompletableFuture<>(); + String fetchedFlagConfig = + FileUtils.readFileToString(DIFFERENT_FLAG_CONFIG_FILE, StandardCharsets.UTF_8); - when(mockHttpClient.getAsync("/flag-config/v1/config")).thenReturn(configFetchFuture); + when(mockHttpClient.getAsync("/flag-config/v1/config")).thenReturn(configFetchFuture); - // Set initial config and verify that no config has been set yet. - requestor.setInitialConfiguration(initialConfigFuture); + requestor.setInitialConfiguration(initialConfigFuture); - assertTrue(configStore.getConfiguration().isEmpty()); - assertEquals(Collections.emptySet(), configStore.getConfiguration().getFlagKeys()); - Mockito.verify(configStore, Mockito.times(0)).saveConfiguration(any()); + assertTrue(configStore.getConfiguration().isEmpty()); + assertEquals(Collections.emptySet(), configStore.getConfiguration().getFlagKeys()); + verify(configStore, times(0)).saveConfiguration(any()); - // The initial config contains only one flag keyed `numeric_flag`. The fetch response has only - // one flag keyed - // `boolean_flag`. We make sure to complete the fetch future first to verify the cache load does - // not overwrite it. - CompletableFuture handle = requestor.fetchAndSaveFromRemoteAsync(); + // The initial config contains only one flag keyed `numeric_flag`. The fetch response has only + // one flag keyed `boolean_flag`. We complete the fetch first to verify cache doesn't + // overwrite. + CompletableFuture handle = requestor.fetchAndSaveFromRemoteAsync(); - // Resolve the fetch and then the initialConfig - configFetchFuture.complete(fetchedFlagConfig.getBytes(StandardCharsets.UTF_8)); - initialConfigFuture.complete(new Configuration.Builder(flagConfig.getBytes()).build()); + configFetchFuture.complete(fetchedFlagConfig.getBytes(StandardCharsets.UTF_8)); + initialConfigFuture.complete(new Configuration.Builder(flagConfig.getBytes()).build()); - assertFalse(configStore.getConfiguration().isEmpty()); - assertFalse(configStore.getConfiguration().getFlagKeys().isEmpty()); - Mockito.verify(configStore, Mockito.times(1)).saveConfiguration(any()); + assertFalse(configStore.getConfiguration().isEmpty()); + assertFalse(configStore.getConfiguration().getFlagKeys().isEmpty()); + verify(configStore, times(1)).saveConfiguration(any()); - // `numeric_flag` is only in the cache which should have been ignored. - assertNull(configStore.getConfiguration().getFlag("numeric_flag")); + // `numeric_flag` is only in the cache which should have been ignored. + assertNull(configStore.getConfiguration().getFlag("numeric_flag")); + // `boolean_flag` is available only from the fetch + assertNotNull(configStore.getConfiguration().getFlag("boolean_flag")); + } - // `boolean_flag` is available only from the fetch - assertNotNull(configStore.getConfiguration().getFlag("boolean_flag")); - } + @Test + void testBrokenFetchDoesntClobberCache() throws IOException { + CompletableFuture initialConfigFuture = new CompletableFuture<>(); + String flagConfig = loadInitialFlagConfigString(); + CompletableFuture configFetchFuture = new CompletableFuture<>(); + + when(mockHttpClient.getAsync("/flag-config/v1/config")).thenReturn(configFetchFuture); + + requestor.setInitialConfiguration(initialConfigFuture); + + assertTrue(configStore.getConfiguration().isEmpty()); + assertEquals(Collections.emptySet(), configStore.getConfiguration().getFlagKeys()); + verify(configStore, times(0)).saveConfiguration(any()); - @Test - public void testBrokenFetchDoesntClobberCache() throws IOException { - IConfigurationStore configStore = Mockito.spy(new ConfigurationStore()); - EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); + requestor.fetchAndSaveFromRemoteAsync(); - ConfigurationRequestor requestor = - new ConfigurationRequestor(configStore, mockHttpClient, false, true); + initialConfigFuture.complete(new Configuration.Builder(flagConfig.getBytes()).build()); + configFetchFuture.completeExceptionally(new Exception("Intentional exception")); - CompletableFuture initialConfigFuture = new CompletableFuture<>(); - String flagConfig = FileUtils.readFileToString(initialFlagConfigFile, StandardCharsets.UTF_8); - CompletableFuture configFetchFuture = new CompletableFuture<>(); + assertFalse(configStore.getConfiguration().isEmpty()); + assertFalse(configStore.getConfiguration().getFlagKeys().isEmpty()); + verify(configStore, times(1)).saveConfiguration(any()); - when(mockHttpClient.getAsync("/flag-config/v1/config")).thenReturn(configFetchFuture); + assertNotNull(configStore.getConfiguration().getFlag("numeric_flag")); + assertNull(configStore.getConfiguration().getFlag("boolean_flag")); + } - // Set initial config and verify that no config has been set yet. - requestor.setInitialConfiguration(initialConfigFuture); + @Test + void testCacheWritesAfterBrokenFetch() throws IOException { + CompletableFuture initialConfigFuture = new CompletableFuture<>(); + String flagConfig = loadInitialFlagConfigString(); + CompletableFuture configFetchFuture = new CompletableFuture<>(); - assertTrue(configStore.getConfiguration().isEmpty()); - assertEquals(Collections.emptySet(), configStore.getConfiguration().getFlagKeys()); - Mockito.verify(configStore, Mockito.times(0)).saveConfiguration(any()); + when(mockHttpClient.getAsync("/flag-config/v1/config")).thenReturn(configFetchFuture); - requestor.fetchAndSaveFromRemoteAsync(); + requestor.setInitialConfiguration(initialConfigFuture); + verify(configStore, times(0)).saveConfiguration(any()); - // Resolve the initial config - initialConfigFuture.complete(new Configuration.Builder(flagConfig.getBytes()).build()); + assertTrue(configStore.getConfiguration().isEmpty()); + assertEquals(Collections.emptySet(), configStore.getConfiguration().getFlagKeys()); - // Error out the fetch - configFetchFuture.completeExceptionally(new Exception("Intentional exception")); + requestor.fetchAndSaveFromRemoteAsync(); + configFetchFuture.completeExceptionally(new Exception("Intentional exception")); - assertFalse(configStore.getConfiguration().isEmpty()); - assertFalse(configStore.getConfiguration().getFlagKeys().isEmpty()); - Mockito.verify(configStore, Mockito.times(1)).saveConfiguration(any()); + initialConfigFuture.complete(new Configuration.Builder(flagConfig.getBytes()).build()); - // `numeric_flag` is only in the cache which should be available - assertNotNull(configStore.getConfiguration().getFlag("numeric_flag")); + verify(configStore, times(1)).saveConfiguration(any()); + assertFalse(configStore.getConfiguration().isEmpty()); + assertFalse(configStore.getConfiguration().getFlagKeys().isEmpty()); - assertNull(configStore.getConfiguration().getFlag("boolean_flag")); + assertNotNull(configStore.getConfiguration().getFlag("numeric_flag")); + assertNull(configStore.getConfiguration().getFlag("boolean_flag")); + } } - @Test - public void testCacheWritesAfterBrokenFetch() throws IOException { - IConfigurationStore configStore = Mockito.spy(new ConfigurationStore()); - EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); + // ==================== Configuration Change Listener Tests ==================== + + @Nested + class ConfigurationChangeListenerTests { + private ConfigurationStore mockConfigStore; + private EppoHttpClient mockHttpClient; + private ConfigurationRequestor requestor; + + @BeforeEach + void setUp() { + mockConfigStore = mock(ConfigurationStore.class); + mockHttpClient = mock(EppoHttpClient.class); + requestor = + new ConfigurationRequestor( + mockConfigStore, mockHttpClient, false, true, null, null, createTestRequestFactory()); + } + + @Test + void testConfigurationChangeListener() throws IOException { + String flagConfig = loadInitialFlagConfigString(); + when(mockHttpClient.get(anyString())).thenReturn(flagConfig.getBytes()); + when(mockConfigStore.saveConfiguration(any())) + .thenReturn(CompletableFuture.completedFuture(null)); - ConfigurationRequestor requestor = - new ConfigurationRequestor(configStore, mockHttpClient, false, true); + List receivedConfigs = new ArrayList<>(); + Runnable unsubscribe = requestor.onConfigurationChange(receivedConfigs::add); - CompletableFuture initialConfigFuture = new CompletableFuture<>(); - String flagConfig = FileUtils.readFileToString(initialFlagConfigFile, StandardCharsets.UTF_8); - CompletableFuture configFetchFuture = new CompletableFuture<>(); + requestor.fetchAndSaveFromRemote(); + assertEquals(1, receivedConfigs.size()); - when(mockHttpClient.getAsync("/flag-config/v1/config")).thenReturn(configFetchFuture); + requestor.fetchAndSaveFromRemote(); + assertEquals(2, receivedConfigs.size()); - // Set initial config and verify that no config has been set yet. - requestor.setInitialConfiguration(initialConfigFuture); - Mockito.verify(configStore, Mockito.times(0)).saveConfiguration(any()); + unsubscribe.run(); + requestor.fetchAndSaveFromRemote(); + assertEquals(2, receivedConfigs.size()); // Count should remain the same + } - // default configuration is empty config. - assertTrue(configStore.getConfiguration().isEmpty()); - assertEquals(Collections.emptySet(), configStore.getConfiguration().getFlagKeys()); + @Test + void testMultipleConfigurationChangeListeners() { + when(mockHttpClient.get(anyString())).thenReturn("{}".getBytes()); + when(mockConfigStore.saveConfiguration(any())) + .thenReturn(CompletableFuture.completedFuture(null)); - // Fetch from remote with an error - requestor.fetchAndSaveFromRemoteAsync(); - configFetchFuture.completeExceptionally(new Exception("Intentional exception")); + AtomicInteger callCount1 = new AtomicInteger(0); + AtomicInteger callCount2 = new AtomicInteger(0); - // Resolve the initial config after the fetch throws an error. - initialConfigFuture.complete(new Configuration.Builder(flagConfig.getBytes()).build()); + Runnable unsubscribe1 = requestor.onConfigurationChange(v -> callCount1.incrementAndGet()); + Runnable unsubscribe2 = requestor.onConfigurationChange(v -> callCount2.incrementAndGet()); - // Verify that a configuration was saved by the requestor - Mockito.verify(configStore, Mockito.times(1)).saveConfiguration(any()); - assertFalse(configStore.getConfiguration().isEmpty()); - assertFalse(configStore.getConfiguration().getFlagKeys().isEmpty()); + requestor.fetchAndSaveFromRemote(); + assertEquals(1, callCount1.get()); + assertEquals(1, callCount2.get()); - // `numeric_flag` is only in the cache which should be available - assertNotNull(configStore.getConfiguration().getFlag("numeric_flag")); + unsubscribe1.run(); + requestor.fetchAndSaveFromRemote(); + assertEquals(1, callCount1.get()); + assertEquals(2, callCount2.get()); - assertNull(configStore.getConfiguration().getFlag("boolean_flag")); - } + unsubscribe2.run(); + requestor.fetchAndSaveFromRemote(); + assertEquals(1, callCount1.get()); + assertEquals(2, callCount2.get()); + } - private ConfigurationStore mockConfigStore; - private EppoHttpClient mockHttpClient; - private ConfigurationRequestor requestor; + @Test + void testConfigurationChangeListenerIgnoresFailedFetch() { + when(mockHttpClient.get(anyString())).thenThrow(new RuntimeException("Fetch failed")); - @BeforeEach - public void setup() { - mockConfigStore = mock(ConfigurationStore.class); - mockHttpClient = mock(EppoHttpClient.class); - requestor = new ConfigurationRequestor(mockConfigStore, mockHttpClient, false, true); - } + AtomicInteger callCount = new AtomicInteger(0); + requestor.onConfigurationChange(v -> callCount.incrementAndGet()); - @Test - public void testConfigurationChangeListener() throws IOException { - // Setup mock response - String flagConfig = FileUtils.readFileToString(initialFlagConfigFile, StandardCharsets.UTF_8); - when(mockHttpClient.get(anyString())).thenReturn(flagConfig.getBytes()); - when(mockConfigStore.saveConfiguration(any())) - .thenReturn(CompletableFuture.completedFuture(null)); + try { + requestor.fetchAndSaveFromRemote(); + } catch (Exception e) { + // Expected + } + assertEquals(0, callCount.get()); + } - List receivedConfigs = new ArrayList<>(); + @Test + void testConfigurationChangeListenerIgnoresFailedSave() { + when(mockHttpClient.get(anyString())).thenReturn("{}".getBytes()); + when(mockConfigStore.saveConfiguration(any())) + .thenReturn( + CompletableFuture.supplyAsync( + () -> { + throw new RuntimeException("Save failed"); + })); + + AtomicInteger callCount = new AtomicInteger(0); + requestor.onConfigurationChange(v -> callCount.incrementAndGet()); + + try { + requestor.fetchAndSaveFromRemote(); + } catch (RuntimeException e) { + // Pass + } + assertEquals(0, callCount.get()); + } - // Subscribe to configuration changes - Runnable unsubscribe = requestor.onConfigurationChange(receivedConfigs::add); + @Test + void testConfigurationChangeListenerAsyncSave() { + when(mockHttpClient.getAsync(anyString())) + .thenReturn(CompletableFuture.completedFuture("{\"flags\":{}}".getBytes())); - // Initial fetch should trigger the callback - requestor.fetchAndSaveFromRemote(); - assertEquals(1, receivedConfigs.size()); + CompletableFuture saveFuture = new CompletableFuture<>(); + when(mockConfigStore.saveConfiguration(any())).thenReturn(saveFuture); - // Another fetch should trigger the callback again (fetches aren't optimized with eTag yet). - requestor.fetchAndSaveFromRemote(); - assertEquals(2, receivedConfigs.size()); + AtomicInteger callCount = new AtomicInteger(0); + requestor.onConfigurationChange(v -> callCount.incrementAndGet()); - // Unsubscribe should prevent further callbacks - unsubscribe.run(); - requestor.fetchAndSaveFromRemote(); - assertEquals(2, receivedConfigs.size()); // Count should remain the same - } + CompletableFuture fetch = requestor.fetchAndSaveFromRemoteAsync(); + assertEquals(0, callCount.get()); - @Test - public void testMultipleConfigurationChangeListeners() { - // Setup mock response - when(mockHttpClient.get(anyString())).thenReturn("{}".getBytes()); - when(mockConfigStore.saveConfiguration(any())) - .thenReturn(CompletableFuture.completedFuture(null)); - - AtomicInteger callCount1 = new AtomicInteger(0); - AtomicInteger callCount2 = new AtomicInteger(0); - - // Subscribe multiple listeners - Runnable unsubscribe1 = requestor.onConfigurationChange(v -> callCount1.incrementAndGet()); - Runnable unsubscribe2 = requestor.onConfigurationChange(v -> callCount2.incrementAndGet()); - - // Fetch should trigger both callbacks - requestor.fetchAndSaveFromRemote(); - assertEquals(1, callCount1.get()); - assertEquals(1, callCount2.get()); - - // Unsubscribe first listener - unsubscribe1.run(); - requestor.fetchAndSaveFromRemote(); - assertEquals(1, callCount1.get()); // Should not increase - assertEquals(2, callCount2.get()); // Should increase - - // Unsubscribe second listener - unsubscribe2.run(); - requestor.fetchAndSaveFromRemote(); - assertEquals(1, callCount1.get()); // Should not increase - assertEquals(2, callCount2.get()); // Should not increase - } + saveFuture.complete(null); + fetch.join(); + assertEquals(1, callCount.get()); + } - @Test - public void testConfigurationChangeListenerIgnoresFailedFetch() { - // Setup mock response to simulate failure - when(mockHttpClient.get(anyString())).thenThrow(new RuntimeException("Fetch failed")); + @Test + void testUnsubscribeFromConfigurationChangeByReference() throws IOException { + String flagConfig = loadInitialFlagConfigString(); + when(mockHttpClient.get(anyString())).thenReturn(flagConfig.getBytes()); + when(mockConfigStore.saveConfiguration(any())) + .thenReturn(CompletableFuture.completedFuture(null)); - AtomicInteger callCount = new AtomicInteger(0); - requestor.onConfigurationChange(v -> callCount.incrementAndGet()); + List receivedConfigs = new ArrayList<>(); + Consumer callback = receivedConfigs::add; + + requestor.onConfigurationChange(callback); - // Failed fetch should not trigger the callback - try { requestor.fetchAndSaveFromRemote(); - } catch (Exception e) { - // Expected + assertEquals(1, receivedConfigs.size()); + + boolean removed = requestor.unsubscribeFromConfigurationChange(callback); + assertTrue(removed); + + requestor.fetchAndSaveFromRemote(); + assertEquals(1, receivedConfigs.size()); } - assertEquals(0, callCount.get()); - } - @Test - public void testConfigurationChangeListenerIgnoresFailedSave() { - // Setup mock responses - when(mockHttpClient.get(anyString())).thenReturn("{}".getBytes()); - when(mockConfigStore.saveConfiguration(any())) - .thenReturn( - CompletableFuture.supplyAsync( - () -> { - throw new RuntimeException("Save failed"); - })); - - AtomicInteger callCount = new AtomicInteger(0); - requestor.onConfigurationChange(v -> callCount.incrementAndGet()); - - // Failed save should not trigger the callback - try { + @Test + void testUnsubscribeNonExistentConfigurationChangeListener() { + Consumer callback = config -> {}; + boolean removed = requestor.unsubscribeFromConfigurationChange(callback); + assertFalse(removed); + } + + @Test + void testUnsubscribeOneOfMultipleConfigurationChangeListeners() { + when(mockHttpClient.get(anyString())).thenReturn("{}".getBytes()); + when(mockConfigStore.saveConfiguration(any())) + .thenReturn(CompletableFuture.completedFuture(null)); + + AtomicInteger callCount1 = new AtomicInteger(0); + AtomicInteger callCount2 = new AtomicInteger(0); + AtomicInteger callCount3 = new AtomicInteger(0); + + Consumer callback1 = v -> callCount1.incrementAndGet(); + Consumer callback2 = v -> callCount2.incrementAndGet(); + Consumer callback3 = v -> callCount3.incrementAndGet(); + + requestor.onConfigurationChange(callback1); + requestor.onConfigurationChange(callback2); + requestor.onConfigurationChange(callback3); + requestor.fetchAndSaveFromRemote(); - } catch (RuntimeException e) { - // Pass + assertEquals(1, callCount1.get()); + assertEquals(1, callCount2.get()); + assertEquals(1, callCount3.get()); + + boolean removed = requestor.unsubscribeFromConfigurationChange(callback2); + assertTrue(removed); + + requestor.fetchAndSaveFromRemote(); + assertEquals(2, callCount1.get()); + assertEquals(1, callCount2.get()); + assertEquals(2, callCount3.get()); } - assertEquals(0, callCount.get()); } - @Test - public void testConfigurationChangeListenerAsyncSave() { - // Setup mock responses - when(mockHttpClient.getAsync(anyString())) - .thenReturn(CompletableFuture.completedFuture("{\"flags\":{}}".getBytes())); + // ==================== Tests for EppoConfigurationClient ==================== + + @Nested + class EppoConfigurationClientTests { + private IConfigurationStore configStore; + private EppoHttpClient mockHttpClient; + private EppoConfigurationClient mockConfigClient; + private EppoConfigurationRequestFactory requestFactory; + private byte[] flagConfigBytes; + + @BeforeEach + void setUp() throws IOException { + configStore = Mockito.spy(new ConfigurationStore()); + mockHttpClient = mock(EppoHttpClient.class); + mockConfigClient = mock(EppoConfigurationClient.class); + requestFactory = createTestRequestFactory(); + flagConfigBytes = loadInitialFlagConfig(); + } - CompletableFuture saveFuture = new CompletableFuture<>(); - when(mockConfigStore.saveConfiguration(any())).thenReturn(saveFuture); + private ConfigurationRequestor createRequestor() { + return new ConfigurationRequestor( + configStore, mockHttpClient, false, false, null, mockConfigClient, requestFactory); + } - AtomicInteger callCount = new AtomicInteger(0); - requestor.onConfigurationChange(v -> callCount.incrementAndGet()); + @Test + void testFetchWithEppoConfigurationClient() { + EppoConfigurationResponse successResponse = + EppoConfigurationResponse.success(200, "version-123", flagConfigBytes); + when(mockConfigClient.get(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(successResponse)); - // Start fetch - CompletableFuture fetch = requestor.fetchAndSaveFromRemoteAsync(); - assertEquals(0, callCount.get()); // Callback should not be called yet + ConfigurationRequestor requestor = createRequestor(); + requestor.fetchAndSaveFromRemote(); - // Complete the save - saveFuture.complete(null); - fetch.join(); - assertEquals(1, callCount.get()); // Callback should be called after save completes - } + verify(mockConfigClient) + .get( + argThat( + request -> + request.getResourcePath().equals(Constants.FLAG_CONFIG_ENDPOINT) + && request.getQueryParams().get("apiKey").equals("test-api-key") + && request.getQueryParams().get("sdkName").equals("java") + && request.getQueryParams().get("sdkVersion").equals("1.0.0") + && request.getLastVersionId() == null)); + + verify(mockHttpClient, never()).get(anyString()); + assertFalse(configStore.getConfiguration().isEmpty()); + assertNotNull(configStore.getConfiguration().getFlag("numeric_flag")); + } - @Test - public void testUnsubscribeFromConfigurationChangeByReference() throws IOException { - // Setup mock response - String flagConfig = FileUtils.readFileToString(initialFlagConfigFile, StandardCharsets.UTF_8); - when(mockHttpClient.get(anyString())).thenReturn(flagConfig.getBytes()); - when(mockConfigStore.saveConfiguration(any())) - .thenReturn(CompletableFuture.completedFuture(null)); + @Test + void testFetchWithEppoConfigurationClientTracksVersionId() { + EppoConfigurationResponse firstResponse = + EppoConfigurationResponse.success(200, "version-123", flagConfigBytes); + EppoConfigurationResponse notModifiedResponse = + EppoConfigurationResponse.notModified("version-123"); - List receivedConfigs = new ArrayList<>(); - Consumer callback = receivedConfigs::add; + when(mockConfigClient.get(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(firstResponse)) + .thenReturn(CompletableFuture.completedFuture(notModifiedResponse)); - // Subscribe to configuration changes - requestor.onConfigurationChange(callback); + ConfigurationRequestor requestor = createRequestor(); - // Initial fetch should trigger the callback - requestor.fetchAndSaveFromRemote(); - assertEquals(1, receivedConfigs.size()); + requestor.fetchAndSaveFromRemote(); + verify(mockConfigClient).get(argThat(request -> request.getLastVersionId() == null)); + + requestor.fetchAndSaveFromRemote(); + verify(mockConfigClient) + .get(argThat(request -> "version-123".equals(request.getLastVersionId()))); + + verify(configStore, times(1)).saveConfiguration(any()); + } + + @Test + void testFetchWithEppoConfigurationClientHandles304NotModified() { + EppoConfigurationResponse notModifiedResponse = + EppoConfigurationResponse.notModified("version-123"); + when(mockConfigClient.get(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(notModifiedResponse)); + + ConfigurationRequestor requestor = createRequestor(); + requestor.fetchAndSaveFromRemote(); - // Unsubscribe using the callback reference - boolean removed = requestor.unsubscribeFromConfigurationChange(callback); - assertTrue(removed); + verify(configStore, never()).saveConfiguration(any()); + } + + @Test + void testFetchAsyncWithEppoConfigurationClient() { + EppoConfigurationResponse successResponse = + EppoConfigurationResponse.success(200, "version-456", flagConfigBytes); + when(mockConfigClient.get(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(successResponse)); + + ConfigurationRequestor requestor = createRequestor(); + requestor.fetchAndSaveFromRemoteAsync().join(); + + verify(mockConfigClient).get(any(EppoConfigurationRequest.class)); + verify(mockHttpClient, never()).getAsync(anyString()); + assertFalse(configStore.getConfiguration().isEmpty()); + } + + @Test + void testFetchWithEppoConfigurationClientErrorResponse() { + EppoConfigurationResponse errorResponse = + EppoConfigurationResponse.error(500, "Internal Server Error".getBytes()); + when(mockConfigClient.get(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(errorResponse)); - // Another fetch should not trigger the callback - requestor.fetchAndSaveFromRemote(); - assertEquals(1, receivedConfigs.size()); // Count should remain the same + ConfigurationRequestor requestor = createRequestor(); + + assertThrows(RuntimeException.class, requestor::fetchAndSaveFromRemote); + verify(configStore, never()).saveConfiguration(any()); + } } - @Test - public void testUnsubscribeNonExistentConfigurationChangeListener() { - Consumer callback = config -> {}; + // ==================== Tests for ConfigurationParser ==================== + + @Nested + class ConfigurationParserTests { + private IConfigurationStore configStore; + private EppoHttpClient mockHttpClient; + private EppoConfigurationClient mockConfigClient; + private ConfigurationParser mockParser; + private EppoConfigurationRequestFactory requestFactory; + private byte[] flagConfigBytes; + + @BeforeEach + void setUp() throws IOException { + configStore = Mockito.spy(new ConfigurationStore()); + mockHttpClient = mock(EppoHttpClient.class); + mockConfigClient = mock(EppoConfigurationClient.class); + mockParser = mock(ConfigurationParser.class); + requestFactory = createTestRequestFactory(); + flagConfigBytes = loadInitialFlagConfig(); + } + + private void stubConfigClientSuccess(String versionId) { + EppoConfigurationResponse successResponse = + EppoConfigurationResponse.success(200, versionId, flagConfigBytes); + when(mockConfigClient.get(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(successResponse)); + } + + private void stubParserSuccess() throws Exception { + FlagConfigResponse mockFlagConfigResponse = new FlagConfigResponse.Default(); + when(mockParser.parseFlagConfig(flagConfigBytes)).thenReturn(mockFlagConfigResponse); + } + + @Test + void testFetchWithConfigurationParser() throws Exception { + stubConfigClientSuccess("version-789"); + stubParserSuccess(); + + ConfigurationRequestor requestor = + new ConfigurationRequestor( + configStore, + mockHttpClient, + false, + false, + mockParser, + mockConfigClient, + requestFactory); + + requestor.fetchAndSaveFromRemote(); + + verify(mockParser).parseFlagConfig(flagConfigBytes); + verify(configStore).saveConfiguration(any()); + } + + @Test + void testFetchAsyncWithConfigurationParser() throws Exception { + stubConfigClientSuccess("version-async"); + stubParserSuccess(); + + ConfigurationRequestor requestor = + new ConfigurationRequestor( + configStore, + mockHttpClient, + false, + false, + mockParser, + mockConfigClient, + requestFactory); + + requestor.fetchAndSaveFromRemoteAsync().join(); + + verify(mockParser).parseFlagConfig(flagConfigBytes); + verify(configStore).saveConfiguration(any()); + } + + @Test + void testFetchWithParserOnly() throws Exception { + // Test using parser with the default EppoHttpClient (not EppoConfigurationClient) + when(mockHttpClient.get(Constants.FLAG_CONFIG_ENDPOINT)).thenReturn(flagConfigBytes); + stubParserSuccess(); + + ConfigurationRequestor requestor = + new ConfigurationRequestor( + configStore, + mockHttpClient, + false, + false, + mockParser, + null, // No EppoConfigurationClient + requestFactory); + + requestor.fetchAndSaveFromRemote(); + + verify(mockHttpClient).get(Constants.FLAG_CONFIG_ENDPOINT); + verify(mockParser).parseFlagConfig(flagConfigBytes); + verify(configStore).saveConfiguration(any()); + } - // Try to unsubscribe a callback that was never subscribed - boolean removed = requestor.unsubscribeFromConfigurationChange(callback); - assertFalse(removed); + @Test + void testFetchWithConfigurationParserParseError() throws Exception { + byte[] invalidBytes = "invalid json".getBytes(StandardCharsets.UTF_8); + + EppoConfigurationResponse successResponse = + EppoConfigurationResponse.success(200, "version-error", invalidBytes); + when(mockConfigClient.get(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(successResponse)); + + when(mockParser.parseFlagConfig(invalidBytes)) + .thenThrow( + new cloud.eppo.parser.ConfigurationParseException("Failed to parse configuration")); + + ConfigurationRequestor requestor = + new ConfigurationRequestor( + configStore, + mockHttpClient, + false, + false, + mockParser, + mockConfigClient, + requestFactory); + + assertThrows(RuntimeException.class, requestor::fetchAndSaveFromRemote); + verify(configStore, never()).saveConfiguration(any()); + } } - @Test - public void testUnsubscribeOneOfMultipleConfigurationChangeListeners() { - // Setup mock response - when(mockHttpClient.get(anyString())).thenReturn("{}".getBytes()); - when(mockConfigStore.saveConfiguration(any())) - .thenReturn(CompletableFuture.completedFuture(null)); - - AtomicInteger callCount1 = new AtomicInteger(0); - AtomicInteger callCount2 = new AtomicInteger(0); - AtomicInteger callCount3 = new AtomicInteger(0); - - Consumer callback1 = v -> callCount1.incrementAndGet(); - Consumer callback2 = v -> callCount2.incrementAndGet(); - Consumer callback3 = v -> callCount3.incrementAndGet(); - - // Subscribe multiple listeners - requestor.onConfigurationChange(callback1); - requestor.onConfigurationChange(callback2); - requestor.onConfigurationChange(callback3); - - // Fetch should trigger all callbacks - requestor.fetchAndSaveFromRemote(); - assertEquals(1, callCount1.get()); - assertEquals(1, callCount2.get()); - assertEquals(1, callCount3.get()); - - // Unsubscribe middle listener - boolean removed = requestor.unsubscribeFromConfigurationChange(callback2); - assertTrue(removed); - - requestor.fetchAndSaveFromRemote(); - assertEquals(2, callCount1.get()); // Should increase - assertEquals(1, callCount2.get()); // Should not increase - assertEquals(2, callCount3.get()); // Should increase + // ==================== Integration Tests with Real Implementations ==================== + // These tests use OkHttpEppoClient and JacksonConfigurationParser from eppo-sdk-common + + @Nested + class IntegrationTests { + private okhttp3.mockwebserver.MockWebServer mockServer; + private OkHttpEppoClient okHttpClient; + private JacksonConfigurationParser jacksonParser; + + @BeforeEach + void setUp() throws IOException { + mockServer = new okhttp3.mockwebserver.MockWebServer(); + mockServer.start(); + okHttpClient = new OkHttpEppoClient(); + jacksonParser = new JacksonConfigurationParser(); + } + + @org.junit.jupiter.api.AfterEach + void tearDown() throws IOException { + mockServer.shutdown(); + } + + private EppoConfigurationRequestFactory createMockServerRequestFactory() { + return new EppoConfigurationRequestFactory( + mockServer.url("/").toString(), "test-api-key", "java-test", "1.0.0"); + } + + private ConfigurationRequestor createRequestor(IConfigurationStore configStore) { + return new ConfigurationRequestor( + configStore, + mock(EppoHttpClient.class), // Not used when EppoConfigurationClient is provided + false, + false, + jacksonParser, + okHttpClient, + createMockServerRequestFactory()); + } + + private void enqueueSuccessResponse(String body, String etag) { + mockServer.enqueue( + new okhttp3.mockwebserver.MockResponse() + .setBody(body) + .setHeader("ETag", etag) + .setResponseCode(200)); + } + + @Test + void testFetchWithCommonParserAndClient() throws IOException, InterruptedException { + IConfigurationStore configStore = new ConfigurationStore(); + String flagConfig = loadInitialFlagConfigString(); + + enqueueSuccessResponse(flagConfig, "version-real-1"); + + ConfigurationRequestor requestor = createRequestor(configStore); + requestor.fetchAndSaveFromRemote(); + + assertFalse(configStore.getConfiguration().isEmpty()); + assertNotNull(configStore.getConfiguration().getFlag("numeric_flag")); + + okhttp3.mockwebserver.RecordedRequest recordedRequest = mockServer.takeRequest(); + assertTrue(recordedRequest.getPath().contains("apiKey=test-api-key")); + assertTrue(recordedRequest.getPath().contains("sdkName=java-test")); + } + + @Test + void testFetchHandles304NotModified() throws IOException, InterruptedException { + IConfigurationStore configStore = Mockito.spy(new ConfigurationStore()); + String flagConfig = loadInitialFlagConfigString(); + + enqueueSuccessResponse(flagConfig, "version-etag-test"); + mockServer.enqueue( + new okhttp3.mockwebserver.MockResponse() + .setHeader("ETag", "version-etag-test") + .setResponseCode(304)); + + ConfigurationRequestor requestor = createRequestor(configStore); + + requestor.fetchAndSaveFromRemote(); + verify(configStore, times(1)).saveConfiguration(any()); + + requestor.fetchAndSaveFromRemote(); + verify(configStore, times(1)).saveConfiguration(any()); // Still only 1 + + mockServer.takeRequest(); // First request + okhttp3.mockwebserver.RecordedRequest secondRequest = mockServer.takeRequest(); + assertEquals("version-etag-test", secondRequest.getHeader("If-None-Match")); + } + + @Test + void testFetchAsync() throws Exception { + IConfigurationStore configStore = new ConfigurationStore(); + String flagConfig = loadInitialFlagConfigString(); + + enqueueSuccessResponse(flagConfig, "version-async-real"); + + ConfigurationRequestor requestor = createRequestor(configStore); + requestor.fetchAndSaveFromRemoteAsync().join(); + + assertFalse(configStore.getConfiguration().isEmpty()); + assertNotNull(configStore.getConfiguration().getFlag("numeric_flag")); + } + + @Test + void testFetchHandlesInvalidJson() { + IConfigurationStore configStore = new ConfigurationStore(); + + mockServer.enqueue( + new okhttp3.mockwebserver.MockResponse() + .setBody("this is not valid json") + .setResponseCode(200)); + + ConfigurationRequestor requestor = createRequestor(configStore); + + assertThrows(RuntimeException.class, requestor::fetchAndSaveFromRemote); + } + + @Test + void testFetchHandlesServerError() { + IConfigurationStore configStore = new ConfigurationStore(); + + mockServer.enqueue( + new okhttp3.mockwebserver.MockResponse() + .setBody("Internal Server Error") + .setResponseCode(500)); + + ConfigurationRequestor requestor = createRequestor(configStore); + + assertThrows(RuntimeException.class, requestor::fetchAndSaveFromRemote); + } + + @Test + void testConfigurationChangeListener() throws Exception { + IConfigurationStore configStore = new ConfigurationStore(); + String flagConfig = loadInitialFlagConfigString(); + + enqueueSuccessResponse(flagConfig, "version-callback"); + + ConfigurationRequestor requestor = createRequestor(configStore); + + List receivedConfigs = new ArrayList<>(); + requestor.onConfigurationChange(receivedConfigs::add); + + requestor.fetchAndSaveFromRemote(); + + assertEquals(1, receivedConfigs.size()); + assertNotNull(receivedConfigs.get(0).getFlag("numeric_flag")); + } } } diff --git a/src/test/java/cloud/eppo/ProfileBaseEppoClientTest.java b/src/test/java/cloud/eppo/ProfileBaseEppoClientTest.java index 0e7ba5f5..45928d33 100644 --- a/src/test/java/cloud/eppo/ProfileBaseEppoClientTest.java +++ b/src/test/java/cloud/eppo/ProfileBaseEppoClientTest.java @@ -48,6 +48,8 @@ public static void initClient() { true, null, null, + null, + null, null); eppoClient.loadConfiguration(); From e29f0cd36f1f88d5abb4572c6e4d20ca62051148 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Tue, 10 Feb 2026 14:27:32 -0700 Subject: [PATCH 16/44] Cut over to real implementations in testing --- .../java/cloud/eppo/BaseEppoClientTest.java | 109 +++++++++--------- .../eppo/helpers/AssignmentTestCase.java | 29 ++++- .../java/cloud/eppo/helpers/TestUtils.java | 82 ++++++++++++- 3 files changed, 158 insertions(+), 62 deletions(-) diff --git a/src/test/java/cloud/eppo/BaseEppoClientTest.java b/src/test/java/cloud/eppo/BaseEppoClientTest.java index 32963725..4dbc6d4b 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientTest.java @@ -1,10 +1,10 @@ package cloud.eppo; import static cloud.eppo.helpers.AssignmentTestCase.*; -import static cloud.eppo.helpers.TestUtils.mockHttpError; -import static cloud.eppo.helpers.TestUtils.mockHttpResponse; -import static cloud.eppo.helpers.TestUtils.setBaseClientHttpClientOverrideField; +import static cloud.eppo.helpers.TestUtils.mockConfigurationClient; +import static cloud.eppo.helpers.TestUtils.mockConfigurationClientError; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; import cloud.eppo.api.*; @@ -12,8 +12,12 @@ import cloud.eppo.api.dto.VariationType; import cloud.eppo.cache.LRUInMemoryAssignmentCache; import cloud.eppo.helpers.AssignmentTestCase; +import cloud.eppo.http.EppoConfigurationClient; +import cloud.eppo.http.EppoConfigurationRequest; +import cloud.eppo.http.EppoConfigurationResponse; import cloud.eppo.logging.Assignment; import cloud.eppo.logging.AssignmentLogger; +import cloud.eppo.parser.ConfigurationParser; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.File; @@ -57,8 +61,12 @@ public class BaseEppoClientTest { private final ObjectMapper mapper = new ObjectMapper().registerModule(AssignmentTestCase.assignmentTestCaseModule()); + // Use JacksonConfigurationParser for all tests + private final ConfigurationParser parser = new JacksonConfigurationParser(); + private BaseEppoClient eppoClient; private AssignmentLogger mockAssignmentLogger; + private EppoConfigurationClient mockConfigClient; private final File initialFlagConfigFile = new File("src/test/resources/static/initial-flag-config.json"); @@ -89,7 +97,9 @@ private void initClientWithData( true, initialFlagConfiguration, null, - null); + null, + parser, + mockConfigClient); } private void initClient(boolean isGracefulMode, boolean isConfigObfuscated) { @@ -109,7 +119,9 @@ private void initClient(boolean isGracefulMode, boolean isConfigObfuscated) { true, null, null, - null); + null, + parser, + mockConfigClient); eppoClient.loadConfiguration(); log.info("Test client initialized"); @@ -133,7 +145,9 @@ private CompletableFuture initClientAsync( true, null, null, - null); + null, + parser, + mockConfigClient); return eppoClient.loadConfigurationAsync(); } @@ -155,7 +169,9 @@ private void initClientWithAssignmentCache(IAssignmentCache cache) { true, null, cache, - null); + null, + parser, + mockConfigClient); eppoClient.loadConfiguration(); log.info("Test client initialized"); @@ -163,8 +179,8 @@ private void initClientWithAssignmentCache(IAssignmentCache cache) { @BeforeEach public void cleanUp() { - // TODO: Clear any caches - setBaseClientHttpClientOverrideField(null); + // Reset mock config client before each test + mockConfigClient = null; } @ParameterizedTest @@ -230,6 +246,8 @@ public void testCustomBaseUrl() throws IOException, InterruptedException { true, null, null, + null, + null, null); eppoClient.loadConfiguration(); @@ -272,22 +290,9 @@ public void testErrorGracefulModeOn() throws JsonProcessingException { assertEquals( "", spyClient.getStringAssignment("experiment1", "subject1", new Attributes(), "")); - assertEquals( - mapper.readTree("{\"a\": 1, \"b\": false}").toString(), - spyClient - .getJSONAssignment( - "subject1", "experiment1", mapper.readTree("{\"a\": 1, \"b\": false}")) - .toString()); - assertEquals( "{\"a\": 1, \"b\": false}", spyClient.getJSONStringAssignment("subject1", "experiment1", "{\"a\": 1, \"b\": false}")); - - assertEquals( - mapper.readTree("{}").toString(), - spyClient - .getJSONAssignment("subject1", "experiment1", new Attributes(), mapper.readTree("{}")) - .toString()); } @Test @@ -328,23 +333,11 @@ public void testErrorGracefulModeOff() { assertThrows( RuntimeException.class, () -> spyClient.getStringAssignment("experiment1", "subject1", new Attributes(), "")); - - assertThrows( - RuntimeException.class, - () -> - spyClient.getJSONAssignment( - "subject1", "experiment1", mapper.readTree("{\"a\": 1, \"b\": false}"))); - assertThrows( - RuntimeException.class, - () -> - spyClient.getJSONAssignment( - "subject1", "experiment1", new Attributes(), mapper.readTree("{}"))); } @Test public void testInvalidConfigJSON() { - - mockHttpResponse("{}"); + mockConfigClient = mockConfigurationClient("{}"); initClient(false, false); @@ -359,8 +352,8 @@ private CompletableFuture immediateConfigFuture( @Test public void testGracefulInitializationFailure() { - // Set up bad HTTP response - mockHttpError(); + // Set up failing configuration client + mockConfigClient = mockConfigurationClientError(); // Initialize and no exception should be thrown. assertDoesNotThrow(() -> initClient(true, false)); @@ -368,8 +361,8 @@ public void testGracefulInitializationFailure() { @Test public void testClientMakesDefaultAssignmentsAfterFailingToInitialize() { - // Set up bad HTTP response - mockHttpError(); + // Set up failing configuration client + mockConfigClient = mockConfigurationClientError(); // Initialize and no exception should be thrown. assertDoesNotThrow(() -> initClient(true, false)); @@ -379,15 +372,15 @@ public void testClientMakesDefaultAssignmentsAfterFailingToInitialize() { @Test public void testClientMakesDefaultAssignmentsAfterFailingToInitializeNonGracefulMode() { - // Set up bad HTTP response - mockHttpError(); + // Set up failing configuration client + mockConfigClient = mockConfigurationClientError(); // Initialize and no exception should be thrown. try { initClient(false, false); } catch (RuntimeException e) { // Expected - assertEquals("Intentional Error", e.getMessage()); + assertTrue(e.getMessage().contains("Intentional Error")); } finally { assertEquals("default", eppoClient.getStringAssignment("experiment1", "subject1", "default")); } @@ -395,8 +388,8 @@ public void testClientMakesDefaultAssignmentsAfterFailingToInitializeNonGraceful @Test public void testNonGracefulInitializationFailure() { - // Set up bad HTTP response - mockHttpError(); + // Set up failing configuration client + mockConfigClient = mockConfigurationClientError(); // Initialize and assert exception thrown assertThrows(Exception.class, () -> initClient(false, false)); @@ -404,8 +397,8 @@ public void testNonGracefulInitializationFailure() { @Test public void testGracefulAsyncInitializationFailure() { - // Set up bad HTTP response - mockHttpError(); + // Set up failing configuration client + mockConfigClient = mockConfigurationClientError(); // Initialize CompletableFuture init = initClientAsync(true, false); @@ -418,8 +411,8 @@ public void testGracefulAsyncInitializationFailure() { @Test public void testNonGracefulAsyncInitializationFailure() { - // Set up bad HTTP response - mockHttpError(); + // Set up failing configuration client + mockConfigClient = mockConfigurationClientError(); // Initialize CompletableFuture init = initClientAsync(false, false); @@ -712,8 +705,9 @@ public void run() { @Test public void testPolling() { - EppoHttpClient httpClient = mockHttpResponse(BOOL_FLAG_CONFIG); + mockConfigClient = mockConfigurationClient(BOOL_FLAG_CONFIG); + mockAssignmentLogger = mock(AssignmentLogger.class); BaseEppoClient client = eppoClient = new BaseEppoClient( @@ -729,20 +723,22 @@ public void testPolling() { true, null, null, - null); + null, + parser, + mockConfigClient); client.loadConfiguration(); client.startPolling(20); // Method will be called immediately on init - verify(httpClient, times(1)).get(anyString()); + verify(mockConfigClient, times(1)).get(any(EppoConfigurationRequest.class)); assertTrue(eppoClient.getBooleanAssignment("bool_flag", "subject1", false)); // Sleep for 25 ms to allow another polling cycle to complete sleepUninterruptedly(25); // Now, the method should have been called twice - verify(httpClient, times(2)).get(anyString()); + verify(mockConfigClient, times(2)).get(any(EppoConfigurationRequest.class)); eppoClient.stopPolling(); assertTrue(eppoClient.getBooleanAssignment("bool_flag", "subject1", false)); @@ -750,10 +746,13 @@ public void testPolling() { sleepUninterruptedly(25); // No more calls since stopped - verify(httpClient, times(2)).get(anyString()); + verify(mockConfigClient, times(2)).get(any(EppoConfigurationRequest.class)); // Set up a different config to be served - when(httpClient.get(anyString())).thenReturn(DISABLED_BOOL_FLAG_CONFIG.getBytes()); + EppoConfigurationResponse disabledResponse = + EppoConfigurationResponse.success(200, "v2", DISABLED_BOOL_FLAG_CONFIG.getBytes()); + when(mockConfigClient.get(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(disabledResponse)); client.startPolling(20); // True until the next config is fetched. @@ -843,6 +842,8 @@ public void testGetConfigurationBeforeInitialization() { true, null, null, + null, + null, null); // Get configuration before loading diff --git a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java index 776aa5d1..c2d9a523 100644 --- a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java +++ b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java @@ -59,6 +59,14 @@ public List getSubjects() { private static final ObjectMapper mapper = new ObjectMapper().registerModule(assignmentTestCaseModule()); + private static JsonNode parseJson(String jsonString) { + try { + return mapper.readTree(jsonString); + } catch (IOException e) { + throw new RuntimeException("Failed to parse JSON: " + jsonString, e); + } + } + public static SimpleModule assignmentTestCaseModule() { SimpleModule module = new SimpleModule(); module.addDeserializer(AssignmentTestCase.class, new AssignmentTestCaseDeserializer()); @@ -168,16 +176,25 @@ private static void runTestCaseBase( } break; case JSON: + // JSON assignments use getJSONStringAssignment and parse the result + String defaultJsonString = + testCase.getDefaultValue().jsonValue() != null + ? testCase.getDefaultValue().jsonValue().toString() + : null; if (validateDetails) { - AssignmentDetails details = - eppoClient.getJSONAssignmentDetails( - flagKey, subjectKey, subjectAttributes, testCase.getDefaultValue().jsonValue()); - assertAssignment(flagKey, subjectAssignment, details.getVariation()); + AssignmentDetails details = + eppoClient.getJSONStringAssignmentDetails( + flagKey, subjectKey, subjectAttributes, defaultJsonString); + JsonNode jsonValue = + details.getVariation() != null ? parseJson(details.getVariation()) : null; + assertAssignment(flagKey, subjectAssignment, jsonValue); assertAssignmentDetails(flagKey, subjectAssignment, details.getEvaluationDetails()); } else { + String jsonStringAssignment = + eppoClient.getJSONStringAssignment( + flagKey, subjectKey, subjectAttributes, defaultJsonString); JsonNode jsonAssignment = - eppoClient.getJSONAssignment( - flagKey, subjectKey, subjectAttributes, testCase.getDefaultValue().jsonValue()); + jsonStringAssignment != null ? parseJson(jsonStringAssignment) : null; assertAssignment(flagKey, subjectAssignment, jsonAssignment); } break; diff --git a/src/test/java/cloud/eppo/helpers/TestUtils.java b/src/test/java/cloud/eppo/helpers/TestUtils.java index 4b9a5822..6fc845e2 100644 --- a/src/test/java/cloud/eppo/helpers/TestUtils.java +++ b/src/test/java/cloud/eppo/helpers/TestUtils.java @@ -1,16 +1,83 @@ package cloud.eppo.helpers; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; import cloud.eppo.BaseEppoClient; import cloud.eppo.EppoHttpClient; +import cloud.eppo.http.EppoConfigurationClient; +import cloud.eppo.http.EppoConfigurationRequest; +import cloud.eppo.http.EppoConfigurationResponse; import java.lang.reflect.Field; import java.util.concurrent.CompletableFuture; -import okhttp3.*; public class TestUtils { + /** + * Creates a mock EppoConfigurationClient that returns the given response body for all requests. + * + * @param responseBody the response body to return + * @return a mock EppoConfigurationClient + */ + public static EppoConfigurationClient mockConfigurationClient(String responseBody) { + return mockConfigurationClient(responseBody.getBytes()); + } + + /** + * Creates a mock EppoConfigurationClient that returns the given response body for all requests. + * + * @param responseBody the response body to return + * @return a mock EppoConfigurationClient + */ + public static EppoConfigurationClient mockConfigurationClient(byte[] responseBody) { + EppoConfigurationClient mockClient = mock(EppoConfigurationClient.class); + EppoConfigurationResponse successResponse = + EppoConfigurationResponse.success(200, "test-version", responseBody); + + when(mockClient.get(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(successResponse)); + + return mockClient; + } + + /** + * Creates a mock EppoConfigurationClient that returns an error for all requests. + * + * @return a mock EppoConfigurationClient that fails + */ + public static EppoConfigurationClient mockConfigurationClientError() { + EppoConfigurationClient mockClient = mock(EppoConfigurationClient.class); + + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(new RuntimeException("Intentional Error")); + + when(mockClient.get(any(EppoConfigurationRequest.class))).thenReturn(failedFuture); + + return mockClient; + } + + /** + * Creates a mock EppoConfigurationClient that returns a 500 error response. + * + * @return a mock EppoConfigurationClient that returns error status + */ + public static EppoConfigurationClient mockConfigurationClientErrorResponse() { + EppoConfigurationClient mockClient = mock(EppoConfigurationClient.class); + EppoConfigurationResponse errorResponse = + EppoConfigurationResponse.error(500, "Internal Server Error".getBytes()); + + when(mockClient.get(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(errorResponse)); + + return mockClient; + } + + // ==================== Legacy methods (deprecated, use mockConfigurationClient instead) + // ==================== + + /** @deprecated Use mockConfigurationClient() and pass to BaseEppoClient constructor instead */ + @Deprecated @SuppressWarnings("SameParameterValue") public static EppoHttpClient mockHttpResponse(String responseBody) { // Create a mock instance of EppoHttpClient @@ -28,6 +95,10 @@ public static EppoHttpClient mockHttpResponse(String responseBody) { return mockHttpClient; } + /** + * @deprecated Use mockConfigurationClientError() and pass to BaseEppoClient constructor instead + */ + @Deprecated public static void mockHttpError() { // Create a mock instance of EppoHttpClient EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); @@ -43,11 +114,18 @@ public static void mockHttpError() { setBaseClientHttpClientOverrideField(mockHttpClient); } + /** @deprecated Will be removed when httpClientOverride is removed from BaseEppoClient */ + @Deprecated public static void setBaseClientHttpClientOverrideField(EppoHttpClient httpClient) { setBaseClientOverrideField("httpClientOverride", httpClient); } - /** Uses reflection to set a static override field used for tests (e.g., httpClientOverride) */ + /** + * Uses reflection to set a static override field used for tests (e.g., httpClientOverride) + * + * @deprecated Will be removed when override fields are removed from BaseEppoClient + */ + @Deprecated @SuppressWarnings("SameParameterValue") public static void setBaseClientOverrideField(String fieldName, T override) { try { From b700ec4c0acdd53354d431b123bae6db35cecc32 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Tue, 10 Feb 2026 14:40:13 -0700 Subject: [PATCH 17/44] restore JSON methods for now --- src/main/java/cloud/eppo/BaseEppoClient.java | 49 ++++++++++--------- .../java/cloud/eppo/BaseEppoClientTest.java | 24 +++++++++ .../eppo/helpers/AssignmentTestCase.java | 29 +++-------- 3 files changed, 55 insertions(+), 47 deletions(-) diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index 1932e9e8..bc6a2d5c 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -16,6 +16,7 @@ import cloud.eppo.logging.BanditAssignment; import cloud.eppo.logging.BanditLogger; import cloud.eppo.parser.ConfigurationParser; +import com.fasterxml.jackson.databind.JsonNode; import java.util.HashMap; import java.util.Map; import java.util.Timer; @@ -566,28 +567,27 @@ public AssignmentDetails getStringAssignmentDetails( } } - public JsonNode getJSONAssignment(String flagKey, String subjectKey, JsonNode defaultValue) { - return getJSONAssignment(flagKey, subjectKey, new Attributes(), defaultValue); + public String getJSONStringAssignment(String flagKey, String subjectKey, String defaultValue) { + return this.getJSONStringAssignment(flagKey, subjectKey, new Attributes(), defaultValue); } - public JsonNode getJSONAssignment( - String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { - return this.getJSONAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue) + public String getJSONStringAssignment( + String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { + return this.getJSONStringAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue) .getVariation(); } - public AssignmentDetails getJSONAssignmentDetails( - String flagKey, String subjectKey, JsonNode defaultValue) { - return this.getJSONAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); + public AssignmentDetails getJSONStringAssignmentDetails( + String flagKey, String subjectKey, String defaultValue) { + return this.getJSONStringAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); } - public AssignmentDetails getJSONAssignmentDetails( - String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { + public AssignmentDetails getJSONStringAssignmentDetails( + String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { try { return this.getTypedAssignmentWithDetails( flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.JSON); } catch (Exception e) { - String defaultValueString = defaultValue != null ? defaultValue.toString() : null; return new AssignmentDetails<>( throwIfNotGraceful(e, defaultValue), null, @@ -597,31 +597,32 @@ public AssignmentDetails getJSONAssignmentDetails( getConfiguration().getConfigPublishedAt(), FlagEvaluationCode.ASSIGNMENT_ERROR, e.getMessage(), - EppoValue.valueOf(defaultValueString))); + EppoValue.valueOf(defaultValue))); } } - public String getJSONStringAssignment(String flagKey, String subjectKey, String defaultValue) { - return this.getJSONStringAssignment(flagKey, subjectKey, new Attributes(), defaultValue); + public JsonNode getJSONAssignment(String flagKey, String subjectKey, JsonNode defaultValue) { + return getJSONAssignment(flagKey, subjectKey, new Attributes(), defaultValue); } - public String getJSONStringAssignment( - String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { - return this.getJSONStringAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue) + public JsonNode getJSONAssignment( + String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { + return this.getJSONAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue) .getVariation(); } - public AssignmentDetails getJSONStringAssignmentDetails( - String flagKey, String subjectKey, String defaultValue) { - return this.getJSONStringAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); + public AssignmentDetails getJSONAssignmentDetails( + String flagKey, String subjectKey, JsonNode defaultValue) { + return this.getJSONAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); } - public AssignmentDetails getJSONStringAssignmentDetails( - String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { + public AssignmentDetails getJSONAssignmentDetails( + String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { try { return this.getTypedAssignmentWithDetails( flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.JSON); } catch (Exception e) { + String defaultValueString = defaultValue != null ? defaultValue.toString() : null; return new AssignmentDetails<>( throwIfNotGraceful(e, defaultValue), null, @@ -631,7 +632,7 @@ public AssignmentDetails getJSONStringAssignmentDetails( getConfiguration().getConfigPublishedAt(), FlagEvaluationCode.ASSIGNMENT_ERROR, e.getMessage(), - EppoValue.valueOf(defaultValue))); + EppoValue.valueOf(defaultValueString))); } } @@ -795,7 +796,7 @@ private Map buildLogMetaData(boolean isConfigObfuscated) { return metaData; } - private T throwIfNotGraceful(Exception e, T defaultValue) { + protected T throwIfNotGraceful(Exception e, T defaultValue) { if (this.isGracefulMode) { log.info("error getting assignment value: {}", e.getMessage()); return defaultValue; diff --git a/src/test/java/cloud/eppo/BaseEppoClientTest.java b/src/test/java/cloud/eppo/BaseEppoClientTest.java index 4dbc6d4b..288e96ec 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientTest.java @@ -290,9 +290,22 @@ public void testErrorGracefulModeOn() throws JsonProcessingException { assertEquals( "", spyClient.getStringAssignment("experiment1", "subject1", new Attributes(), "")); + assertEquals( + mapper.readTree("{\"a\": 1, \"b\": false}").toString(), + spyClient + .getJSONAssignment( + "subject1", "experiment1", mapper.readTree("{\"a\": 1, \"b\": false}")) + .toString()); + assertEquals( "{\"a\": 1, \"b\": false}", spyClient.getJSONStringAssignment("subject1", "experiment1", "{\"a\": 1, \"b\": false}")); + + assertEquals( + mapper.readTree("{}").toString(), + spyClient + .getJSONAssignment("subject1", "experiment1", new Attributes(), mapper.readTree("{}")) + .toString()); } @Test @@ -333,6 +346,17 @@ public void testErrorGracefulModeOff() { assertThrows( RuntimeException.class, () -> spyClient.getStringAssignment("experiment1", "subject1", new Attributes(), "")); + + assertThrows( + RuntimeException.class, + () -> + spyClient.getJSONAssignment( + "subject1", "experiment1", mapper.readTree("{\"a\": 1, \"b\": false}"))); + assertThrows( + RuntimeException.class, + () -> + spyClient.getJSONAssignment( + "subject1", "experiment1", new Attributes(), mapper.readTree("{}"))); } @Test diff --git a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java index c2d9a523..776aa5d1 100644 --- a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java +++ b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java @@ -59,14 +59,6 @@ public List getSubjects() { private static final ObjectMapper mapper = new ObjectMapper().registerModule(assignmentTestCaseModule()); - private static JsonNode parseJson(String jsonString) { - try { - return mapper.readTree(jsonString); - } catch (IOException e) { - throw new RuntimeException("Failed to parse JSON: " + jsonString, e); - } - } - public static SimpleModule assignmentTestCaseModule() { SimpleModule module = new SimpleModule(); module.addDeserializer(AssignmentTestCase.class, new AssignmentTestCaseDeserializer()); @@ -176,25 +168,16 @@ private static void runTestCaseBase( } break; case JSON: - // JSON assignments use getJSONStringAssignment and parse the result - String defaultJsonString = - testCase.getDefaultValue().jsonValue() != null - ? testCase.getDefaultValue().jsonValue().toString() - : null; if (validateDetails) { - AssignmentDetails details = - eppoClient.getJSONStringAssignmentDetails( - flagKey, subjectKey, subjectAttributes, defaultJsonString); - JsonNode jsonValue = - details.getVariation() != null ? parseJson(details.getVariation()) : null; - assertAssignment(flagKey, subjectAssignment, jsonValue); + AssignmentDetails details = + eppoClient.getJSONAssignmentDetails( + flagKey, subjectKey, subjectAttributes, testCase.getDefaultValue().jsonValue()); + assertAssignment(flagKey, subjectAssignment, details.getVariation()); assertAssignmentDetails(flagKey, subjectAssignment, details.getEvaluationDetails()); } else { - String jsonStringAssignment = - eppoClient.getJSONStringAssignment( - flagKey, subjectKey, subjectAttributes, defaultJsonString); JsonNode jsonAssignment = - jsonStringAssignment != null ? parseJson(jsonStringAssignment) : null; + eppoClient.getJSONAssignment( + flagKey, subjectKey, subjectAttributes, testCase.getDefaultValue().jsonValue()); assertAssignment(flagKey, subjectAssignment, jsonAssignment); } break; From 260081cc25ab9e3ef05e7cac867d41903efa5bc3 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Tue, 10 Feb 2026 14:40:34 -0700 Subject: [PATCH 18/44] Common client (okhttp and Jackson for parsing) --- .../java/cloud/eppo/CommonEppoClient.java | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 eppo-sdk-common/src/main/java/cloud/eppo/CommonEppoClient.java diff --git a/eppo-sdk-common/src/main/java/cloud/eppo/CommonEppoClient.java b/eppo-sdk-common/src/main/java/cloud/eppo/CommonEppoClient.java new file mode 100644 index 00000000..02386ca7 --- /dev/null +++ b/eppo-sdk-common/src/main/java/cloud/eppo/CommonEppoClient.java @@ -0,0 +1,117 @@ +package cloud.eppo; + +import cloud.eppo.api.AssignmentDetails; +import cloud.eppo.api.Attributes; +import cloud.eppo.api.Configuration; +import cloud.eppo.api.EppoValue; +import cloud.eppo.api.EvaluationDetails; +import cloud.eppo.api.FlagEvaluationCode; +import cloud.eppo.api.IAssignmentCache; +import cloud.eppo.api.dto.VariationType; +import cloud.eppo.logging.AssignmentLogger; +import cloud.eppo.logging.BanditLogger; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.concurrent.CompletableFuture; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Eppo client implementation that uses OkHttp for HTTP communication and Jackson for JSON parsing. + * + *

This client extends {@link BaseEppoClient} and provides default implementations of the HTTP + * client and configuration parser using OkHttp 4 and Jackson respectively. + * + *

For custom HTTP client or parser implementations, depend on {@code + * cloud.eppo:eppo-sdk-framework} instead and use {@link BaseEppoClient} directly with your own + * implementations of {@link cloud.eppo.http.EppoConfigurationClient} and {@link + * cloud.eppo.parser.ConfigurationParser}. + */ +public class CommonEppoClient extends BaseEppoClient { + + /** + * Creates a new CommonEppoClient with the specified configuration. + * + * @param apiKey the API key for authentication with Eppo servers + * @param sdkName the SDK name for request metadata (e.g., "java-server-sdk") + * @param sdkVersion the SDK version for request metadata + * @param apiBaseUrl the base URL for API requests, or null for the default + * @param assignmentLogger logger for assignment events, or null to disable logging + * @param banditLogger logger for bandit assignment events, or null to disable logging + * @param configurationStore custom configuration store, or null for the default in-memory store + * @param isGracefulMode if true, errors during evaluation return default values instead of + * throwing + * @param expectObfuscatedConfig if true, expect obfuscated configuration (for Android clients) + * @param supportBandits if true, enable bandit support + * @param initialConfiguration future providing initial configuration, or null + * @param assignmentCache cache for deduplicating assignment logs, or null to disable + * @param banditAssignmentCache cache for deduplicating bandit assignment logs, or null to disable + */ + protected CommonEppoClient( + @NotNull String apiKey, + @NotNull String sdkName, + @NotNull String sdkVersion, + @Nullable String apiBaseUrl, + @Nullable AssignmentLogger assignmentLogger, + @Nullable BanditLogger banditLogger, + @Nullable IConfigurationStore configurationStore, + boolean isGracefulMode, + boolean expectObfuscatedConfig, + boolean supportBandits, + @Nullable CompletableFuture initialConfiguration, + @Nullable IAssignmentCache assignmentCache, + @Nullable IAssignmentCache banditAssignmentCache) { + super( + apiKey, + sdkName, + sdkVersion, + apiBaseUrl, + assignmentLogger, + banditLogger, + configurationStore, + isGracefulMode, + expectObfuscatedConfig, + supportBandits, + initialConfiguration, + assignmentCache, + banditAssignmentCache, + new JacksonConfigurationParser(), + new OkHttpEppoClient()); + } + + // ==================== JSON Assignment Methods (Jackson-dependent) ==================== + + public JsonNode getJSONAssignment(String flagKey, String subjectKey, JsonNode defaultValue) { + return getJSONAssignment(flagKey, subjectKey, new Attributes(), defaultValue); + } + + public JsonNode getJSONAssignment( + String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { + return this.getJSONAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue) + .getVariation(); + } + + public AssignmentDetails getJSONAssignmentDetails( + String flagKey, String subjectKey, JsonNode defaultValue) { + return this.getJSONAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); + } + + public AssignmentDetails getJSONAssignmentDetails( + String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { + try { + return this.getTypedAssignmentWithDetails( + flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.JSON); + } catch (Exception e) { + String defaultValueString = defaultValue != null ? defaultValue.toString() : null; + return new AssignmentDetails<>( + throwIfNotGraceful(e, defaultValue), + null, + EvaluationDetails.buildDefault( + getConfiguration().getEnvironmentName(), + getConfiguration().getConfigFetchedAt(), + getConfiguration().getConfigPublishedAt(), + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValueString))); + } + } +} From 79705cbf66cdf4dccc076a9ec00df8c96744f964 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Tue, 10 Feb 2026 14:56:48 -0700 Subject: [PATCH 19/44] restore json methods for now --- .../java/cloud/eppo/CommonEppoClient.java | 44 ------------------ src/main/java/cloud/eppo/BaseEppoClient.java | 46 +++++++++---------- 2 files changed, 23 insertions(+), 67 deletions(-) diff --git a/eppo-sdk-common/src/main/java/cloud/eppo/CommonEppoClient.java b/eppo-sdk-common/src/main/java/cloud/eppo/CommonEppoClient.java index 02386ca7..0194d442 100644 --- a/eppo-sdk-common/src/main/java/cloud/eppo/CommonEppoClient.java +++ b/eppo-sdk-common/src/main/java/cloud/eppo/CommonEppoClient.java @@ -1,16 +1,9 @@ package cloud.eppo; -import cloud.eppo.api.AssignmentDetails; -import cloud.eppo.api.Attributes; import cloud.eppo.api.Configuration; -import cloud.eppo.api.EppoValue; -import cloud.eppo.api.EvaluationDetails; -import cloud.eppo.api.FlagEvaluationCode; import cloud.eppo.api.IAssignmentCache; -import cloud.eppo.api.dto.VariationType; import cloud.eppo.logging.AssignmentLogger; import cloud.eppo.logging.BanditLogger; -import com.fasterxml.jackson.databind.JsonNode; import java.util.concurrent.CompletableFuture; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -77,41 +70,4 @@ protected CommonEppoClient( new JacksonConfigurationParser(), new OkHttpEppoClient()); } - - // ==================== JSON Assignment Methods (Jackson-dependent) ==================== - - public JsonNode getJSONAssignment(String flagKey, String subjectKey, JsonNode defaultValue) { - return getJSONAssignment(flagKey, subjectKey, new Attributes(), defaultValue); - } - - public JsonNode getJSONAssignment( - String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { - return this.getJSONAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue) - .getVariation(); - } - - public AssignmentDetails getJSONAssignmentDetails( - String flagKey, String subjectKey, JsonNode defaultValue) { - return this.getJSONAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); - } - - public AssignmentDetails getJSONAssignmentDetails( - String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { - try { - return this.getTypedAssignmentWithDetails( - flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.JSON); - } catch (Exception e) { - String defaultValueString = defaultValue != null ? defaultValue.toString() : null; - return new AssignmentDetails<>( - throwIfNotGraceful(e, defaultValue), - null, - EvaluationDetails.buildDefault( - getConfiguration().getEnvironmentName(), - getConfiguration().getConfigFetchedAt(), - getConfiguration().getConfigPublishedAt(), - FlagEvaluationCode.ASSIGNMENT_ERROR, - e.getMessage(), - EppoValue.valueOf(defaultValueString))); - } - } } diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index bc6a2d5c..a2b011b0 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -567,27 +567,28 @@ public AssignmentDetails getStringAssignmentDetails( } } - public String getJSONStringAssignment(String flagKey, String subjectKey, String defaultValue) { - return this.getJSONStringAssignment(flagKey, subjectKey, new Attributes(), defaultValue); + public JsonNode getJSONAssignment(String flagKey, String subjectKey, JsonNode defaultValue) { + return getJSONAssignment(flagKey, subjectKey, new Attributes(), defaultValue); } - public String getJSONStringAssignment( - String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { - return this.getJSONStringAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue) + public JsonNode getJSONAssignment( + String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { + return this.getJSONAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue) .getVariation(); } - public AssignmentDetails getJSONStringAssignmentDetails( - String flagKey, String subjectKey, String defaultValue) { - return this.getJSONStringAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); + public AssignmentDetails getJSONAssignmentDetails( + String flagKey, String subjectKey, JsonNode defaultValue) { + return this.getJSONAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); } - public AssignmentDetails getJSONStringAssignmentDetails( - String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { + public AssignmentDetails getJSONAssignmentDetails( + String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { try { return this.getTypedAssignmentWithDetails( flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.JSON); } catch (Exception e) { + String defaultValueString = defaultValue != null ? defaultValue.toString() : null; return new AssignmentDetails<>( throwIfNotGraceful(e, defaultValue), null, @@ -597,32 +598,31 @@ public AssignmentDetails getJSONStringAssignmentDetails( getConfiguration().getConfigPublishedAt(), FlagEvaluationCode.ASSIGNMENT_ERROR, e.getMessage(), - EppoValue.valueOf(defaultValue))); + EppoValue.valueOf(defaultValueString))); } } - public JsonNode getJSONAssignment(String flagKey, String subjectKey, JsonNode defaultValue) { - return getJSONAssignment(flagKey, subjectKey, new Attributes(), defaultValue); + public String getJSONStringAssignment(String flagKey, String subjectKey, String defaultValue) { + return this.getJSONStringAssignment(flagKey, subjectKey, new Attributes(), defaultValue); } - public JsonNode getJSONAssignment( - String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { - return this.getJSONAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue) + public String getJSONStringAssignment( + String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { + return this.getJSONStringAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue) .getVariation(); } - public AssignmentDetails getJSONAssignmentDetails( - String flagKey, String subjectKey, JsonNode defaultValue) { - return this.getJSONAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); + public AssignmentDetails getJSONStringAssignmentDetails( + String flagKey, String subjectKey, String defaultValue) { + return this.getJSONStringAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); } - public AssignmentDetails getJSONAssignmentDetails( - String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { + public AssignmentDetails getJSONStringAssignmentDetails( + String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { try { return this.getTypedAssignmentWithDetails( flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.JSON); } catch (Exception e) { - String defaultValueString = defaultValue != null ? defaultValue.toString() : null; return new AssignmentDetails<>( throwIfNotGraceful(e, defaultValue), null, @@ -632,7 +632,7 @@ public AssignmentDetails getJSONAssignmentDetails( getConfiguration().getConfigPublishedAt(), FlagEvaluationCode.ASSIGNMENT_ERROR, e.getMessage(), - EppoValue.valueOf(defaultValueString))); + EppoValue.valueOf(defaultValue))); } } From 50fbae3984016942af9e2d52744af0c7a4eedb23 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Tue, 10 Feb 2026 15:07:34 -0700 Subject: [PATCH 20/44] tidy --- .../eppo/JacksonConfigurationParserTest.java | 10 ++++----- .../cloud/eppo/ConfigurationRequestor.java | 21 +++++++++++++------ .../eppo/ConfigurationRequestorTest.java | 13 ------------ .../java/cloud/eppo/helpers/TestUtils.java | 3 --- 4 files changed, 20 insertions(+), 27 deletions(-) diff --git a/eppo-sdk-common/src/test/java/cloud/eppo/JacksonConfigurationParserTest.java b/eppo-sdk-common/src/test/java/cloud/eppo/JacksonConfigurationParserTest.java index 2315e20f..63e3be79 100644 --- a/eppo-sdk-common/src/test/java/cloud/eppo/JacksonConfigurationParserTest.java +++ b/eppo-sdk-common/src/test/java/cloud/eppo/JacksonConfigurationParserTest.java @@ -49,11 +49,11 @@ public void testParseFlagConfig() throws IOException { public void testParseBanditParams() throws IOException { byte[] banditParamsJson = loadTestResource("shared/ufc/bandit-models-v1.json"); - BanditParametersResponse banditsResponse = parser.parseBanditParams(banditParamsJson); + BanditParametersResponse banditResponse = parser.parseBanditParams(banditParamsJson); + assertNotNull(banditResponse); + + Map bandits = banditResponse.getBandits(); - assertNotNull(banditsResponse); - assertNotNull(banditsResponse.getBandits()); - Map bandits = banditsResponse.getBandits(); assertThat(bandits).containsKey("banner_bandit"); BanditParameters bannerBandit = bandits.get("banner_bandit"); @@ -103,9 +103,9 @@ public void testParseBanditParamsEmptyBandits() { byte[] emptyBanditsJson = "{\"bandits\": {}}".getBytes(); BanditParametersResponse banditsResponse = parser.parseBanditParams(emptyBanditsJson); + assertNotNull(banditsResponse); Map bandits = banditsResponse.getBandits(); - assertNotNull(banditsResponse); assertNotNull(bandits); assertThat(bandits).isEmpty(); } diff --git a/src/main/java/cloud/eppo/ConfigurationRequestor.java b/src/main/java/cloud/eppo/ConfigurationRequestor.java index da256ba9..e00e2ccf 100644 --- a/src/main/java/cloud/eppo/ConfigurationRequestor.java +++ b/src/main/java/cloud/eppo/ConfigurationRequestor.java @@ -1,7 +1,7 @@ package cloud.eppo; import cloud.eppo.api.Configuration; -import cloud.eppo.api.dto.BanditParameters; +import cloud.eppo.api.dto.BanditParametersResponse; import cloud.eppo.api.dto.FlagConfigResponse; import cloud.eppo.callback.CallbackManager; import cloud.eppo.http.EppoConfigurationClient; @@ -10,7 +10,6 @@ import cloud.eppo.http.EppoConfigurationResponse; import cloud.eppo.parser.ConfigurationParseException; import cloud.eppo.parser.ConfigurationParser; -import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.function.Consumer; @@ -163,10 +162,9 @@ void fetchAndSaveFromRemote() { if (banditParametersJsonBytes != null) { if (configurationParser != null) { try { - Map bandits = + BanditParametersResponse bandits = configurationParser.parseBanditParams(banditParametersJsonBytes); - // Note: Configuration.Builder handles bandits directly from bytes for now - configBuilder.banditParameters(banditParametersJsonBytes); + configBuilder.banditParameters(bandits); } catch (ConfigurationParseException e) { log.error("Failed to parse bandit parameters", e); throw new RuntimeException(e); @@ -280,7 +278,18 @@ private CompletableFuture buildAndSaveConfiguration( if (supportBandits && configBuilder.requiresUpdatedBanditModels()) { byte[] banditParametersJsonBytes = fetchBanditParametersAsync(); if (banditParametersJsonBytes != null) { - configBuilder.banditParameters(banditParametersJsonBytes); + if (configurationParser != null) { + try { + BanditParametersResponse bandits = + configurationParser.parseBanditParams(banditParametersJsonBytes); + configBuilder.banditParameters(bandits); + } catch (ConfigurationParseException e) { + log.error("Failed to parse bandit parameters", e); + throw new RuntimeException(e); + } + } else { + configBuilder.banditParameters(banditParametersJsonBytes); + } } } diff --git a/src/test/java/cloud/eppo/ConfigurationRequestorTest.java b/src/test/java/cloud/eppo/ConfigurationRequestorTest.java index 37a305c8..93838f16 100644 --- a/src/test/java/cloud/eppo/ConfigurationRequestorTest.java +++ b/src/test/java/cloud/eppo/ConfigurationRequestorTest.java @@ -30,8 +30,6 @@ public class ConfigurationRequestorTest { - // ==================== Shared Test Fixtures ==================== - private static final File INITIAL_FLAG_CONFIG_FILE = new File("src/test/resources/static/initial-flag-config.json"); private static final File DIFFERENT_FLAG_CONFIG_FILE = @@ -50,8 +48,6 @@ private static String loadInitialFlagConfigString() throws IOException { return FileUtils.readFileToString(INITIAL_FLAG_CONFIG_FILE, StandardCharsets.UTF_8); } - // ==================== Initial Configuration Tests ==================== - @Nested class InitialConfigurationTests { private IConfigurationStore configStore; @@ -176,8 +172,6 @@ void testCacheWritesAfterBrokenFetch() throws IOException { } } - // ==================== Configuration Change Listener Tests ==================== - @Nested class ConfigurationChangeListenerTests { private ConfigurationStore mockConfigStore; @@ -358,8 +352,6 @@ void testUnsubscribeOneOfMultipleConfigurationChangeListeners() { } } - // ==================== Tests for EppoConfigurationClient ==================== - @Nested class EppoConfigurationClientTests { private IConfigurationStore configStore; @@ -472,8 +464,6 @@ void testFetchWithEppoConfigurationClientErrorResponse() { } } - // ==================== Tests for ConfigurationParser ==================== - @Nested class ConfigurationParserTests { private IConfigurationStore configStore; @@ -598,9 +588,6 @@ void testFetchWithConfigurationParserParseError() throws Exception { } } - // ==================== Integration Tests with Real Implementations ==================== - // These tests use OkHttpEppoClient and JacksonConfigurationParser from eppo-sdk-common - @Nested class IntegrationTests { private okhttp3.mockwebserver.MockWebServer mockServer; diff --git a/src/test/java/cloud/eppo/helpers/TestUtils.java b/src/test/java/cloud/eppo/helpers/TestUtils.java index 6fc845e2..1e80538d 100644 --- a/src/test/java/cloud/eppo/helpers/TestUtils.java +++ b/src/test/java/cloud/eppo/helpers/TestUtils.java @@ -73,9 +73,6 @@ public static EppoConfigurationClient mockConfigurationClientErrorResponse() { return mockClient; } - // ==================== Legacy methods (deprecated, use mockConfigurationClient instead) - // ==================== - /** @deprecated Use mockConfigurationClient() and pass to BaseEppoClient constructor instead */ @Deprecated @SuppressWarnings("SameParameterValue") From bd9ca5324c3a903066beba8b43a7fd9a196e20bf Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 11 Feb 2026 09:33:58 -0700 Subject: [PATCH 21/44] use config.flagsSnapshotId --- .../java/cloud/eppo/ConfigurationRequestor.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/main/java/cloud/eppo/ConfigurationRequestor.java b/src/main/java/cloud/eppo/ConfigurationRequestor.java index e00e2ccf..2cc0c434 100644 --- a/src/main/java/cloud/eppo/ConfigurationRequestor.java +++ b/src/main/java/cloud/eppo/ConfigurationRequestor.java @@ -30,9 +30,6 @@ public class ConfigurationRequestor { @Nullable private final EppoConfigurationClient eppoConfigurationClient; @NotNull private final EppoConfigurationRequestFactory requestFactory; - // Track version IDs for conditional requests (used with EppoConfigurationClient) - private String lastFlagConfigVersionId = null; - private CompletableFuture remoteFetchFuture = null; private CompletableFuture configurationFuture = null; private boolean initialConfigSet = false; @@ -112,10 +109,11 @@ void fetchAndSaveFromRemote() { byte[] flagConfigurationJsonBytes; FlagConfigResponse flagConfigResponse = null; + String flagsSnapshotId = null; // Use EppoConfigurationClient if available, otherwise fall back to EppoHttpClient if (eppoConfigurationClient != null) { EppoConfigurationRequest flagRequest = - requestFactory.createFlagConfigRequest(lastFlagConfigVersionId); + requestFactory.createFlagConfigRequest(lastConfig.getFlagsSnapshotId()); EppoConfigurationResponse flagResponse; try { flagResponse = eppoConfigurationClient.get(flagRequest).get(); @@ -134,8 +132,8 @@ void fetchAndSaveFromRemote() { "Failed to fetch flag configuration. Status: " + flagResponse.getStatusCode()); } - lastFlagConfigVersionId = flagResponse.getVersionId(); flagConfigurationJsonBytes = flagResponse.getBody(); + flagsSnapshotId = flagResponse.getVersionId(); } else { flagConfigurationJsonBytes = client.get(Constants.FLAG_CONFIG_ENDPOINT); } @@ -157,6 +155,8 @@ void fetchAndSaveFromRemote() { Configuration.builder(flagConfigurationJsonBytes).banditParametersFromConfig(lastConfig); } + configBuilder.flagsSnapshotId(flagsSnapshotId); + if (supportBandits && configBuilder.requiresUpdatedBanditModels()) { byte[] banditParametersJsonBytes = fetchBanditParameters(); if (banditParametersJsonBytes != null) { @@ -213,7 +213,7 @@ CompletableFuture fetchAndSaveFromRemoteAsync() { // Use EppoConfigurationClient if available, otherwise fall back to EppoHttpClient if (eppoConfigurationClient != null) { EppoConfigurationRequest flagRequest = - requestFactory.createFlagConfigRequest(lastFlagConfigVersionId); + requestFactory.createFlagConfigRequest(lastConfig.getFlagsSnapshotId()); remoteFetchFuture = eppoConfigurationClient @@ -232,7 +232,6 @@ CompletableFuture fetchAndSaveFromRemoteAsync() { + flagResponse.getStatusCode()); } - lastFlagConfigVersionId = flagResponse.getVersionId(); byte[] flagConfigJsonBytes = flagResponse.getBody(); return buildAndSaveConfiguration(flagConfigJsonBytes, lastConfig); From 350c6366b93828abf5456b6e352351f8b2485611 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 12 Feb 2026 13:04:44 -0700 Subject: [PATCH 22/44] merge artifact --- .../cloud/eppo/JacksonConfigurationParserTest.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/eppo-sdk-common/src/test/java/cloud/eppo/JacksonConfigurationParserTest.java b/eppo-sdk-common/src/test/java/cloud/eppo/JacksonConfigurationParserTest.java index 63e3be79..2315e20f 100644 --- a/eppo-sdk-common/src/test/java/cloud/eppo/JacksonConfigurationParserTest.java +++ b/eppo-sdk-common/src/test/java/cloud/eppo/JacksonConfigurationParserTest.java @@ -49,11 +49,11 @@ public void testParseFlagConfig() throws IOException { public void testParseBanditParams() throws IOException { byte[] banditParamsJson = loadTestResource("shared/ufc/bandit-models-v1.json"); - BanditParametersResponse banditResponse = parser.parseBanditParams(banditParamsJson); - assertNotNull(banditResponse); - - Map bandits = banditResponse.getBandits(); + BanditParametersResponse banditsResponse = parser.parseBanditParams(banditParamsJson); + assertNotNull(banditsResponse); + assertNotNull(banditsResponse.getBandits()); + Map bandits = banditsResponse.getBandits(); assertThat(bandits).containsKey("banner_bandit"); BanditParameters bannerBandit = bandits.get("banner_bandit"); @@ -103,9 +103,9 @@ public void testParseBanditParamsEmptyBandits() { byte[] emptyBanditsJson = "{\"bandits\": {}}".getBytes(); BanditParametersResponse banditsResponse = parser.parseBanditParams(emptyBanditsJson); - assertNotNull(banditsResponse); Map bandits = banditsResponse.getBandits(); + assertNotNull(banditsResponse); assertNotNull(bandits); assertThat(bandits).isEmpty(); } From 3b117fbebe09e202f7402cd3a6ac7ef1604d5204 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 12 Feb 2026 13:12:48 -0700 Subject: [PATCH 23/44] comments --- src/main/java/cloud/eppo/ConfigurationRequestor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/cloud/eppo/ConfigurationRequestor.java b/src/main/java/cloud/eppo/ConfigurationRequestor.java index 2cc0c434..9f85c65a 100644 --- a/src/main/java/cloud/eppo/ConfigurationRequestor.java +++ b/src/main/java/cloud/eppo/ConfigurationRequestor.java @@ -252,7 +252,7 @@ CompletableFuture fetchAndSaveFromRemoteAsync() { return remoteFetchFuture; } - /** Builds configuration from flag bytes and saves it. Used by async fetch. */ + // Common handling for building config and conditionally loading bandit parameters, async. private CompletableFuture buildAndSaveConfiguration( byte[] flagConfigJsonBytes, Configuration lastConfig) { Configuration.Builder configBuilder; From ebffdb7de169a713a7470205b090389542d8fb4e Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 11 Feb 2026 08:12:23 -0700 Subject: [PATCH 24/44] Hard cut to ConfigurationClient --- src/main/java/cloud/eppo/BaseEppoClient.java | 53 +---- .../cloud/eppo/ConfigurationRequestor.java | 184 +++++++----------- .../cloud/eppo/BaseEppoClientBanditTest.java | 4 +- .../java/cloud/eppo/BaseEppoClientTest.java | 11 +- .../eppo/ConfigurationRequestorTest.java | 125 +++++------- .../cloud/eppo/ProfileBaseEppoClientTest.java | 2 +- .../java/cloud/eppo/helpers/TestUtils.java | 66 ------- 7 files changed, 127 insertions(+), 318 deletions(-) diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index a2b011b0..efcbb8cf 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -47,42 +47,6 @@ public class BaseEppoClient { private final CompletableFuture initialConfigFuture; - // Fields useful for testing in situations where we want to mock the http client or configuration - // store (accessed via reflection) - /** @noinspection FieldMayBeFinal */ - private static EppoHttpClient httpClientOverride = null; - - protected BaseEppoClient( - @NotNull String apiKey, - @NotNull String sdkName, - @NotNull String sdkVersion, - @Nullable String apiBaseUrl, - @Nullable AssignmentLogger assignmentLogger, - @Nullable BanditLogger banditLogger, - @Nullable IConfigurationStore configurationStore, - boolean isGracefulMode, - boolean expectObfuscatedConfig, - boolean supportBandits, - @Nullable CompletableFuture initialConfiguration, - @Nullable IAssignmentCache assignmentCache, - @Nullable IAssignmentCache banditAssignmentCache) { - this( - apiKey, - sdkName, - sdkVersion, - apiBaseUrl, - assignmentLogger, - banditLogger, - configurationStore, - isGracefulMode, - expectObfuscatedConfig, - supportBandits, - initialConfiguration, - assignmentCache, - banditAssignmentCache, - null, - null); - } // It is important that the bandit assignment cache expire with a short-enough TTL to last about // one user session. // The recommended is 10 minutes (per @Sven) @@ -101,7 +65,7 @@ protected BaseEppoClient( @Nullable IAssignmentCache assignmentCache, @Nullable IAssignmentCache banditAssignmentCache, @Nullable ConfigurationParser configurationParser, - @Nullable EppoConfigurationClient eppoConfigurationClient) { + @NotNull EppoConfigurationClient configurationClient) { if (apiBaseUrl == null) { apiBaseUrl = Constants.DEFAULT_BASE_URL; @@ -114,7 +78,6 @@ protected BaseEppoClient( ApiEndpoints endpointHelper = new ApiEndpoints(sdkKey, apiBaseUrl); String effectiveBaseUrl = endpointHelper.getBaseUrl(); - EppoHttpClient httpClient = buildHttpClient(apiBaseUrl, sdkKey, sdkName, sdkVersion); this.configurationStore = configurationStore != null ? configurationStore : new ConfigurationStore(); @@ -122,15 +85,12 @@ protected BaseEppoClient( new EppoConfigurationRequestFactory( effectiveBaseUrl, sdkKey.getToken(), sdkName, sdkVersion); - // For now, the configuration is only obfuscated for Android clients requestor = new ConfigurationRequestor( this.configurationStore, - httpClient, - expectObfuscatedConfig, supportBandits, configurationParser, - eppoConfigurationClient, + configurationClient, requestFactory); initialConfigFuture = initialConfiguration != null @@ -145,15 +105,6 @@ protected BaseEppoClient( this.sdkVersion = sdkVersion; } - private EppoHttpClient buildHttpClient( - String apiBaseUrl, SDKKey sdkKey, String sdkName, String sdkVersion) { - ApiEndpoints endpointHelper = new ApiEndpoints(sdkKey, apiBaseUrl); - - return httpClientOverride != null - ? httpClientOverride - : new EppoHttpClient(endpointHelper.getBaseUrl(), sdkKey.getToken(), sdkName, sdkVersion); - } - protected void loadConfiguration() { try { requestor.fetchAndSaveFromRemote(); diff --git a/src/main/java/cloud/eppo/ConfigurationRequestor.java b/src/main/java/cloud/eppo/ConfigurationRequestor.java index 9f85c65a..d4b8d455 100644 --- a/src/main/java/cloud/eppo/ConfigurationRequestor.java +++ b/src/main/java/cloud/eppo/ConfigurationRequestor.java @@ -21,13 +21,11 @@ public class ConfigurationRequestor { private static final Logger log = LoggerFactory.getLogger(ConfigurationRequestor.class); - private final EppoHttpClient client; private final IConfigurationStore configurationStore; private final boolean supportBandits; - // Optional custom implementations @Nullable private final ConfigurationParser configurationParser; - @Nullable private final EppoConfigurationClient eppoConfigurationClient; + @NotNull private final EppoConfigurationClient configurationClient; @NotNull private final EppoConfigurationRequestFactory requestFactory; private CompletableFuture remoteFetchFuture = null; @@ -38,17 +36,14 @@ public class ConfigurationRequestor { public ConfigurationRequestor( @NotNull IConfigurationStore configurationStore, - @NotNull EppoHttpClient client, - boolean expectObfuscatedConfig, boolean supportBandits, @Nullable ConfigurationParser configurationParser, - @Nullable EppoConfigurationClient eppoConfigurationClient, + @NotNull EppoConfigurationClient configurationClient, @NotNull EppoConfigurationRequestFactory requestFactory) { this.configurationStore = configurationStore; - this.client = client; this.supportBandits = supportBandits; this.configurationParser = configurationParser; - this.eppoConfigurationClient = eppoConfigurationClient; + this.configurationClient = configurationClient; this.requestFactory = requestFactory; } @@ -106,43 +101,34 @@ void fetchAndSaveFromRemote() { // Reuse the `lastConfig` as its bandits may be useful Configuration lastConfig = configurationStore.getConfiguration(); - byte[] flagConfigurationJsonBytes; - FlagConfigResponse flagConfigResponse = null; - - String flagsSnapshotId = null; - // Use EppoConfigurationClient if available, otherwise fall back to EppoHttpClient - if (eppoConfigurationClient != null) { - EppoConfigurationRequest flagRequest = - requestFactory.createFlagConfigRequest(lastConfig.getFlagsSnapshotId()); - EppoConfigurationResponse flagResponse; - try { - flagResponse = eppoConfigurationClient.get(flagRequest).get(); - } catch (InterruptedException | ExecutionException e) { - log.error("Config fetch interrupted", e); - throw new RuntimeException(e); - } - - if (flagResponse.isNotModified()) { - log.debug("Flag configuration not modified"); - return; - } + EppoConfigurationRequest flagRequest = + requestFactory.createFlagConfigRequest(lastConfig.getFlagsSnapshotId()); + EppoConfigurationResponse flagResponse; + try { + flagResponse = configurationClient.get(flagRequest).get(); + } catch (InterruptedException | ExecutionException e) { + log.error("Config fetch interrupted", e); + throw new RuntimeException(e); + } - if (!flagResponse.isSuccessful()) { - throw new RuntimeException( - "Failed to fetch flag configuration. Status: " + flagResponse.getStatusCode()); - } + if (flagResponse.isNotModified()) { + log.debug("Flag configuration not modified"); + return; + } - flagConfigurationJsonBytes = flagResponse.getBody(); - flagsSnapshotId = flagResponse.getVersionId(); - } else { - flagConfigurationJsonBytes = client.get(Constants.FLAG_CONFIG_ENDPOINT); + if (!flagResponse.isSuccessful()) { + throw new RuntimeException( + "Failed to fetch flag configuration. Status: " + flagResponse.getStatusCode()); } + byte[] flagConfigurationJsonBytes = flagResponse.getBody(); + // Use ConfigurationParser if available, otherwise use Configuration.builder Configuration.Builder configBuilder; if (configurationParser != null) { try { - flagConfigResponse = configurationParser.parseFlagConfig(flagConfigurationJsonBytes); + FlagConfigResponse flagConfigResponse = + configurationParser.parseFlagConfig(flagConfigurationJsonBytes); configBuilder = new Configuration.Builder(flagConfigurationJsonBytes, flagConfigResponse) .banditParametersFromConfig(lastConfig); @@ -155,7 +141,7 @@ void fetchAndSaveFromRemote() { Configuration.builder(flagConfigurationJsonBytes).banditParametersFromConfig(lastConfig); } - configBuilder.flagsSnapshotId(flagsSnapshotId); + configBuilder.flagsSnapshotId(flagResponse.getVersionId()); if (supportBandits && configBuilder.requiresUpdatedBanditModels()) { byte[] banditParametersJsonBytes = fetchBanditParameters(); @@ -178,25 +164,21 @@ void fetchAndSaveFromRemote() { saveConfigurationAndNotify(configBuilder.build()).join(); } - /** Fetches bandit parameters using the appropriate client. */ + /** Fetches bandit parameters from the configuration client. */ private byte[] fetchBanditParameters() { - if (eppoConfigurationClient != null) { - EppoConfigurationRequest banditRequest = requestFactory.createBanditParamsRequest(); - EppoConfigurationResponse banditResponse; - try { - banditResponse = eppoConfigurationClient.get(banditRequest).get(); - } catch (InterruptedException | ExecutionException e) { - log.error("Bandit fetch interrupted", e); - throw new RuntimeException(e); - } + EppoConfigurationRequest banditRequest = requestFactory.createBanditParamsRequest(); + EppoConfigurationResponse banditResponse; + try { + banditResponse = configurationClient.get(banditRequest).get(); + } catch (InterruptedException | ExecutionException e) { + log.error("Bandit fetch interrupted", e); + throw new RuntimeException(e); + } - if (banditResponse.isSuccessful() && banditResponse.getBody() != null) { - return banditResponse.getBody(); - } - return null; - } else { - return client.get(Constants.BANDIT_ENDPOINT); + if (banditResponse.isSuccessful() && banditResponse.getBody() != null) { + return banditResponse.getBody(); } + return null; } /** Loads configuration asynchronously from the API server, off-thread. */ @@ -210,60 +192,45 @@ CompletableFuture fetchAndSaveFromRemoteAsync() { remoteFetchFuture = null; } - // Use EppoConfigurationClient if available, otherwise fall back to EppoHttpClient - if (eppoConfigurationClient != null) { - EppoConfigurationRequest flagRequest = - requestFactory.createFlagConfigRequest(lastConfig.getFlagsSnapshotId()); - - remoteFetchFuture = - eppoConfigurationClient - .get(flagRequest) - .thenCompose( - flagResponse -> { - synchronized (this) { - if (flagResponse.isNotModified()) { - log.debug("Flag configuration not modified"); - return CompletableFuture.completedFuture(null); - } - - if (!flagResponse.isSuccessful()) { - throw new RuntimeException( - "Failed to fetch flag configuration. Status: " - + flagResponse.getStatusCode()); - } - - byte[] flagConfigJsonBytes = flagResponse.getBody(); - - return buildAndSaveConfiguration(flagConfigJsonBytes, lastConfig); + EppoConfigurationRequest flagRequest = + requestFactory.createFlagConfigRequest(lastConfig.getFlagsSnapshotId()); + + remoteFetchFuture = + configurationClient + .get(flagRequest) + .thenCompose( + flagResponse -> { + synchronized (this) { + if (flagResponse.isNotModified()) { + log.debug("Flag configuration not modified"); + return CompletableFuture.completedFuture(null); } - }); - } else { - remoteFetchFuture = - client - .getAsync(Constants.FLAG_CONFIG_ENDPOINT) - .thenCompose( - flagConfigJsonBytes -> { - synchronized (this) { - return buildAndSaveConfiguration(flagConfigJsonBytes, lastConfig); + + if (!flagResponse.isSuccessful()) { + throw new RuntimeException( + "Failed to fetch flag configuration. Status: " + + flagResponse.getStatusCode()); } - }); - } + + return buildAndSaveConfiguration(flagResponse, lastConfig); + } + }); return remoteFetchFuture; } // Common handling for building config and conditionally loading bandit parameters, async. private CompletableFuture buildAndSaveConfiguration( - byte[] flagConfigJsonBytes, Configuration lastConfig) { + EppoConfigurationResponse flagResponse, Configuration lastConfig) { Configuration.Builder configBuilder; // Use ConfigurationParser if available if (configurationParser != null) { try { FlagConfigResponse flagConfigResponse = - configurationParser.parseFlagConfig(flagConfigJsonBytes); + configurationParser.parseFlagConfig(flagResponse.getBody()); configBuilder = - new Configuration.Builder(flagConfigJsonBytes, flagConfigResponse) + new Configuration.Builder(flagResponse.getBody(), flagConfigResponse) .banditParametersFromConfig(lastConfig); } catch (ConfigurationParseException e) { log.error("Failed to parse flag configuration", e); @@ -271,9 +238,11 @@ private CompletableFuture buildAndSaveConfiguration( } } else { configBuilder = - Configuration.builder(flagConfigJsonBytes).banditParametersFromConfig(lastConfig); + Configuration.builder(flagResponse.getBody()).banditParametersFromConfig(lastConfig); } + configBuilder.flagsSnapshotId(flagResponse.getVersionId()); + if (supportBandits && configBuilder.requiresUpdatedBanditModels()) { byte[] banditParametersJsonBytes = fetchBanditParametersAsync(); if (banditParametersJsonBytes != null) { @@ -297,25 +266,16 @@ private CompletableFuture buildAndSaveConfiguration( /** Fetches bandit parameters synchronously (used within async flow). */ private byte[] fetchBanditParametersAsync() { - if (eppoConfigurationClient != null) { - EppoConfigurationRequest banditRequest = requestFactory.createBanditParamsRequest(); - try { - EppoConfigurationResponse banditResponse = eppoConfigurationClient.get(banditRequest).get(); - if (banditResponse.isSuccessful() && banditResponse.getBody() != null) { - return banditResponse.getBody(); - } - return null; - } catch (InterruptedException | ExecutionException e) { - log.error("Error fetching bandit parameters: " + e.getMessage()); - throw new RuntimeException(e); - } - } else { - try { - return client.getAsync(Constants.BANDIT_ENDPOINT).get(); - } catch (InterruptedException | ExecutionException e) { - log.error("Error fetching from remote: " + e.getMessage()); - throw new RuntimeException(e); + EppoConfigurationRequest banditRequest = requestFactory.createBanditParamsRequest(); + try { + EppoConfigurationResponse banditResponse = configurationClient.get(banditRequest).get(); + if (banditResponse.isSuccessful() && banditResponse.getBody() != null) { + return banditResponse.getBody(); } + return null; + } catch (InterruptedException | ExecutionException e) { + log.error("Error fetching bandit parameters: " + e.getMessage()); + throw new RuntimeException(e); } } diff --git a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java index 9adbbb0e..97ae97bb 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java @@ -80,7 +80,7 @@ public static void initClient() { new ExpiringInMemoryAssignmentCache( banditAssignmentCache, 50, TimeUnit.MILLISECONDS) {}, null, - null); + new OkHttpEppoClient()); eppoClient.loadConfiguration(); @@ -111,7 +111,7 @@ private BaseEppoClient initClientWithData( null, null, null, - null); + new OkHttpEppoClient()); } @BeforeEach diff --git a/src/test/java/cloud/eppo/BaseEppoClientTest.java b/src/test/java/cloud/eppo/BaseEppoClientTest.java index 288e96ec..0bc0ac18 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientTest.java @@ -178,9 +178,10 @@ private void initClientWithAssignmentCache(IAssignmentCache cache) { } @BeforeEach - public void cleanUp() { - // Reset mock config client before each test - mockConfigClient = null; + public void setUp() { + // Use real OkHttpEppoClient by default for integration tests that fetch real test data + // Individual tests can override with a mock if needed + mockConfigClient = new OkHttpEppoClient(); } @ParameterizedTest @@ -248,7 +249,7 @@ public void testCustomBaseUrl() throws IOException, InterruptedException { null, null, null, - null); + new OkHttpEppoClient()); eppoClient.loadConfiguration(); @@ -868,7 +869,7 @@ public void testGetConfigurationBeforeInitialization() { null, null, null, - null); + new OkHttpEppoClient()); // Get configuration before loading Configuration config = eppoClient.getConfiguration(); diff --git a/src/test/java/cloud/eppo/ConfigurationRequestorTest.java b/src/test/java/cloud/eppo/ConfigurationRequestorTest.java index 93838f16..e165b717 100644 --- a/src/test/java/cloud/eppo/ConfigurationRequestorTest.java +++ b/src/test/java/cloud/eppo/ConfigurationRequestorTest.java @@ -2,7 +2,6 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.*; @@ -51,16 +50,16 @@ private static String loadInitialFlagConfigString() throws IOException { @Nested class InitialConfigurationTests { private IConfigurationStore configStore; - private EppoHttpClient mockHttpClient; + private EppoConfigurationClient mockConfigClient; private ConfigurationRequestor requestor; @BeforeEach void setUp() { configStore = Mockito.spy(new ConfigurationStore()); - mockHttpClient = mock(EppoHttpClient.class); + mockConfigClient = mock(EppoConfigurationClient.class); requestor = new ConfigurationRequestor( - configStore, mockHttpClient, false, true, null, null, createTestRequestFactory()); + configStore, true, null, mockConfigClient, createTestRequestFactory()); } @Test @@ -87,11 +86,12 @@ void testInitialConfigurationFuture() throws IOException { void testInitialConfigurationDoesntClobberFetch() throws IOException { CompletableFuture initialConfigFuture = new CompletableFuture<>(); String flagConfig = loadInitialFlagConfigString(); - CompletableFuture configFetchFuture = new CompletableFuture<>(); String fetchedFlagConfig = FileUtils.readFileToString(DIFFERENT_FLAG_CONFIG_FILE, StandardCharsets.UTF_8); - when(mockHttpClient.getAsync("/flag-config/v1/config")).thenReturn(configFetchFuture); + // Mock the config client to return a completable future that we control + CompletableFuture configFetchFuture = new CompletableFuture<>(); + when(mockConfigClient.get(any(EppoConfigurationRequest.class))).thenReturn(configFetchFuture); requestor.setInitialConfiguration(initialConfigFuture); @@ -104,7 +104,9 @@ void testInitialConfigurationDoesntClobberFetch() throws IOException { // overwrite. CompletableFuture handle = requestor.fetchAndSaveFromRemoteAsync(); - configFetchFuture.complete(fetchedFlagConfig.getBytes(StandardCharsets.UTF_8)); + configFetchFuture.complete( + EppoConfigurationResponse.success( + 200, "version-1", fetchedFlagConfig.getBytes(StandardCharsets.UTF_8))); initialConfigFuture.complete(new Configuration.Builder(flagConfig.getBytes()).build()); assertFalse(configStore.getConfiguration().isEmpty()); @@ -121,9 +123,9 @@ void testInitialConfigurationDoesntClobberFetch() throws IOException { void testBrokenFetchDoesntClobberCache() throws IOException { CompletableFuture initialConfigFuture = new CompletableFuture<>(); String flagConfig = loadInitialFlagConfigString(); - CompletableFuture configFetchFuture = new CompletableFuture<>(); - when(mockHttpClient.getAsync("/flag-config/v1/config")).thenReturn(configFetchFuture); + CompletableFuture configFetchFuture = new CompletableFuture<>(); + when(mockConfigClient.get(any(EppoConfigurationRequest.class))).thenReturn(configFetchFuture); requestor.setInitialConfiguration(initialConfigFuture); @@ -148,9 +150,9 @@ void testBrokenFetchDoesntClobberCache() throws IOException { void testCacheWritesAfterBrokenFetch() throws IOException { CompletableFuture initialConfigFuture = new CompletableFuture<>(); String flagConfig = loadInitialFlagConfigString(); - CompletableFuture configFetchFuture = new CompletableFuture<>(); - when(mockHttpClient.getAsync("/flag-config/v1/config")).thenReturn(configFetchFuture); + CompletableFuture configFetchFuture = new CompletableFuture<>(); + when(mockConfigClient.get(any(EppoConfigurationRequest.class))).thenReturn(configFetchFuture); requestor.setInitialConfiguration(initialConfigFuture); verify(configStore, times(0)).saveConfiguration(any()); @@ -175,22 +177,35 @@ void testCacheWritesAfterBrokenFetch() throws IOException { @Nested class ConfigurationChangeListenerTests { private ConfigurationStore mockConfigStore; - private EppoHttpClient mockHttpClient; + private EppoConfigurationClient mockConfigClient; private ConfigurationRequestor requestor; @BeforeEach void setUp() { mockConfigStore = mock(ConfigurationStore.class); - mockHttpClient = mock(EppoHttpClient.class); + mockConfigClient = mock(EppoConfigurationClient.class); + when(mockConfigStore.getConfiguration()).thenReturn(Configuration.emptyConfig()); requestor = new ConfigurationRequestor( - mockConfigStore, mockHttpClient, false, true, null, null, createTestRequestFactory()); + mockConfigStore, true, null, mockConfigClient, createTestRequestFactory()); + } + + private void stubConfigClientSuccess(byte[] responseBody) { + EppoConfigurationResponse successResponse = + EppoConfigurationResponse.success(200, "version-1", responseBody); + when(mockConfigClient.get(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(successResponse)); + } + + private void stubConfigClientFailure() { + when(mockConfigClient.get(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.failedFuture(new RuntimeException("Fetch failed"))); } @Test void testConfigurationChangeListener() throws IOException { String flagConfig = loadInitialFlagConfigString(); - when(mockHttpClient.get(anyString())).thenReturn(flagConfig.getBytes()); + stubConfigClientSuccess(flagConfig.getBytes()); when(mockConfigStore.saveConfiguration(any())) .thenReturn(CompletableFuture.completedFuture(null)); @@ -210,7 +225,7 @@ void testConfigurationChangeListener() throws IOException { @Test void testMultipleConfigurationChangeListeners() { - when(mockHttpClient.get(anyString())).thenReturn("{}".getBytes()); + stubConfigClientSuccess("{}".getBytes()); when(mockConfigStore.saveConfiguration(any())) .thenReturn(CompletableFuture.completedFuture(null)); @@ -237,7 +252,7 @@ void testMultipleConfigurationChangeListeners() { @Test void testConfigurationChangeListenerIgnoresFailedFetch() { - when(mockHttpClient.get(anyString())).thenThrow(new RuntimeException("Fetch failed")); + stubConfigClientFailure(); AtomicInteger callCount = new AtomicInteger(0); requestor.onConfigurationChange(v -> callCount.incrementAndGet()); @@ -252,7 +267,7 @@ void testConfigurationChangeListenerIgnoresFailedFetch() { @Test void testConfigurationChangeListenerIgnoresFailedSave() { - when(mockHttpClient.get(anyString())).thenReturn("{}".getBytes()); + stubConfigClientSuccess("{}".getBytes()); when(mockConfigStore.saveConfiguration(any())) .thenReturn( CompletableFuture.supplyAsync( @@ -273,8 +288,10 @@ void testConfigurationChangeListenerIgnoresFailedSave() { @Test void testConfigurationChangeListenerAsyncSave() { - when(mockHttpClient.getAsync(anyString())) - .thenReturn(CompletableFuture.completedFuture("{\"flags\":{}}".getBytes())); + EppoConfigurationResponse successResponse = + EppoConfigurationResponse.success(200, "version-1", "{\"flags\":{}}".getBytes()); + when(mockConfigClient.get(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(successResponse)); CompletableFuture saveFuture = new CompletableFuture<>(); when(mockConfigStore.saveConfiguration(any())).thenReturn(saveFuture); @@ -293,7 +310,7 @@ void testConfigurationChangeListenerAsyncSave() { @Test void testUnsubscribeFromConfigurationChangeByReference() throws IOException { String flagConfig = loadInitialFlagConfigString(); - when(mockHttpClient.get(anyString())).thenReturn(flagConfig.getBytes()); + stubConfigClientSuccess(flagConfig.getBytes()); when(mockConfigStore.saveConfiguration(any())) .thenReturn(CompletableFuture.completedFuture(null)); @@ -321,7 +338,7 @@ void testUnsubscribeNonExistentConfigurationChangeListener() { @Test void testUnsubscribeOneOfMultipleConfigurationChangeListeners() { - when(mockHttpClient.get(anyString())).thenReturn("{}".getBytes()); + stubConfigClientSuccess("{}".getBytes()); when(mockConfigStore.saveConfiguration(any())) .thenReturn(CompletableFuture.completedFuture(null)); @@ -355,7 +372,6 @@ void testUnsubscribeOneOfMultipleConfigurationChangeListeners() { @Nested class EppoConfigurationClientTests { private IConfigurationStore configStore; - private EppoHttpClient mockHttpClient; private EppoConfigurationClient mockConfigClient; private EppoConfigurationRequestFactory requestFactory; private byte[] flagConfigBytes; @@ -363,15 +379,13 @@ class EppoConfigurationClientTests { @BeforeEach void setUp() throws IOException { configStore = Mockito.spy(new ConfigurationStore()); - mockHttpClient = mock(EppoHttpClient.class); mockConfigClient = mock(EppoConfigurationClient.class); requestFactory = createTestRequestFactory(); flagConfigBytes = loadInitialFlagConfig(); } private ConfigurationRequestor createRequestor() { - return new ConfigurationRequestor( - configStore, mockHttpClient, false, false, null, mockConfigClient, requestFactory); + return new ConfigurationRequestor(configStore, false, null, mockConfigClient, requestFactory); } @Test @@ -394,7 +408,6 @@ void testFetchWithEppoConfigurationClient() { && request.getQueryParams().get("sdkVersion").equals("1.0.0") && request.getLastVersionId() == null)); - verify(mockHttpClient, never()).get(anyString()); assertFalse(configStore.getConfiguration().isEmpty()); assertNotNull(configStore.getConfiguration().getFlag("numeric_flag")); } @@ -446,7 +459,6 @@ void testFetchAsyncWithEppoConfigurationClient() { requestor.fetchAndSaveFromRemoteAsync().join(); verify(mockConfigClient).get(any(EppoConfigurationRequest.class)); - verify(mockHttpClient, never()).getAsync(anyString()); assertFalse(configStore.getConfiguration().isEmpty()); } @@ -467,7 +479,6 @@ void testFetchWithEppoConfigurationClientErrorResponse() { @Nested class ConfigurationParserTests { private IConfigurationStore configStore; - private EppoHttpClient mockHttpClient; private EppoConfigurationClient mockConfigClient; private ConfigurationParser mockParser; private EppoConfigurationRequestFactory requestFactory; @@ -476,7 +487,6 @@ class ConfigurationParserTests { @BeforeEach void setUp() throws IOException { configStore = Mockito.spy(new ConfigurationStore()); - mockHttpClient = mock(EppoHttpClient.class); mockConfigClient = mock(EppoConfigurationClient.class); mockParser = mock(ConfigurationParser.class); requestFactory = createTestRequestFactory(); @@ -502,13 +512,7 @@ void testFetchWithConfigurationParser() throws Exception { ConfigurationRequestor requestor = new ConfigurationRequestor( - configStore, - mockHttpClient, - false, - false, - mockParser, - mockConfigClient, - requestFactory); + configStore, false, mockParser, mockConfigClient, requestFactory); requestor.fetchAndSaveFromRemote(); @@ -523,13 +527,7 @@ void testFetchAsyncWithConfigurationParser() throws Exception { ConfigurationRequestor requestor = new ConfigurationRequestor( - configStore, - mockHttpClient, - false, - false, - mockParser, - mockConfigClient, - requestFactory); + configStore, false, mockParser, mockConfigClient, requestFactory); requestor.fetchAndSaveFromRemoteAsync().join(); @@ -537,29 +535,6 @@ void testFetchAsyncWithConfigurationParser() throws Exception { verify(configStore).saveConfiguration(any()); } - @Test - void testFetchWithParserOnly() throws Exception { - // Test using parser with the default EppoHttpClient (not EppoConfigurationClient) - when(mockHttpClient.get(Constants.FLAG_CONFIG_ENDPOINT)).thenReturn(flagConfigBytes); - stubParserSuccess(); - - ConfigurationRequestor requestor = - new ConfigurationRequestor( - configStore, - mockHttpClient, - false, - false, - mockParser, - null, // No EppoConfigurationClient - requestFactory); - - requestor.fetchAndSaveFromRemote(); - - verify(mockHttpClient).get(Constants.FLAG_CONFIG_ENDPOINT); - verify(mockParser).parseFlagConfig(flagConfigBytes); - verify(configStore).saveConfiguration(any()); - } - @Test void testFetchWithConfigurationParserParseError() throws Exception { byte[] invalidBytes = "invalid json".getBytes(StandardCharsets.UTF_8); @@ -575,13 +550,7 @@ void testFetchWithConfigurationParserParseError() throws Exception { ConfigurationRequestor requestor = new ConfigurationRequestor( - configStore, - mockHttpClient, - false, - false, - mockParser, - mockConfigClient, - requestFactory); + configStore, false, mockParser, mockConfigClient, requestFactory); assertThrows(RuntimeException.class, requestor::fetchAndSaveFromRemote); verify(configStore, never()).saveConfiguration(any()); @@ -614,13 +583,7 @@ private EppoConfigurationRequestFactory createMockServerRequestFactory() { private ConfigurationRequestor createRequestor(IConfigurationStore configStore) { return new ConfigurationRequestor( - configStore, - mock(EppoHttpClient.class), // Not used when EppoConfigurationClient is provided - false, - false, - jacksonParser, - okHttpClient, - createMockServerRequestFactory()); + configStore, false, jacksonParser, okHttpClient, createMockServerRequestFactory()); } private void enqueueSuccessResponse(String body, String etag) { diff --git a/src/test/java/cloud/eppo/ProfileBaseEppoClientTest.java b/src/test/java/cloud/eppo/ProfileBaseEppoClientTest.java index 45928d33..b9abba7f 100644 --- a/src/test/java/cloud/eppo/ProfileBaseEppoClientTest.java +++ b/src/test/java/cloud/eppo/ProfileBaseEppoClientTest.java @@ -50,7 +50,7 @@ public static void initClient() { null, null, null, - null); + new OkHttpEppoClient()); eppoClient.loadConfiguration(); diff --git a/src/test/java/cloud/eppo/helpers/TestUtils.java b/src/test/java/cloud/eppo/helpers/TestUtils.java index 1e80538d..6eda2a1d 100644 --- a/src/test/java/cloud/eppo/helpers/TestUtils.java +++ b/src/test/java/cloud/eppo/helpers/TestUtils.java @@ -1,15 +1,11 @@ package cloud.eppo.helpers; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; -import cloud.eppo.BaseEppoClient; -import cloud.eppo.EppoHttpClient; import cloud.eppo.http.EppoConfigurationClient; import cloud.eppo.http.EppoConfigurationRequest; import cloud.eppo.http.EppoConfigurationResponse; -import java.lang.reflect.Field; import java.util.concurrent.CompletableFuture; public class TestUtils { @@ -72,66 +68,4 @@ public static EppoConfigurationClient mockConfigurationClientErrorResponse() { return mockClient; } - - /** @deprecated Use mockConfigurationClient() and pass to BaseEppoClient constructor instead */ - @Deprecated - @SuppressWarnings("SameParameterValue") - public static EppoHttpClient mockHttpResponse(String responseBody) { - // Create a mock instance of EppoHttpClient - EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); - - // Mock sync get - when(mockHttpClient.get(anyString())).thenReturn(responseBody.getBytes()); - - // Mock async get - CompletableFuture mockAsyncResponse = new CompletableFuture<>(); - when(mockHttpClient.getAsync(anyString())).thenReturn(mockAsyncResponse); - mockAsyncResponse.complete(responseBody.getBytes()); - - setBaseClientHttpClientOverrideField(mockHttpClient); - return mockHttpClient; - } - - /** - * @deprecated Use mockConfigurationClientError() and pass to BaseEppoClient constructor instead - */ - @Deprecated - public static void mockHttpError() { - // Create a mock instance of EppoHttpClient - EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); - - // Mock sync get - when(mockHttpClient.get(anyString())).thenThrow(new RuntimeException("Intentional Error")); - - // Mock async get - CompletableFuture mockAsyncResponse = new CompletableFuture<>(); - when(mockHttpClient.getAsync(anyString())).thenReturn(mockAsyncResponse); - mockAsyncResponse.completeExceptionally(new RuntimeException("Intentional Error")); - - setBaseClientHttpClientOverrideField(mockHttpClient); - } - - /** @deprecated Will be removed when httpClientOverride is removed from BaseEppoClient */ - @Deprecated - public static void setBaseClientHttpClientOverrideField(EppoHttpClient httpClient) { - setBaseClientOverrideField("httpClientOverride", httpClient); - } - - /** - * Uses reflection to set a static override field used for tests (e.g., httpClientOverride) - * - * @deprecated Will be removed when override fields are removed from BaseEppoClient - */ - @Deprecated - @SuppressWarnings("SameParameterValue") - public static void setBaseClientOverrideField(String fieldName, T override) { - try { - Field httpClientOverrideField = BaseEppoClient.class.getDeclaredField(fieldName); - httpClientOverrideField.setAccessible(true); - httpClientOverrideField.set(null, override); - httpClientOverrideField.setAccessible(false); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new RuntimeException(e); - } - } } From 969ecaabbe0817efe8890cf6edca0eb773faa526 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 11 Feb 2026 08:17:18 -0700 Subject: [PATCH 25/44] remove deprecated http client --- src/main/java/cloud/eppo/EppoHttpClient.java | 119 ------------ .../eppo/EppoHttpClientRequestCallback.java | 7 - .../java/cloud/eppo/EppoHttpClientTest.java | 174 ------------------ 3 files changed, 300 deletions(-) delete mode 100644 src/main/java/cloud/eppo/EppoHttpClient.java delete mode 100644 src/main/java/cloud/eppo/EppoHttpClientRequestCallback.java delete mode 100644 src/test/java/cloud/eppo/EppoHttpClientTest.java diff --git a/src/main/java/cloud/eppo/EppoHttpClient.java b/src/main/java/cloud/eppo/EppoHttpClient.java deleted file mode 100644 index b8818488..00000000 --- a/src/main/java/cloud/eppo/EppoHttpClient.java +++ /dev/null @@ -1,119 +0,0 @@ -package cloud.eppo; - -import java.io.IOException; -import java.net.HttpURLConnection; -import java.util.Arrays; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import okhttp3.Call; -import okhttp3.Callback; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class EppoHttpClient { - private static final Logger log = LoggerFactory.getLogger(EppoHttpClient.class); - - private final OkHttpClient client; - - private final String baseUrl; - private final String apiKey; - private final String sdkName; - private final String sdkVersion; - - public EppoHttpClient(String baseUrl, String apiKey, String sdkName, String sdkVersion) { - this.baseUrl = baseUrl; - this.apiKey = apiKey; - this.sdkName = sdkName; - this.sdkVersion = sdkVersion; - this.client = buildOkHttpClient(); - } - - private static OkHttpClient buildOkHttpClient() { - OkHttpClient.Builder builder = - new OkHttpClient() - .newBuilder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(10, TimeUnit.SECONDS); - - return builder.build(); - } - - public byte[] get(String path) { - try { - // Wait and return the async get. - return getAsync(path).get(); - } catch (InterruptedException | ExecutionException e) { - log.error("Config fetch interrupted", e); - throw new RuntimeException(e); - } - } - - public CompletableFuture getAsync(String path) { - CompletableFuture future = new CompletableFuture<>(); - Request request = buildRequest(path); - client - .newCall(request) - .enqueue( - new Callback() { - @Override - public void onResponse(@NotNull Call call, @NotNull Response response) { - if (response.isSuccessful() && response.body() != null) { - log.debug("Fetch successful"); - try { - future.complete(response.body().bytes()); - } catch (IOException ex) { - future.completeExceptionally( - new RuntimeException( - "Failed to read response from URL {}" + redactApiKey(request.url()), - ex)); - } - } else { - if (response.code() == HttpURLConnection.HTTP_FORBIDDEN) { - future.completeExceptionally(new RuntimeException("Invalid API key")); - } else { - log.debug("Fetch failed with status code: {}", response.code()); - future.completeExceptionally( - new RuntimeException( - "Bad response from URL " + redactApiKey(request.url()))); - } - } - response.close(); - } - - @Override - public void onFailure(@NotNull Call call, @NotNull IOException e) { - log.error( - "Http request failure: {} {}", - e.getMessage(), - Arrays.toString(e.getStackTrace()), - e); - future.completeExceptionally( - new RuntimeException( - "Unable to fetch from URL " + redactApiKey(request.url()))); - } - }); - return future; - } - - private Request buildRequest(String path) { - HttpUrl httpUrl = - HttpUrl.parse(baseUrl + path) - .newBuilder() - .addQueryParameter("apiKey", apiKey) - .addQueryParameter("sdkName", sdkName) - .addQueryParameter("sdkVersion", sdkVersion) - .build(); - - return new Request.Builder().url(httpUrl).build(); - } - - private String redactApiKey(HttpUrl url) { - return url.toString().replaceAll("apiKey=[^&]*", "apiKey="); - } -} diff --git a/src/main/java/cloud/eppo/EppoHttpClientRequestCallback.java b/src/main/java/cloud/eppo/EppoHttpClientRequestCallback.java deleted file mode 100644 index 6d67a2c4..00000000 --- a/src/main/java/cloud/eppo/EppoHttpClientRequestCallback.java +++ /dev/null @@ -1,7 +0,0 @@ -package cloud.eppo; - -public interface EppoHttpClientRequestCallback { - void onSuccess(String responseBody); - - void onFailure(String errorMessage); -} diff --git a/src/test/java/cloud/eppo/EppoHttpClientTest.java b/src/test/java/cloud/eppo/EppoHttpClientTest.java deleted file mode 100644 index b14ab8dd..00000000 --- a/src/test/java/cloud/eppo/EppoHttpClientTest.java +++ /dev/null @@ -1,174 +0,0 @@ -package cloud.eppo; - -import static org.junit.jupiter.api.Assertions.*; - -import java.io.IOException; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.SocketPolicy; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -public class EppoHttpClientTest { - private MockWebServer mockWebServer; - private EppoHttpClient httpClient; - private static final String TEST_API_KEY = "test-secret-api-key-12345"; - private static final String SDK_NAME = "test-sdk"; - private static final String SDK_VERSION = "1.0.0"; - - @BeforeEach - public void setUp() throws IOException { - mockWebServer = new MockWebServer(); - mockWebServer.start(); - String baseUrl = mockWebServer.url("/").toString(); - httpClient = new EppoHttpClient(baseUrl, TEST_API_KEY, SDK_NAME, SDK_VERSION); - } - - @AfterEach - public void tearDown() throws IOException { - mockWebServer.shutdown(); - } - - @Test - public void testApiKeyRedactedInIOExceptionMessage() { - // Simulate a response that will cause an IOException when reading the body - mockWebServer.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody("test") - .setSocketPolicy(SocketPolicy.DISCONNECT_DURING_RESPONSE_BODY)); - - CompletableFuture future = httpClient.getAsync("/test-path"); - - ExecutionException exception = assertThrows(ExecutionException.class, future::get); - Throwable cause = exception.getCause(); - assertNotNull(cause); - String errorMessage = cause.getMessage(); - - // Verify the error message contains a URL - assertTrue(errorMessage.contains("URL"), "Error message should mention URL"); - - // Verify the actual API key is NOT present in the error message - assertFalse( - errorMessage.contains(TEST_API_KEY), "Error message should not contain the actual API key"); - - // Verify the redacted placeholder IS present - assertTrue( - errorMessage.contains("apiKey="), - "Error message should contain redacted API key placeholder"); - } - - @Test - public void testApiKeyRedactedInBadResponseMessage() { - // Return a 500 error to trigger the "Bad response" error path - mockWebServer.enqueue(new MockResponse().setResponseCode(500).setBody("Internal Server Error")); - - CompletableFuture future = httpClient.getAsync("/test-path"); - - ExecutionException exception = assertThrows(ExecutionException.class, future::get); - Throwable cause = exception.getCause(); - assertNotNull(cause); - String errorMessage = cause.getMessage(); - - // Verify the error message is about a bad response - assertTrue( - errorMessage.contains("Bad response from URL"), - "Error message should mention bad response"); - - // Verify the actual API key is NOT present in the error message - assertFalse( - errorMessage.contains(TEST_API_KEY), "Error message should not contain the actual API key"); - - // Verify the redacted placeholder IS present - assertTrue( - errorMessage.contains("apiKey="), - "Error message should contain redacted API key placeholder"); - } - - @Test - public void testApiKeyRedactedInConnectionFailureMessage() throws IOException { - // Shut down the server to simulate connection failure - mockWebServer.shutdown(); - - CompletableFuture future = httpClient.getAsync("/test-path"); - - ExecutionException exception = assertThrows(ExecutionException.class, future::get); - Throwable cause = exception.getCause(); - assertNotNull(cause); - String errorMessage = cause.getMessage(); - - // Verify the error message is about being unable to fetch - assertTrue( - errorMessage.contains("Unable to fetch from URL"), - "Error message should mention unable to fetch"); - - // Verify the actual API key is NOT present in the error message - assertFalse( - errorMessage.contains(TEST_API_KEY), "Error message should not contain the actual API key"); - - // Verify the redacted placeholder IS present - assertTrue( - errorMessage.contains("apiKey="), - "Error message should contain redacted API key placeholder"); - } - - @Test - public void testApiKeyNotRedactedInSuccessfulRequest() - throws ExecutionException, InterruptedException { - // Return a successful response - String responseBody = "success response"; - mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(responseBody)); - - CompletableFuture future = httpClient.getAsync("/test-path"); - byte[] result = future.get(); - - // Verify the request was successful - assertNotNull(result); - assertEquals(responseBody, new String(result)); - } - - @Test - public void testInvalidApiKeyError() { - // Return a 403 error to trigger the "Invalid API key" error path - mockWebServer.enqueue(new MockResponse().setResponseCode(403).setBody("Forbidden")); - - CompletableFuture future = httpClient.getAsync("/test-path"); - - ExecutionException exception = assertThrows(ExecutionException.class, future::get); - Throwable cause = exception.getCause(); - assertNotNull(cause); - String errorMessage = cause.getMessage(); - - // For 403 errors, the message should be "Invalid API key" without URL details - assertEquals( - "Invalid API key", - errorMessage, - "403 errors should show generic 'Invalid API key' message"); - } - - @Test - public void testApiKeyRedactionPreservesOtherQueryParameters() { - // Return a 500 error to trigger error path - mockWebServer.enqueue(new MockResponse().setResponseCode(500).setBody("Error")); - - CompletableFuture future = httpClient.getAsync("/test-path"); - - ExecutionException exception = assertThrows(ExecutionException.class, future::get); - Throwable cause = exception.getCause(); - assertNotNull(cause); - String errorMessage = cause.getMessage(); - - // Verify other query parameters are still present - assertTrue(errorMessage.contains("sdkName=" + SDK_NAME), "SDK name should be preserved"); - assertTrue( - errorMessage.contains("sdkVersion=" + SDK_VERSION), "SDK version should be preserved"); - - // Verify API key is redacted - assertFalse(errorMessage.contains(TEST_API_KEY), "API key should be redacted"); - assertTrue( - errorMessage.contains("apiKey="), "Redacted placeholder should be present"); - } -} From 1aff3478590be0b6bff3c8e015afb0c854921b8f Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 11 Feb 2026 09:27:34 -0700 Subject: [PATCH 26/44] hard cut to ConfigurationParser --- src/main/java/cloud/eppo/BaseEppoClient.java | 2 +- .../cloud/eppo/ConfigurationRequestor.java | 85 +++++++------------ .../cloud/eppo/BaseEppoClientBanditTest.java | 6 +- .../java/cloud/eppo/BaseEppoClientTest.java | 4 +- .../eppo/ConfigurationRequestorTest.java | 15 +++- .../cloud/eppo/ProfileBaseEppoClientTest.java | 4 +- 6 files changed, 53 insertions(+), 63 deletions(-) diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index efcbb8cf..1a30b740 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -64,7 +64,7 @@ protected BaseEppoClient( @Nullable CompletableFuture initialConfiguration, @Nullable IAssignmentCache assignmentCache, @Nullable IAssignmentCache banditAssignmentCache, - @Nullable ConfigurationParser configurationParser, + @NotNull ConfigurationParser configurationParser, @NotNull EppoConfigurationClient configurationClient) { if (apiBaseUrl == null) { diff --git a/src/main/java/cloud/eppo/ConfigurationRequestor.java b/src/main/java/cloud/eppo/ConfigurationRequestor.java index d4b8d455..0ef30601 100644 --- a/src/main/java/cloud/eppo/ConfigurationRequestor.java +++ b/src/main/java/cloud/eppo/ConfigurationRequestor.java @@ -14,7 +14,6 @@ import java.util.concurrent.ExecutionException; import java.util.function.Consumer; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,7 +23,7 @@ public class ConfigurationRequestor { private final IConfigurationStore configurationStore; private final boolean supportBandits; - @Nullable private final ConfigurationParser configurationParser; + @NotNull private final ConfigurationParser configurationParser; @NotNull private final EppoConfigurationClient configurationClient; @NotNull private final EppoConfigurationRequestFactory requestFactory; @@ -37,7 +36,7 @@ public class ConfigurationRequestor { public ConfigurationRequestor( @NotNull IConfigurationStore configurationStore, boolean supportBandits, - @Nullable ConfigurationParser configurationParser, + @NotNull ConfigurationParser configurationParser, @NotNull EppoConfigurationClient configurationClient, @NotNull EppoConfigurationRequestFactory requestFactory) { this.configurationStore = configurationStore; @@ -123,22 +122,16 @@ void fetchAndSaveFromRemote() { byte[] flagConfigurationJsonBytes = flagResponse.getBody(); - // Use ConfigurationParser if available, otherwise use Configuration.builder Configuration.Builder configBuilder; - if (configurationParser != null) { - try { - FlagConfigResponse flagConfigResponse = - configurationParser.parseFlagConfig(flagConfigurationJsonBytes); - configBuilder = - new Configuration.Builder(flagConfigurationJsonBytes, flagConfigResponse) - .banditParametersFromConfig(lastConfig); - } catch (ConfigurationParseException e) { - log.error("Failed to parse flag configuration", e); - throw new RuntimeException(e); - } - } else { + try { + FlagConfigResponse flagConfigResponse = + configurationParser.parseFlagConfig(flagConfigurationJsonBytes); configBuilder = - Configuration.builder(flagConfigurationJsonBytes).banditParametersFromConfig(lastConfig); + new Configuration.Builder(flagConfigurationJsonBytes, flagConfigResponse) + .banditParametersFromConfig(lastConfig); + } catch (ConfigurationParseException e) { + log.error("Failed to parse flag configuration", e); + throw new RuntimeException(e); } configBuilder.flagsSnapshotId(flagResponse.getVersionId()); @@ -146,17 +139,13 @@ void fetchAndSaveFromRemote() { if (supportBandits && configBuilder.requiresUpdatedBanditModels()) { byte[] banditParametersJsonBytes = fetchBanditParameters(); if (banditParametersJsonBytes != null) { - if (configurationParser != null) { - try { - BanditParametersResponse bandits = - configurationParser.parseBanditParams(banditParametersJsonBytes); - configBuilder.banditParameters(bandits); - } catch (ConfigurationParseException e) { - log.error("Failed to parse bandit parameters", e); - throw new RuntimeException(e); - } - } else { - configBuilder.banditParameters(banditParametersJsonBytes); + try { + BanditParametersResponse bandits = + configurationParser.parseBanditParams(banditParametersJsonBytes); + configBuilder.banditParameters(bandits); + } catch (ConfigurationParseException e) { + log.error("Failed to parse bandit parameters", e); + throw new RuntimeException(e); } } } @@ -224,21 +213,15 @@ private CompletableFuture buildAndSaveConfiguration( EppoConfigurationResponse flagResponse, Configuration lastConfig) { Configuration.Builder configBuilder; - // Use ConfigurationParser if available - if (configurationParser != null) { - try { - FlagConfigResponse flagConfigResponse = - configurationParser.parseFlagConfig(flagResponse.getBody()); - configBuilder = - new Configuration.Builder(flagResponse.getBody(), flagConfigResponse) - .banditParametersFromConfig(lastConfig); - } catch (ConfigurationParseException e) { - log.error("Failed to parse flag configuration", e); - throw new RuntimeException(e); - } - } else { + try { + FlagConfigResponse flagConfigResponse = + configurationParser.parseFlagConfig(flagResponse.getBody()); configBuilder = - Configuration.builder(flagResponse.getBody()).banditParametersFromConfig(lastConfig); + new Configuration.Builder(flagResponse.getBody(), flagConfigResponse) + .banditParametersFromConfig(lastConfig); + } catch (ConfigurationParseException e) { + log.error("Failed to parse flag configuration", e); + throw new RuntimeException(e); } configBuilder.flagsSnapshotId(flagResponse.getVersionId()); @@ -246,17 +229,13 @@ private CompletableFuture buildAndSaveConfiguration( if (supportBandits && configBuilder.requiresUpdatedBanditModels()) { byte[] banditParametersJsonBytes = fetchBanditParametersAsync(); if (banditParametersJsonBytes != null) { - if (configurationParser != null) { - try { - BanditParametersResponse bandits = - configurationParser.parseBanditParams(banditParametersJsonBytes); - configBuilder.banditParameters(bandits); - } catch (ConfigurationParseException e) { - log.error("Failed to parse bandit parameters", e); - throw new RuntimeException(e); - } - } else { - configBuilder.banditParameters(banditParametersJsonBytes); + try { + BanditParametersResponse bandits = + configurationParser.parseBanditParams(banditParametersJsonBytes); + configBuilder.banditParameters(bandits); + } catch (ConfigurationParseException e) { + log.error("Failed to parse bandit parameters", e); + throw new RuntimeException(e); } } } diff --git a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java index 97ae97bb..c77e59ee 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java @@ -12,6 +12,7 @@ import cloud.eppo.logging.AssignmentLogger; import cloud.eppo.logging.BanditAssignment; import cloud.eppo.logging.BanditLogger; +import cloud.eppo.parser.ConfigurationParser; import java.io.File; import java.io.IOException; import java.util.*; @@ -40,6 +41,7 @@ public class BaseEppoClientBanditTest { private static final AssignmentLogger mockAssignmentLogger = mock(AssignmentLogger.class); private static final BanditLogger mockBanditLogger = mock(BanditLogger.class); + private static final ConfigurationParser parser = new JacksonConfigurationParser(); private static final Date testStart = new Date(); private static BaseEppoClient eppoClient; @@ -79,7 +81,7 @@ public static void initClient() { new AbstractAssignmentCache(assignmentCache) {}, new ExpiringInMemoryAssignmentCache( banditAssignmentCache, 50, TimeUnit.MILLISECONDS) {}, - null, + parser, new OkHttpEppoClient()); eppoClient.loadConfiguration(); @@ -110,7 +112,7 @@ private BaseEppoClient initClientWithData( initialConfig, null, null, - null, + parser, new OkHttpEppoClient()); } diff --git a/src/test/java/cloud/eppo/BaseEppoClientTest.java b/src/test/java/cloud/eppo/BaseEppoClientTest.java index 0bc0ac18..dd4d2711 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientTest.java @@ -248,7 +248,7 @@ public void testCustomBaseUrl() throws IOException, InterruptedException { null, null, null, - null, + parser, new OkHttpEppoClient()); eppoClient.loadConfiguration(); @@ -868,7 +868,7 @@ public void testGetConfigurationBeforeInitialization() { null, null, null, - null, + parser, new OkHttpEppoClient()); // Get configuration before loading diff --git a/src/test/java/cloud/eppo/ConfigurationRequestorTest.java b/src/test/java/cloud/eppo/ConfigurationRequestorTest.java index e165b717..253f6dbc 100644 --- a/src/test/java/cloud/eppo/ConfigurationRequestorTest.java +++ b/src/test/java/cloud/eppo/ConfigurationRequestorTest.java @@ -51,15 +51,17 @@ private static String loadInitialFlagConfigString() throws IOException { class InitialConfigurationTests { private IConfigurationStore configStore; private EppoConfigurationClient mockConfigClient; + private ConfigurationParser parser; private ConfigurationRequestor requestor; @BeforeEach void setUp() { configStore = Mockito.spy(new ConfigurationStore()); mockConfigClient = mock(EppoConfigurationClient.class); + parser = new JacksonConfigurationParser(); requestor = new ConfigurationRequestor( - configStore, true, null, mockConfigClient, createTestRequestFactory()); + configStore, true, parser, mockConfigClient, createTestRequestFactory()); } @Test @@ -178,16 +180,18 @@ void testCacheWritesAfterBrokenFetch() throws IOException { class ConfigurationChangeListenerTests { private ConfigurationStore mockConfigStore; private EppoConfigurationClient mockConfigClient; + private ConfigurationParser parser; private ConfigurationRequestor requestor; @BeforeEach void setUp() { mockConfigStore = mock(ConfigurationStore.class); mockConfigClient = mock(EppoConfigurationClient.class); - when(mockConfigStore.getConfiguration()).thenReturn(Configuration.emptyConfig()); +when(mockConfigStore.getConfiguration()).thenReturn(Configuration.emptyConfig()); + parser = new JacksonConfigurationParser(); requestor = new ConfigurationRequestor( - mockConfigStore, true, null, mockConfigClient, createTestRequestFactory()); + mockConfigStore, true, parser, mockConfigClient, createTestRequestFactory()); } private void stubConfigClientSuccess(byte[] responseBody) { @@ -373,6 +377,7 @@ void testUnsubscribeOneOfMultipleConfigurationChangeListeners() { class EppoConfigurationClientTests { private IConfigurationStore configStore; private EppoConfigurationClient mockConfigClient; + private ConfigurationParser parser; private EppoConfigurationRequestFactory requestFactory; private byte[] flagConfigBytes; @@ -380,12 +385,14 @@ class EppoConfigurationClientTests { void setUp() throws IOException { configStore = Mockito.spy(new ConfigurationStore()); mockConfigClient = mock(EppoConfigurationClient.class); + parser = new JacksonConfigurationParser(); requestFactory = createTestRequestFactory(); flagConfigBytes = loadInitialFlagConfig(); } private ConfigurationRequestor createRequestor() { - return new ConfigurationRequestor(configStore, false, null, mockConfigClient, requestFactory); + return new ConfigurationRequestor( + configStore, false, parser, mockConfigClient, requestFactory); } @Test diff --git a/src/test/java/cloud/eppo/ProfileBaseEppoClientTest.java b/src/test/java/cloud/eppo/ProfileBaseEppoClientTest.java index b9abba7f..e50b5098 100644 --- a/src/test/java/cloud/eppo/ProfileBaseEppoClientTest.java +++ b/src/test/java/cloud/eppo/ProfileBaseEppoClientTest.java @@ -6,6 +6,7 @@ import cloud.eppo.api.Attributes; import cloud.eppo.logging.Assignment; import cloud.eppo.logging.AssignmentLogger; +import cloud.eppo.parser.ConfigurationParser; import java.lang.management.ManagementFactory; import java.lang.management.ThreadMXBean; import java.util.HashMap; @@ -24,6 +25,7 @@ public class ProfileBaseEppoClientTest { "https://us-central1-eppo-qa.cloudfunctions.net/serveGitHubRacTestFile"; private static BaseEppoClient eppoClient; + private static final ConfigurationParser parser = new JacksonConfigurationParser(); private static final AssignmentLogger noOpAssignmentLogger = new AssignmentLogger() { @Override @@ -49,7 +51,7 @@ public static void initClient() { null, null, null, - null, + parser, new OkHttpEppoClient()); eppoClient.loadConfiguration(); From ef91b6a26427d48f5e487be28d2eb4dd282cc2c8 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 11 Feb 2026 09:57:51 -0700 Subject: [PATCH 27/44] remove grafting format field --- src/main/java/cloud/eppo/api/Configuration.java | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/main/java/cloud/eppo/api/Configuration.java b/src/main/java/cloud/eppo/api/Configuration.java index 2d7db847..3aae6127 100644 --- a/src/main/java/cloud/eppo/api/Configuration.java +++ b/src/main/java/cloud/eppo/api/Configuration.java @@ -10,9 +10,7 @@ import cloud.eppo.api.dto.FlagConfigResponse; import cloud.eppo.api.dto.VariationType; import cloud.eppo.ufc.dto.adapters.EppoModule; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.*; import java.util.Arrays; import java.util.Collections; @@ -98,21 +96,6 @@ public class Configuration { this.configFetchedAt = configFetchedAt; this.configPublishedAt = configPublishedAt; this.flagsSnapshotId = flagsSnapshotId; - - // Graft the `format` field into the flagConfigJson' - if (flagConfigJson != null && flagConfigJson.length != 0) { - try { - JsonNode jNode = mapper.readTree(flagConfigJson); - FlagConfigResponse.Format format = - isConfigObfuscated - ? FlagConfigResponse.Format.CLIENT - : FlagConfigResponse.Format.SERVER; - ((ObjectNode) jNode).put("format", format.toString()); - flagConfigJson = mapper.writeValueAsBytes(jNode); - } catch (IOException e) { - log.error("Error adding `format` field to FlagConfigResponse JSON"); - } - } this.flagConfigJson = flagConfigJson; this.banditParamsJson = banditParamsJson; } From e8c5521a111ade16404d2ee84b47425aa0d1aba7 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 11 Feb 2026 11:17:31 -0700 Subject: [PATCH 28/44] drop serialize methods --- src/test/java/cloud/eppo/ConfigurationRequestorTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/cloud/eppo/ConfigurationRequestorTest.java b/src/test/java/cloud/eppo/ConfigurationRequestorTest.java index 253f6dbc..54085183 100644 --- a/src/test/java/cloud/eppo/ConfigurationRequestorTest.java +++ b/src/test/java/cloud/eppo/ConfigurationRequestorTest.java @@ -187,7 +187,7 @@ class ConfigurationChangeListenerTests { void setUp() { mockConfigStore = mock(ConfigurationStore.class); mockConfigClient = mock(EppoConfigurationClient.class); -when(mockConfigStore.getConfiguration()).thenReturn(Configuration.emptyConfig()); + when(mockConfigStore.getConfiguration()).thenReturn(Configuration.emptyConfig()); parser = new JacksonConfigurationParser(); requestor = new ConfigurationRequestor( From 779ce0a4337267e8e68b85f851de5aca7ccf72cd Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 12 Feb 2026 14:16:06 -0700 Subject: [PATCH 29/44] rip mapper from configuration --- .../java/cloud/eppo/api/Configuration.java | 70 +++---------------- .../cloud/eppo/BaseEppoClientBanditTest.java | 6 +- .../java/cloud/eppo/BaseEppoClientTest.java | 7 +- .../eppo/ConfigurationRequestorTest.java | 22 ++++-- .../eppo/api/ConfigurationBuilderTest.java | 63 +++++++++-------- 5 files changed, 70 insertions(+), 98 deletions(-) diff --git a/src/main/java/cloud/eppo/api/Configuration.java b/src/main/java/cloud/eppo/api/Configuration.java index 3aae6127..e3362187 100644 --- a/src/main/java/cloud/eppo/api/Configuration.java +++ b/src/main/java/cloud/eppo/api/Configuration.java @@ -9,8 +9,6 @@ import cloud.eppo.api.dto.FlagConfig; import cloud.eppo.api.dto.FlagConfigResponse; import cloud.eppo.api.dto.VariationType; -import cloud.eppo.ufc.dto.adapters.EppoModule; -import com.fasterxml.jackson.databind.ObjectMapper; import java.io.*; import java.util.Arrays; import java.util.Collections; @@ -33,16 +31,19 @@ * there are no bandits referenced by the flag configuration. * *

Usage: Building with just flag configuration (unobfuscated is default) - * Configuration config = new Configuration.Builder(flagConfigJsonString).build(); + * FlagConfigResponse flagConfig = parser.parseFlagConfig(flagConfigJsonBytes); + * Configuration config = new Configuration.Builder(flagConfigJsonBytes, flagConfig).build(); * * *

Building with bandits (known configuration) - * Configuration config = new Configuration.Builder(flagConfigJsonString).banditParameters(banditConfigJson).build(); + * FlagConfigResponse flagConfig = parser.parseFlagConfig(flagConfigJsonBytes); + * Configuration config = new Configuration.Builder(flagConfigJsonBytes, flagConfig).banditParameters(banditConfigJson).build(); * * *

Conditionally loading bandit models (with or without an existing bandit config JSON string). * - * Configuration.Builder configBuilder = new Configuration.Builder(flagConfigJsonString).banditParameters(banditConfigJson); + * FlagConfigResponse flagConfig = parser.parseFlagConfig(flagConfigJsonBytes); + * Configuration.Builder configBuilder = new Configuration.Builder(flagConfigJsonBytes, flagConfig).banditParameters(banditConfigJson); * if (configBuilder.requiresBanditModels()) { * // Load the bandit parameters encoded in a JSON string * configBuilder.banditParameters(banditParameterJsonString); @@ -56,9 +57,6 @@ * then check `requiresBanditModels()`. */ public class Configuration { - private static final ObjectMapper mapper = - new ObjectMapper().registerModule(EppoModule.eppoModule()); - private static final byte[] emptyFlagsBytes = "{ \"flags\": {}, \"format\": \"SERVER\" }".getBytes(); @@ -223,14 +221,6 @@ public boolean isConfigObfuscated() { return isConfigObfuscated; } - public byte[] serializeFlagConfigToBytes() { - return flagConfigJson; - } - - public byte[] serializeBanditParamsToBytes() { - return banditParamsJson; - } - public boolean isEmpty() { return flags == null || flags.isEmpty(); } @@ -264,8 +254,8 @@ public Date getConfigPublishedAt() { return flagsSnapshotId; } - public static Builder builder(byte[] flagJson) { - return new Builder(flagJson); + public static Builder builder(byte[] flagJson, FlagConfigResponse flagConfigResponse) { + return new Builder(flagJson, flagConfigResponse); } /** @@ -285,22 +275,6 @@ public static class Builder { private final Date configPublishedAt; @Nullable private String flagsSnapshotId; - private static FlagConfigResponse parseFlagResponse(byte[] flagJson) { - if (flagJson == null || flagJson.length == 0) { - log.warn("Null or empty configuration string. Call `Configuration.Empty()` instead"); - return null; - } - try { - return mapper.readValue(flagJson, FlagConfigResponse.class); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - public Builder(byte[] flagJson) { - this(flagJson, parseFlagResponse(flagJson)); - } - public Builder(byte[] flagJson, FlagConfigResponse flagConfigResponse) { this( flagJson, @@ -368,34 +342,6 @@ public Builder banditParameters(BanditParametersResponse banditParametersRespons return this; } - public Builder banditParameters(String banditParameterJson) { - return banditParameters(banditParameterJson.getBytes()); - } - - public Builder banditParameters(byte[] banditParameterJson) { - if (banditParameterJson == null || banditParameterJson.length == 0) { - log.debug("Bandit parameters are null or empty"); - return this; - } - BanditParametersResponse config; - try { - config = mapper.readValue(banditParameterJson, BanditParametersResponse.class); - } catch (IOException e) { - log.error("Unable to parse bandit parameters JSON"); - throw new RuntimeException(e); - } - - if (config == null || config.getBandits() == null) { - log.warn("`bandits` map missing in bandit parameters JSON"); - bandits = Collections.emptyMap(); - } else { - bandits = Collections.unmodifiableMap(config.getBandits()); - log.debug("Loaded {} bandit models from bandit parameters JSON", bandits.size()); - } - - return this; - } - public Builder flagsSnapshotId(@Nullable String flagsSnapshotId) { this.flagsSnapshotId = flagsSnapshotId; return this; diff --git a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java index c77e59ee..decf0a00 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java @@ -94,8 +94,10 @@ private BaseEppoClient initClientWithData( CompletableFuture initialConfig = CompletableFuture.completedFuture( - Configuration.builder(initialFlagConfiguration.getBytes()) - .banditParameters(initialBanditParameters) + Configuration.builder( + initialFlagConfiguration.getBytes(), + parser.parseFlagConfig(initialFlagConfiguration.getBytes())) + .banditParameters(parser.parseBanditParams(initialBanditParameters.getBytes())) .build()); return new BaseEppoClient( diff --git a/src/test/java/cloud/eppo/BaseEppoClientTest.java b/src/test/java/cloud/eppo/BaseEppoClientTest.java index dd4d2711..99bae68e 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientTest.java @@ -372,7 +372,9 @@ public void testInvalidConfigJSON() { private CompletableFuture immediateConfigFuture( String config, boolean isObfuscated) { - return CompletableFuture.completedFuture(Configuration.builder(config.getBytes()).build()); + return CompletableFuture.completedFuture( + Configuration.builder(config.getBytes(), parser.parseFlagConfig(config.getBytes())) + .build()); } @Test @@ -477,7 +479,8 @@ public void testWithInitialConfigurationFuture() throws IOException { assertEquals(0, result); // Now, complete the initial config future and check the value. - futureConfig.complete(Configuration.builder(flagConfig).build()); + futureConfig.complete( + Configuration.builder(flagConfig, parser.parseFlagConfig(flagConfig)).build()); result = eppoClient.getDoubleAssignment("numeric_flag", "dummy subject", 0); assertEquals(5, result); diff --git a/src/test/java/cloud/eppo/ConfigurationRequestorTest.java b/src/test/java/cloud/eppo/ConfigurationRequestorTest.java index 54085183..d32171f9 100644 --- a/src/test/java/cloud/eppo/ConfigurationRequestorTest.java +++ b/src/test/java/cloud/eppo/ConfigurationRequestorTest.java @@ -12,6 +12,7 @@ import cloud.eppo.http.EppoConfigurationRequestFactory; import cloud.eppo.http.EppoConfigurationResponse; import cloud.eppo.parser.ConfigurationParser; +import com.fasterxml.jackson.databind.JsonNode; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -47,6 +48,19 @@ private static String loadInitialFlagConfigString() throws IOException { return FileUtils.readFileToString(INITIAL_FLAG_CONFIG_FILE, StandardCharsets.UTF_8); } + private static ConfigurationParser configurationParser = + new JacksonConfigurationParser(); + + private Configuration buildConfig(String json) { + FlagConfigResponse flagConfigResponse = configurationParser.parseFlagConfig(json.getBytes()); + return new Configuration.Builder(json.getBytes(), flagConfigResponse).build(); + } + + private Configuration buildConfig(byte[] json) { + FlagConfigResponse flagConfigResponse = configurationParser.parseFlagConfig(json); + return new Configuration.Builder(json, flagConfigResponse).build(); + } + @Nested class InitialConfigurationTests { private IConfigurationStore configStore; @@ -76,7 +90,7 @@ void testInitialConfigurationFuture() throws IOException { assertEquals(Collections.emptySet(), configStore.getConfiguration().getFlagKeys()); verify(configStore, times(0)).saveConfiguration(any()); - futureConfig.complete(Configuration.builder(flagConfig).build()); + futureConfig.complete(buildConfig(flagConfig)); assertFalse(configStore.getConfiguration().isEmpty()); assertFalse(configStore.getConfiguration().getFlagKeys().isEmpty()); @@ -109,7 +123,7 @@ void testInitialConfigurationDoesntClobberFetch() throws IOException { configFetchFuture.complete( EppoConfigurationResponse.success( 200, "version-1", fetchedFlagConfig.getBytes(StandardCharsets.UTF_8))); - initialConfigFuture.complete(new Configuration.Builder(flagConfig.getBytes()).build()); + initialConfigFuture.complete(buildConfig(flagConfig)); assertFalse(configStore.getConfiguration().isEmpty()); assertFalse(configStore.getConfiguration().getFlagKeys().isEmpty()); @@ -137,7 +151,7 @@ void testBrokenFetchDoesntClobberCache() throws IOException { requestor.fetchAndSaveFromRemoteAsync(); - initialConfigFuture.complete(new Configuration.Builder(flagConfig.getBytes()).build()); + initialConfigFuture.complete(buildConfig(flagConfig)); configFetchFuture.completeExceptionally(new Exception("Intentional exception")); assertFalse(configStore.getConfiguration().isEmpty()); @@ -165,7 +179,7 @@ void testCacheWritesAfterBrokenFetch() throws IOException { requestor.fetchAndSaveFromRemoteAsync(); configFetchFuture.completeExceptionally(new Exception("Intentional exception")); - initialConfigFuture.complete(new Configuration.Builder(flagConfig.getBytes()).build()); + initialConfigFuture.complete(buildConfig(flagConfig)); verify(configStore, times(1)).saveConfiguration(any()); assertFalse(configStore.getConfiguration().isEmpty()); diff --git a/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java b/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java index 432f6cee..ff3e92a7 100644 --- a/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java +++ b/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java @@ -3,15 +3,17 @@ import static cloud.eppo.Utils.getMD5Hex; import static org.junit.jupiter.api.Assertions.*; +import cloud.eppo.JacksonConfigurationParser; import cloud.eppo.api.dto.BanditModelData; import cloud.eppo.api.dto.BanditParameters; import cloud.eppo.api.dto.BanditParametersResponse; import cloud.eppo.api.dto.FlagConfig; import cloud.eppo.api.dto.FlagConfigResponse; import cloud.eppo.api.dto.VariationType; +import cloud.eppo.parser.ConfigurationParser; import cloud.eppo.ufc.dto.adapters.EppoModule; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; import java.text.SimpleDateFormat; import java.util.Collections; import java.util.Date; @@ -25,44 +27,43 @@ public class ConfigurationBuilderTest { private static final ObjectMapper mapper = new ObjectMapper().registerModule(EppoModule.eppoModule()); + private static final ConfigurationParser parser = new JacksonConfigurationParser(); + + private Configuration buildConfig(byte[] jsonBytes) { + FlagConfigResponse flagConfigResponse = parser.parseFlagConfig(jsonBytes); + return new Configuration.Builder(jsonBytes, flagConfigResponse).build(); + } + + private Configuration buildConfig(String json) { + return buildConfig(json.getBytes()); + } + @Test public void testHydrateConfigFromBytesForServer_true() { byte[] jsonBytes = "{ \"format\": \"SERVER\", \"flags\":{} }".getBytes(); - Configuration config = new Configuration.Builder(jsonBytes).build(); + Configuration config = buildConfig(jsonBytes); assertFalse(config.isConfigObfuscated()); } @Test public void testHydrateConfigFromBytesForServer_false() { byte[] jsonBytes = "{ \"format\": \"CLIENT\", \"flags\":{} }".getBytes(); - Configuration config = new Configuration.Builder(jsonBytes).build(); + Configuration config = buildConfig(jsonBytes); assertTrue(config.isConfigObfuscated()); } @Test - public void testBuildConfigAutoDetectsServerFormat() throws IOException { + public void testBuildConfigAutoDetectsServerFormat() { byte[] jsonBytes = "{ \"flags\":{}, \"format\": \"SERVER\" }".getBytes(); - Configuration config = Configuration.builder(jsonBytes).build(); + Configuration config = buildConfig(jsonBytes); assertFalse(config.isConfigObfuscated()); - - byte[] serializedFlags = config.serializeFlagConfigToBytes(); - FlagConfigResponse rehydratedConfig = - mapper.readValue(serializedFlags, FlagConfigResponse.class); - - assertEquals(rehydratedConfig.getFormat(), FlagConfigResponse.Format.SERVER); } @Test - public void testBuildConfigAutoDetectsClientFormat() throws IOException { + public void testBuildConfigAutoDetectsClientFormat() { byte[] jsonBytes = "{ \"flags\":{}, \"format\": \"CLIENT\" }".getBytes(); - Configuration config = Configuration.builder(jsonBytes).build(); + Configuration config = buildConfig(jsonBytes); assertTrue(config.isConfigObfuscated()); - - byte[] serializedFlags = config.serializeFlagConfigToBytes(); - FlagConfigResponse rehydratedConfig = - mapper.readValue(serializedFlags, FlagConfigResponse.class); - - assertEquals(rehydratedConfig.getFormat(), FlagConfigResponse.Format.CLIENT); } @Test @@ -146,7 +147,7 @@ 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(); + Configuration config = buildConfig(json); assertEquals("Production", config.getEnvironmentName()); } @@ -155,7 +156,7 @@ public void testEnvironmentNameParsedFromJson() { public void testEnvironmentNameNullWhenNotInJson() { // When flags are present but no environment object String json = "{ \"flags\": {} }"; - Configuration config = new Configuration.Builder(json.getBytes()).build(); + Configuration config = buildConfig(json); assertNull(config.getEnvironmentName()); } @@ -163,7 +164,7 @@ public void testEnvironmentNameNullWhenNotInJson() { @Test public void testConfigPublishedAtParsedFromCreatedAt() throws Exception { String json = "{ \"flags\": {}, \"createdAt\": \"2024-04-17T19:40:53.716Z\" }"; - Configuration config = new Configuration.Builder(json.getBytes()).build(); + Configuration config = buildConfig(json); // configPublishedAt should be set from the createdAt field in the JSON Date publishedAt = config.getConfigPublishedAt(); @@ -178,7 +179,7 @@ public void testConfigPublishedAtParsedFromCreatedAt() throws Exception { @Test public void testConfigPublishedAtNullWhenCreatedAtNotInJson() { String json = "{ \"flags\": {} }"; - Configuration config = new Configuration.Builder(json.getBytes()).build(); + Configuration config = buildConfig(json); assertNull(config.getConfigPublishedAt()); } @@ -191,7 +192,7 @@ public void testConfigFetchedAtSetOnBuild() throws InterruptedException { // Small sleep to ensure time difference is measurable Thread.sleep(10); - Configuration config = new Configuration.Builder(json.getBytes()).build(); + Configuration config = buildConfig(json); Thread.sleep(10); Date afterBuild = new Date(); @@ -213,7 +214,7 @@ public void testAllMetadataFieldsTogether() throws Exception { "{ \"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(); + Configuration config = buildConfig(json); Date afterBuild = new Date(); // Verify environmentName @@ -246,8 +247,10 @@ public void testEmptyConfigHasNullMetadata() { @Test public void testBanditParametersFromNullResponse() { String json = "{ \"flags\": {} }"; + byte[] jsonBytes = json.getBytes(); + FlagConfigResponse flagConfigResponse = parser.parseFlagConfig(jsonBytes); Configuration config = - new Configuration.Builder(json.getBytes()) + new Configuration.Builder(jsonBytes, flagConfigResponse) .banditParameters((BanditParametersResponse) null) .build(); @@ -261,8 +264,10 @@ public void testBanditParametersFromResponseWithNullBandits() { BanditParametersResponse response = new BanditParametersResponse.Default(null); String json = "{ \"flags\": {} }"; + byte[] jsonBytes = json.getBytes(); + FlagConfigResponse flagConfigResponse = parser.parseFlagConfig(jsonBytes); Configuration config = - new Configuration.Builder(json.getBytes()).banditParameters(response).build(); + new Configuration.Builder(jsonBytes, flagConfigResponse).banditParameters(response).build(); // Should not throw and bandit should not be found assertNull(config.getBanditParameters("any-bandit")); @@ -285,8 +290,10 @@ public void testBanditParametersFromResponseWithMultipleBandits() { BanditParametersResponse response = new BanditParametersResponse.Default(banditsMap); String json = "{ \"flags\": {} }"; + byte[] jsonBytes = json.getBytes(); + FlagConfigResponse flagConfigResponse = parser.parseFlagConfig(jsonBytes); Configuration config = - new Configuration.Builder(json.getBytes()).banditParameters(response).build(); + new Configuration.Builder(jsonBytes, flagConfigResponse).banditParameters(response).build(); // Verify both bandits are accessible assertNotNull(config.getBanditParameters("bandit-1")); From 434ad38724c9bc6e7eefa672d253924eb5e94979 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 13 Feb 2026 13:22:53 -0700 Subject: [PATCH 30/44] fix: j8 compatible failed future --- src/test/java/cloud/eppo/ConfigurationRequestorTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/test/java/cloud/eppo/ConfigurationRequestorTest.java b/src/test/java/cloud/eppo/ConfigurationRequestorTest.java index d32171f9..8e0fd871 100644 --- a/src/test/java/cloud/eppo/ConfigurationRequestorTest.java +++ b/src/test/java/cloud/eppo/ConfigurationRequestorTest.java @@ -216,8 +216,9 @@ private void stubConfigClientSuccess(byte[] responseBody) { } private void stubConfigClientFailure() { - when(mockConfigClient.get(any(EppoConfigurationRequest.class))) - .thenReturn(CompletableFuture.failedFuture(new RuntimeException("Fetch failed"))); + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(new RuntimeException("Fetch failed")); + when(mockConfigClient.get(any(EppoConfigurationRequest.class))).thenReturn(failedFuture); } @Test From a0cf9758dc28e78ab612389261e159ad48d091bd Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 13 Feb 2026 13:29:09 -0700 Subject: [PATCH 31/44] update jdocs --- .../java/cloud/eppo/api/Configuration.java | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/main/java/cloud/eppo/api/Configuration.java b/src/main/java/cloud/eppo/api/Configuration.java index e3362187..3f591c98 100644 --- a/src/main/java/cloud/eppo/api/Configuration.java +++ b/src/main/java/cloud/eppo/api/Configuration.java @@ -25,7 +25,7 @@ * Encapsulates the Flag Configuration and Bandit parameters in an immutable object with a complete * and coherent state. * - *

A Builder is used to prepare and then create am immutable data structure containing both flag + *

A Builder is used to prepare and then create an immutable data structure containing both flag * and bandit configurations. An intermediate step is required in building the configuration to * accommodate the as-needed loading of bandit parameters as a network call may not be needed if * there are no bandits referenced by the flag configuration. @@ -37,24 +37,26 @@ * *

Building with bandits (known configuration) * FlagConfigResponse flagConfig = parser.parseFlagConfig(flagConfigJsonBytes); - * Configuration config = new Configuration.Builder(flagConfigJsonBytes, flagConfig).banditParameters(banditConfigJson).build(); - * + * BanditParametersResponse banditParams = parser.parseBanditParams(banditParamsJsonBytes); + * Configuration config = new Configuration.Builder(flagConfigJsonBytes, flagConfig) + * .banditParameters(banditParams) + * .build(); + * * - *

Conditionally loading bandit models (with or without an existing bandit config JSON string). - * + *

Conditionally loading bandit models (with or without an existing bandit configuration). * FlagConfigResponse flagConfig = parser.parseFlagConfig(flagConfigJsonBytes); - * Configuration.Builder configBuilder = new Configuration.Builder(flagConfigJsonBytes, flagConfig).banditParameters(banditConfigJson); - * if (configBuilder.requiresBanditModels()) { - * // Load the bandit parameters encoded in a JSON string - * configBuilder.banditParameters(banditParameterJsonString); + * Configuration.Builder configBuilder = new Configuration.Builder(flagConfigJsonBytes, flagConfig) + * .banditParametersFromConfig(existingConfig); + * if (configBuilder.requiresUpdatedBanditModels()) { + * BanditParametersResponse banditParams = parser.parseBanditParams(banditParamsJsonBytes); + * configBuilder.banditParameters(banditParams); * } * Configuration config = configBuilder.build(); * * - *

- * *

Hint: when loading new Flag configuration values, set the current bandit models in the builder - * then check `requiresBanditModels()`. + * using {@link Builder#banditParametersFromConfig(Configuration)}, then check {@link + * Builder#requiresUpdatedBanditModels()}. */ public class Configuration { private static final byte[] emptyFlagsBytes = From ab43c3b5471418b882ed541fa6673d43d43e5080 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 13 Feb 2026 15:13:23 -0700 Subject: [PATCH 32/44] parameterize the Json Flag type on the client --- src/main/java/cloud/eppo/BaseEppoClient.java | 20 ++++++------- .../java/cloud/eppo/BaseEppoClientTest.java | 29 ++++++++++--------- .../eppo/helpers/AssignmentTestCase.java | 6 ++-- 3 files changed, 28 insertions(+), 27 deletions(-) diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index 1a30b740..41f96a52 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -16,7 +16,6 @@ import cloud.eppo.logging.BanditAssignment; import cloud.eppo.logging.BanditLogger; import cloud.eppo.parser.ConfigurationParser; -import com.fasterxml.jackson.databind.JsonNode; import java.util.HashMap; import java.util.Map; import java.util.Timer; @@ -27,7 +26,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class BaseEppoClient { +public class BaseEppoClient { private static final Logger log = LoggerFactory.getLogger(BaseEppoClient.class); protected final ConfigurationRequestor requestor; @@ -64,7 +63,7 @@ protected BaseEppoClient( @Nullable CompletableFuture initialConfiguration, @Nullable IAssignmentCache assignmentCache, @Nullable IAssignmentCache banditAssignmentCache, - @NotNull ConfigurationParser configurationParser, + @NotNull ConfigurationParser configurationParser, @NotNull EppoConfigurationClient configurationClient) { if (apiBaseUrl == null) { @@ -518,23 +517,24 @@ public AssignmentDetails getStringAssignmentDetails( } } - public JsonNode getJSONAssignment(String flagKey, String subjectKey, JsonNode defaultValue) { + public JsonFlagType getJSONAssignment( + String flagKey, String subjectKey, JsonFlagType defaultValue) { return getJSONAssignment(flagKey, subjectKey, new Attributes(), defaultValue); } - public JsonNode getJSONAssignment( - String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { + public JsonFlagType getJSONAssignment( + String flagKey, String subjectKey, Attributes subjectAttributes, JsonFlagType defaultValue) { return this.getJSONAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue) .getVariation(); } - public AssignmentDetails getJSONAssignmentDetails( - String flagKey, String subjectKey, JsonNode defaultValue) { + public AssignmentDetails getJSONAssignmentDetails( + String flagKey, String subjectKey, JsonFlagType defaultValue) { return this.getJSONAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); } - public AssignmentDetails getJSONAssignmentDetails( - String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { + public AssignmentDetails getJSONAssignmentDetails( + String flagKey, String subjectKey, Attributes subjectAttributes, JsonFlagType defaultValue) { try { return this.getTypedAssignmentWithDetails( flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.JSON); diff --git a/src/test/java/cloud/eppo/BaseEppoClientTest.java b/src/test/java/cloud/eppo/BaseEppoClientTest.java index 99bae68e..4ca33a09 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientTest.java @@ -19,6 +19,7 @@ import cloud.eppo.logging.AssignmentLogger; import cloud.eppo.parser.ConfigurationParser; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.File; import java.io.IOException; @@ -62,9 +63,9 @@ public class BaseEppoClientTest { new ObjectMapper().registerModule(AssignmentTestCase.assignmentTestCaseModule()); // Use JacksonConfigurationParser for all tests - private final ConfigurationParser parser = new JacksonConfigurationParser(); + private final ConfigurationParser parser = new JacksonConfigurationParser(); - private BaseEppoClient eppoClient; + private BaseEppoClient eppoClient; private AssignmentLogger mockAssignmentLogger; private EppoConfigurationClient mockConfigClient; @@ -84,7 +85,7 @@ private void initClientWithData( mockAssignmentLogger = mock(AssignmentLogger.class); eppoClient = - new BaseEppoClient( + new BaseEppoClient<>( DUMMY_FLAG_API_KEY, isConfigObfuscated ? "android" : "java", "100.1.0", @@ -106,7 +107,7 @@ private void initClient(boolean isGracefulMode, boolean isConfigObfuscated) { mockAssignmentLogger = mock(AssignmentLogger.class); eppoClient = - new BaseEppoClient( + new BaseEppoClient<>( DUMMY_FLAG_API_KEY, isConfigObfuscated ? "android" : "java", "100.1.0", @@ -132,7 +133,7 @@ private CompletableFuture initClientAsync( mockAssignmentLogger = mock(AssignmentLogger.class); eppoClient = - new BaseEppoClient( + new BaseEppoClient<>( DUMMY_FLAG_API_KEY, isConfigObfuscated ? "android" : "java", "100.1.0", @@ -156,7 +157,7 @@ private void initClientWithAssignmentCache(IAssignmentCache cache) { mockAssignmentLogger = mock(AssignmentLogger.class); eppoClient = - new BaseEppoClient( + new BaseEppoClient<>( DUMMY_FLAG_API_KEY, "java", "100.1.0", @@ -234,7 +235,7 @@ public void testCustomBaseUrl() throws IOException, InterruptedException { mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); eppoClient = - new BaseEppoClient( + new BaseEppoClient<>( DUMMY_FLAG_API_KEY, "java", "100.1.0", @@ -268,8 +269,8 @@ public void testCustomBaseUrl() throws IOException, InterruptedException { public void testErrorGracefulModeOn() throws JsonProcessingException { initClient(true, false); - BaseEppoClient realClient = eppoClient; - BaseEppoClient spyClient = spy(realClient); + BaseEppoClient realClient = eppoClient; + BaseEppoClient spyClient = spy(realClient); doThrow(new RuntimeException("Exception thrown by mock")) .when(spyClient) .evaluateAndMaybeLog( @@ -313,8 +314,8 @@ public void testErrorGracefulModeOn() throws JsonProcessingException { public void testErrorGracefulModeOff() { initClient(false, false); - BaseEppoClient realClient = eppoClient; - BaseEppoClient spyClient = spy(realClient); + BaseEppoClient realClient = eppoClient; + BaseEppoClient spyClient = spy(realClient); doThrow(new RuntimeException("Exception thrown by mock")) .when(spyClient) .evaluateAndMaybeLog( @@ -736,9 +737,9 @@ public void testPolling() { mockConfigClient = mockConfigurationClient(BOOL_FLAG_CONFIG); mockAssignmentLogger = mock(AssignmentLogger.class); - BaseEppoClient client = + BaseEppoClient client = eppoClient = - new BaseEppoClient( + new BaseEppoClient<>( DUMMY_FLAG_API_KEY, "java", "100.1.0", @@ -857,7 +858,7 @@ public void testGetConfigurationBeforeInitialization() { mockAssignmentLogger = mock(AssignmentLogger.class); eppoClient = - new BaseEppoClient( + new BaseEppoClient<>( DUMMY_FLAG_API_KEY, "java", "100.1.0", diff --git a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java index 776aa5d1..fbd5f79c 100644 --- a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java +++ b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java @@ -89,17 +89,17 @@ public static AssignmentTestCase parseTestCaseFile(File testCaseFile) { return testCase; } - public static void runTestCase(AssignmentTestCase testCase, BaseEppoClient eppoClient) { + public static void runTestCase(AssignmentTestCase testCase, BaseEppoClient eppoClient) { runTestCaseBase(testCase, eppoClient, false); } public static void runTestCaseWithDetails( - AssignmentTestCase testCase, BaseEppoClient eppoClient) { + AssignmentTestCase testCase, BaseEppoClient eppoClient) { runTestCaseBase(testCase, eppoClient, true); } private static void runTestCaseBase( - AssignmentTestCase testCase, BaseEppoClient eppoClient, boolean validateDetails) { + AssignmentTestCase testCase, BaseEppoClient eppoClient, boolean validateDetails) { String flagKey = testCase.getFlag(); TestCaseValue defaultValue = testCase.getDefaultValue(); assertFalse(testCase.getSubjects().isEmpty()); From be2b1c3a8a34004e1b3be651e290862d91c1e9a4 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 13 Feb 2026 15:37:37 -0700 Subject: [PATCH 33/44] remove the response bytes from the Configuration data object --- .../cloud/eppo/ConfigurationRequestor.java | 6 +-- .../java/cloud/eppo/api/Configuration.java | 52 ++++--------------- .../cloud/eppo/BaseEppoClientBanditTest.java | 4 +- .../java/cloud/eppo/BaseEppoClientTest.java | 6 +-- .../eppo/ConfigurationRequestorTest.java | 4 +- .../eppo/api/ConfigurationBuilderTest.java | 16 +++--- 6 files changed, 22 insertions(+), 66 deletions(-) diff --git a/src/main/java/cloud/eppo/ConfigurationRequestor.java b/src/main/java/cloud/eppo/ConfigurationRequestor.java index 0ef30601..d4576bd3 100644 --- a/src/main/java/cloud/eppo/ConfigurationRequestor.java +++ b/src/main/java/cloud/eppo/ConfigurationRequestor.java @@ -127,8 +127,7 @@ void fetchAndSaveFromRemote() { FlagConfigResponse flagConfigResponse = configurationParser.parseFlagConfig(flagConfigurationJsonBytes); configBuilder = - new Configuration.Builder(flagConfigurationJsonBytes, flagConfigResponse) - .banditParametersFromConfig(lastConfig); + new Configuration.Builder(flagConfigResponse).banditParametersFromConfig(lastConfig); } catch (ConfigurationParseException e) { log.error("Failed to parse flag configuration", e); throw new RuntimeException(e); @@ -217,8 +216,7 @@ private CompletableFuture buildAndSaveConfiguration( FlagConfigResponse flagConfigResponse = configurationParser.parseFlagConfig(flagResponse.getBody()); configBuilder = - new Configuration.Builder(flagResponse.getBody(), flagConfigResponse) - .banditParametersFromConfig(lastConfig); + new Configuration.Builder(flagConfigResponse).banditParametersFromConfig(lastConfig); } catch (ConfigurationParseException e) { log.error("Failed to parse flag configuration", e); throw new RuntimeException(e); diff --git a/src/main/java/cloud/eppo/api/Configuration.java b/src/main/java/cloud/eppo/api/Configuration.java index 3f591c98..fc88fcc7 100644 --- a/src/main/java/cloud/eppo/api/Configuration.java +++ b/src/main/java/cloud/eppo/api/Configuration.java @@ -10,7 +10,6 @@ import cloud.eppo.api.dto.FlagConfigResponse; import cloud.eppo.api.dto.VariationType; import java.io.*; -import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.Map; @@ -59,9 +58,6 @@ * Builder#requiresUpdatedBanditModels()}. */ public class Configuration { - private static final byte[] emptyFlagsBytes = - "{ \"flags\": {}, \"format\": \"SERVER\" }".getBytes(); - private static final Logger log = LoggerFactory.getLogger(Configuration.class); private final Map banditReferences; private final Map flags; @@ -72,10 +68,6 @@ public class Configuration { private final Date configPublishedAt; @Nullable private final String flagsSnapshotId; - private final byte[] flagConfigJson; - - private final byte[] banditParamsJson; - /** Default visibility for tests. */ Configuration( Map flags, @@ -85,9 +77,7 @@ public class Configuration { String environmentName, Date configFetchedAt, Date configPublishedAt, - @Nullable String flagsSnapshotId, - byte[] flagConfigJson, - byte[] banditParamsJson) { + @Nullable String flagsSnapshotId) { this.flags = flags; this.banditReferences = banditReferences; this.bandits = bandits; @@ -96,8 +86,6 @@ public class Configuration { this.configFetchedAt = configFetchedAt; this.configPublishedAt = configPublishedAt; this.flagsSnapshotId = flagsSnapshotId; - this.flagConfigJson = flagConfigJson; - this.banditParamsJson = banditParamsJson; } public static Configuration emptyConfig() { @@ -109,8 +97,6 @@ public static Configuration emptyConfig() { null, null, null, - null, - emptyFlagsBytes, null); } @@ -134,10 +120,6 @@ public String toString() { + configPublishedAt + ", flagsSnapshotId=" + flagsSnapshotId - + ", flagConfigJson=" - + Arrays.toString(flagConfigJson) - + ", banditParamsJson=" - + Arrays.toString(banditParamsJson) + '}'; } @@ -152,9 +134,7 @@ public boolean equals(Object o) { && Objects.equals(environmentName, that.environmentName) && Objects.equals(configFetchedAt, that.configFetchedAt) && Objects.equals(configPublishedAt, that.configPublishedAt) - && Objects.equals(flagsSnapshotId, that.flagsSnapshotId) - && Objects.deepEquals(flagConfigJson, that.flagConfigJson) - && Objects.deepEquals(banditParamsJson, that.banditParamsJson); + && Objects.equals(flagsSnapshotId, that.flagsSnapshotId); } @Override @@ -167,9 +147,7 @@ public int hashCode() { environmentName, configFetchedAt, configPublishedAt, - flagsSnapshotId, - Arrays.hashCode(flagConfigJson), - Arrays.hashCode(banditParamsJson)); + flagsSnapshotId); } public FlagConfig getFlag(String flagKey) { @@ -256,8 +234,8 @@ public Date getConfigPublishedAt() { return flagsSnapshotId; } - public static Builder builder(byte[] flagJson, FlagConfigResponse flagConfigResponse) { - return new Builder(flagJson, flagConfigResponse); + public static Builder builder(FlagConfigResponse flagConfigResponse) { + return new Builder(flagConfigResponse); } /** @@ -271,25 +249,16 @@ public static class Builder { private final Map flags; private final Map banditReferences; private Map bandits = Collections.emptyMap(); - private final byte[] flagJson; - private byte[] banditParamsJson; private final String environmentName; private final Date configPublishedAt; @Nullable private String flagsSnapshotId; - public Builder(byte[] flagJson, FlagConfigResponse flagConfigResponse) { - this( - flagJson, - flagConfigResponse, - flagConfigResponse.getFormat() == FlagConfigResponse.Format.CLIENT); + public Builder(FlagConfigResponse flagConfigResponse) { + this(flagConfigResponse, flagConfigResponse.getFormat() == FlagConfigResponse.Format.CLIENT); } - public Builder( - byte[] flagJson, - @Nullable FlagConfigResponse flagConfigResponse, - boolean isConfigObfuscated) { + public Builder(@Nullable FlagConfigResponse flagConfigResponse, boolean isConfigObfuscated) { this.isConfigObfuscated = isConfigObfuscated; - this.flagJson = flagJson; if (flagConfigResponse == null || flagConfigResponse.getFlags() == null) { log.warn("'flags' map missing in flag definition JSON"); flags = Collections.emptyMap(); @@ -330,7 +299,6 @@ public Builder banditParametersFromConfig(Configuration currentConfig) { bandits = Collections.emptyMap(); } else { bandits = currentConfig.bandits; - banditParamsJson = currentConfig.banditParamsJson; } return this; } @@ -360,9 +328,7 @@ public Configuration build() { environmentName, configFetchedAt, configPublishedAt, - flagsSnapshotId, - flagJson, - banditParamsJson); + flagsSnapshotId); } } } diff --git a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java index decf0a00..aeb80e4c 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java @@ -94,9 +94,7 @@ private BaseEppoClient initClientWithData( CompletableFuture initialConfig = CompletableFuture.completedFuture( - Configuration.builder( - initialFlagConfiguration.getBytes(), - parser.parseFlagConfig(initialFlagConfiguration.getBytes())) + Configuration.builder(parser.parseFlagConfig(initialFlagConfiguration.getBytes())) .banditParameters(parser.parseBanditParams(initialBanditParameters.getBytes())) .build()); diff --git a/src/test/java/cloud/eppo/BaseEppoClientTest.java b/src/test/java/cloud/eppo/BaseEppoClientTest.java index 4ca33a09..ba59aaca 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientTest.java @@ -374,8 +374,7 @@ public void testInvalidConfigJSON() { private CompletableFuture immediateConfigFuture( String config, boolean isObfuscated) { return CompletableFuture.completedFuture( - Configuration.builder(config.getBytes(), parser.parseFlagConfig(config.getBytes())) - .build()); + Configuration.builder(parser.parseFlagConfig(config.getBytes())).build()); } @Test @@ -480,8 +479,7 @@ public void testWithInitialConfigurationFuture() throws IOException { assertEquals(0, result); // Now, complete the initial config future and check the value. - futureConfig.complete( - Configuration.builder(flagConfig, parser.parseFlagConfig(flagConfig)).build()); + futureConfig.complete(Configuration.builder(parser.parseFlagConfig(flagConfig)).build()); result = eppoClient.getDoubleAssignment("numeric_flag", "dummy subject", 0); assertEquals(5, result); diff --git a/src/test/java/cloud/eppo/ConfigurationRequestorTest.java b/src/test/java/cloud/eppo/ConfigurationRequestorTest.java index 8e0fd871..54af430d 100644 --- a/src/test/java/cloud/eppo/ConfigurationRequestorTest.java +++ b/src/test/java/cloud/eppo/ConfigurationRequestorTest.java @@ -53,12 +53,12 @@ private static String loadInitialFlagConfigString() throws IOException { private Configuration buildConfig(String json) { FlagConfigResponse flagConfigResponse = configurationParser.parseFlagConfig(json.getBytes()); - return new Configuration.Builder(json.getBytes(), flagConfigResponse).build(); + return new Configuration.Builder(flagConfigResponse).build(); } private Configuration buildConfig(byte[] json) { FlagConfigResponse flagConfigResponse = configurationParser.parseFlagConfig(json); - return new Configuration.Builder(json, flagConfigResponse).build(); + return new Configuration.Builder(flagConfigResponse).build(); } @Nested diff --git a/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java b/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java index ff3e92a7..08352950 100644 --- a/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java +++ b/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java @@ -31,7 +31,7 @@ public class ConfigurationBuilderTest { private Configuration buildConfig(byte[] jsonBytes) { FlagConfigResponse flagConfigResponse = parser.parseFlagConfig(jsonBytes); - return new Configuration.Builder(jsonBytes, flagConfigResponse).build(); + return new Configuration.Builder(flagConfigResponse).build(); } private Configuration buildConfig(String json) { @@ -89,9 +89,7 @@ public void getFlagType_shouldReturnCorrectType() { null, // environmentName null, // configFetchedAt null, // configPublishedAt - null, // flagsSnapshotId - null, // flagConfigJson - null); // banditParamsJson + null); // flagsSnapshotId // Test successful case assertEquals(VariationType.STRING, config.getFlagType("test-flag")); @@ -124,9 +122,7 @@ public void getFlagType_withObfuscatedConfig_shouldReturnCorrectType() { null, // environmentName null, // configFetchedAt null, // configPublishedAt - null, // flagsSnapshotId - null, // flagConfigJson - null); // banditParamsJson + null); // flagsSnapshotId // Test successful case with obfuscated config assertEquals(VariationType.NUMERIC, config.getFlagType("test-flag")); @@ -250,7 +246,7 @@ public void testBanditParametersFromNullResponse() { byte[] jsonBytes = json.getBytes(); FlagConfigResponse flagConfigResponse = parser.parseFlagConfig(jsonBytes); Configuration config = - new Configuration.Builder(jsonBytes, flagConfigResponse) + new Configuration.Builder(flagConfigResponse) .banditParameters((BanditParametersResponse) null) .build(); @@ -267,7 +263,7 @@ public void testBanditParametersFromResponseWithNullBandits() { byte[] jsonBytes = json.getBytes(); FlagConfigResponse flagConfigResponse = parser.parseFlagConfig(jsonBytes); Configuration config = - new Configuration.Builder(jsonBytes, flagConfigResponse).banditParameters(response).build(); + new Configuration.Builder(flagConfigResponse).banditParameters(response).build(); // Should not throw and bandit should not be found assertNull(config.getBanditParameters("any-bandit")); @@ -293,7 +289,7 @@ public void testBanditParametersFromResponseWithMultipleBandits() { byte[] jsonBytes = json.getBytes(); FlagConfigResponse flagConfigResponse = parser.parseFlagConfig(jsonBytes); Configuration config = - new Configuration.Builder(jsonBytes, flagConfigResponse).banditParameters(response).build(); + new Configuration.Builder(flagConfigResponse).banditParameters(response).build(); // Verify both bandits are accessible assertNotNull(config.getBanditParameters("bandit-1")); From e32689b25b17430308cc003ab8135c9e6076993f Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 13 Feb 2026 15:41:01 -0700 Subject: [PATCH 34/44] removed unused annotations --- src/main/java/cloud/eppo/model/ShardRange.java | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/main/java/cloud/eppo/model/ShardRange.java b/src/main/java/cloud/eppo/model/ShardRange.java index c93e6f3f..003654e6 100644 --- a/src/main/java/cloud/eppo/model/ShardRange.java +++ b/src/main/java/cloud/eppo/model/ShardRange.java @@ -1,8 +1,5 @@ package cloud.eppo.model; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - import java.util.Objects; /** Shard Range Class */ @@ -10,8 +7,7 @@ public class ShardRange { private final int start; private int end; - @JsonCreator - public ShardRange(@JsonProperty("start") int start, @JsonProperty("end") int end) { + public ShardRange(int start, int end) { this.start = start; this.end = end; } @@ -20,8 +16,7 @@ public ShardRange(@JsonProperty("start") int start, @JsonProperty("end") int end public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; ShardRange that = (ShardRange) o; - return start == that.start && - end == that.end; + return start == that.start && end == that.end; } @Override From 04e319a6c9c568763cefd8b0c4c6293eb79e824b Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 13 Feb 2026 15:46:10 -0700 Subject: [PATCH 35/44] cut over jackson --- .../FlagConfigResponseDeserializer.java | 54 +++- src/main/java/cloud/eppo/Utils.java | 28 --- .../BanditParametersResponseDeserializer.java | 174 ------------- .../eppo/ufc/dto/adapters/DateSerializer.java | 29 --- .../eppo/ufc/dto/adapters/EppoModule.java | 21 -- .../dto/adapters/EppoValueDeserializer.java | 61 ----- .../ufc/dto/adapters/EppoValueSerializer.java | 37 --- .../FlagConfigResponseDeserializer.java | 237 ------------------ src/test/java/cloud/eppo/UtilsTest.java | 43 +--- 9 files changed, 59 insertions(+), 625 deletions(-) delete mode 100644 src/main/java/cloud/eppo/ufc/dto/adapters/BanditParametersResponseDeserializer.java delete mode 100644 src/main/java/cloud/eppo/ufc/dto/adapters/DateSerializer.java delete mode 100644 src/main/java/cloud/eppo/ufc/dto/adapters/EppoModule.java delete mode 100644 src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueDeserializer.java delete mode 100644 src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueSerializer.java delete mode 100644 src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java diff --git a/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java b/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java index 737fcfad..b49d3b5d 100644 --- a/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java +++ b/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java @@ -1,7 +1,5 @@ package cloud.eppo.ufc.dto.adapters; -import static cloud.eppo.Utils.parseUtcISODateNode; - import cloud.eppo.api.EppoValue; import cloud.eppo.api.dto.Allocation; import cloud.eppo.api.dto.BanditFlagVariation; @@ -21,6 +19,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.deser.std.StdDeserializer; import java.io.IOException; +import java.text.ParseException; +import java.text.SimpleDateFormat; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import org.slf4j.Logger; @@ -33,6 +33,15 @@ */ public class FlagConfigResponseDeserializer extends StdDeserializer { private static final Logger log = LoggerFactory.getLogger(FlagConfigResponseDeserializer.class); + private static final ThreadLocal UTC_ISO_DATE_FORMAT = + ThreadLocal.withInitial( + () -> { + SimpleDateFormat dateFormat = + new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US); + dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + return dateFormat; + }); + private final EppoValueDeserializer eppoValueDeserializer = new EppoValueDeserializer(); protected FlagConfigResponseDeserializer(Class vc) { @@ -234,4 +243,45 @@ private BanditReference deserializeBanditReference(JsonNode jsonNode) { } return new BanditReference.Default(modelVersion, flagVariations); } + + // ===== Date Parsing Helpers ===== + + private static Date parseUtcISODateNode(JsonNode isoDateStringElement) { + if (isoDateStringElement == null || isoDateStringElement.isNull()) { + return null; + } + String isoDateString = isoDateStringElement.asText(); + Date result = null; + try { + result = UTC_ISO_DATE_FORMAT.get().parse(isoDateString); + } catch (ParseException e) { + // We expect to fail parsing if the date is base 64 encoded + // Thus we'll leave the result null for now and try again with the decoded value + } + + if (result == null) { + // Date may be encoded + String decodedIsoDateString = base64Decode(isoDateString); + try { + result = UTC_ISO_DATE_FORMAT.get().parse(decodedIsoDateString); + } catch (ParseException e) { + log.warn("Date \"{}\" not in ISO date format", isoDateString); + } + } + + return result; + } + + private static String base64Decode(String input) { + if (input == null) { + return null; + } + byte[] decodedBytes = Base64.getDecoder().decode(input); + if (decodedBytes.length == 0 && !input.isEmpty()) { + throw new RuntimeException( + "zero byte output from Base64; if not running on Android hardware be sure to use" + + " RobolectricTestRunner"); + } + return new String(decodedBytes); + } } diff --git a/src/main/java/cloud/eppo/Utils.java b/src/main/java/cloud/eppo/Utils.java index 3b151881..2c8882de 100644 --- a/src/main/java/cloud/eppo/Utils.java +++ b/src/main/java/cloud/eppo/Utils.java @@ -1,10 +1,8 @@ package cloud.eppo; -import com.fasterxml.jackson.databind.JsonNode; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Base64; import java.util.Date; @@ -92,32 +90,6 @@ public static int getShard(String input, int maxShardValue) { return (int) (value % maxShardValue); } - public static Date parseUtcISODateNode(JsonNode isoDateStringElement) { - if (isoDateStringElement == null || isoDateStringElement.isNull()) { - return null; - } - String isoDateString = isoDateStringElement.asText(); - Date result = null; - try { - result = UTC_ISO_DATE_FORMAT.get().parse(isoDateString); - } catch (ParseException e) { - // We expect to fail parsing if the date is base 64 encoded - // Thus we'll leave the result null for now and try again with the decoded value - } - - if (result == null) { - // Date may be encoded - String decodedIsoDateString = base64Decode(isoDateString); - try { - result = UTC_ISO_DATE_FORMAT.get().parse(decodedIsoDateString); - } catch (ParseException e) { - log.warn("Date \"{}\" not in ISO date format", isoDateString); - } - } - - return result; - } - public static String getISODate(Date date) { return UTC_ISO_DATE_FORMAT.get().format(date); } diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/BanditParametersResponseDeserializer.java b/src/main/java/cloud/eppo/ufc/dto/adapters/BanditParametersResponseDeserializer.java deleted file mode 100644 index 3bc750f2..00000000 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/BanditParametersResponseDeserializer.java +++ /dev/null @@ -1,174 +0,0 @@ -package cloud.eppo.ufc.dto.adapters; - -import cloud.eppo.api.dto.BanditCategoricalAttributeCoefficients; -import cloud.eppo.api.dto.BanditCoefficients; -import cloud.eppo.api.dto.BanditModelData; -import cloud.eppo.api.dto.BanditNumericAttributeCoefficients; -import cloud.eppo.api.dto.BanditParameters; -import cloud.eppo.api.dto.BanditParametersResponse; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.deser.std.StdDeserializer; -import java.io.IOException; -import java.time.Instant; -import java.util.Date; -import java.util.HashMap; -import java.util.Iterator; -import java.util.Map; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class BanditParametersResponseDeserializer - extends StdDeserializer { - private static final Logger log = - LoggerFactory.getLogger(BanditParametersResponseDeserializer.class); - - // Note: public default constructor is required by Jackson - public BanditParametersResponseDeserializer() { - this(null); - } - - protected BanditParametersResponseDeserializer(Class vc) { - super(vc); - } - - @Override - public BanditParametersResponse deserialize( - JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { - JsonNode rootNode = jsonParser.getCodec().readTree(jsonParser); - if (rootNode == null || !rootNode.isObject()) { - log.warn("no top-level JSON object"); - return new BanditParametersResponse.Default(); - } - - JsonNode banditsNode = rootNode.get("bandits"); - if (banditsNode == null || !banditsNode.isObject()) { - log.warn("no root-level bandits object"); - return new BanditParametersResponse.Default(); - } - - Map bandits = new HashMap<>(); - banditsNode - .iterator() - .forEachRemaining( - banditNode -> { - String banditKey = banditNode.get("banditKey").asText(); - String updatedAtStr = banditNode.get("updatedAt").asText(); - Instant instant = Instant.parse(updatedAtStr); - Date updatedAt = Date.from(instant); - String modelName = banditNode.get("modelName").asText(); - String modelVersion = banditNode.get("modelVersion").asText(); - JsonNode modelDataNode = banditNode.get("modelData"); - double gamma = modelDataNode.get("gamma").asDouble(); - double defaultActionScore = modelDataNode.get("defaultActionScore").asDouble(); - double actionProbabilityFloor = - modelDataNode.get("actionProbabilityFloor").asDouble(); - JsonNode coefficientsNode = modelDataNode.get("coefficients"); - Map coefficients = new HashMap<>(); - Iterator> coefficientIterator = coefficientsNode.fields(); - coefficientIterator.forEachRemaining( - field -> { - BanditCoefficients actionCoefficients = - this.parseActionCoefficientsNode(field.getValue()); - coefficients.put(field.getKey(), actionCoefficients); - }); - - BanditModelData modelData = - new BanditModelData.Default( - gamma, defaultActionScore, actionProbabilityFloor, coefficients); - BanditParameters parameters = - new BanditParameters.Default( - banditKey, updatedAt, modelName, modelVersion, modelData); - bandits.put(banditKey, parameters); - }); - - return new BanditParametersResponse.Default(bandits); - } - - private BanditCoefficients parseActionCoefficientsNode(JsonNode actionCoefficientsNode) { - String actionKey = actionCoefficientsNode.get("actionKey").asText(); - Double intercept = actionCoefficientsNode.get("intercept").asDouble(); - - JsonNode subjectNumericAttributeCoefficientsNode = - actionCoefficientsNode.get("subjectNumericCoefficients"); - Map subjectNumericAttributeCoefficients = - this.parseNumericAttributeCoefficientsArrayNode(subjectNumericAttributeCoefficientsNode); - JsonNode subjectCategoricalAttributeCoefficientsNode = - actionCoefficientsNode.get("subjectCategoricalCoefficients"); - Map subjectCategoricalAttributeCoefficients = - this.parseCategoricalAttributeCoefficientsArrayNode( - subjectCategoricalAttributeCoefficientsNode); - - JsonNode actionNumericAttributeCoefficientsNode = - actionCoefficientsNode.get("actionNumericCoefficients"); - Map actionNumericAttributeCoefficients = - this.parseNumericAttributeCoefficientsArrayNode(actionNumericAttributeCoefficientsNode); - JsonNode actionCategoricalAttributeCoefficientsNode = - actionCoefficientsNode.get("actionCategoricalCoefficients"); - Map actionCategoricalAttributeCoefficients = - this.parseCategoricalAttributeCoefficientsArrayNode( - actionCategoricalAttributeCoefficientsNode); - - return new BanditCoefficients.Default( - actionKey, - intercept, - subjectNumericAttributeCoefficients, - subjectCategoricalAttributeCoefficients, - actionNumericAttributeCoefficients, - actionCategoricalAttributeCoefficients); - } - - private Map - parseNumericAttributeCoefficientsArrayNode(JsonNode numericAttributeCoefficientsArrayNode) { - Map numericAttributeCoefficients = new HashMap<>(); - numericAttributeCoefficientsArrayNode - .iterator() - .forEachRemaining( - numericAttributeCoefficientsNode -> { - String attributeKey = numericAttributeCoefficientsNode.get("attributeKey").asText(); - Double coefficient = numericAttributeCoefficientsNode.get("coefficient").asDouble(); - Double missingValueCoefficient = - numericAttributeCoefficientsNode.get("missingValueCoefficient").asDouble(); - BanditNumericAttributeCoefficients coefficients = - new BanditNumericAttributeCoefficients.Default( - attributeKey, coefficient, missingValueCoefficient); - numericAttributeCoefficients.put(attributeKey, coefficients); - }); - - return numericAttributeCoefficients; - } - - private Map - parseCategoricalAttributeCoefficientsArrayNode( - JsonNode categoricalAttributeCoefficientsArrayNode) { - Map categoricalAttributeCoefficients = - new HashMap<>(); - categoricalAttributeCoefficientsArrayNode - .iterator() - .forEachRemaining( - categoricalAttributeCoefficientsNode -> { - String attributeKey = - categoricalAttributeCoefficientsNode.get("attributeKey").asText(); - Double missingValueCoefficient = - categoricalAttributeCoefficientsNode.get("missingValueCoefficient").asDouble(); - - Map valueCoefficients = new HashMap<>(); - JsonNode valuesNode = categoricalAttributeCoefficientsNode.get("valueCoefficients"); - Iterator> coefficientIterator = valuesNode.fields(); - coefficientIterator.forEachRemaining( - field -> { - String value = field.getKey(); - Double coefficient = field.getValue().asDouble(); - valueCoefficients.put(value, coefficient); - }); - - BanditCategoricalAttributeCoefficients coefficients = - new BanditCategoricalAttributeCoefficients.Default( - attributeKey, missingValueCoefficient, valueCoefficients); - categoricalAttributeCoefficients.put(attributeKey, coefficients); - }); - - return categoricalAttributeCoefficients; - } -} diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/DateSerializer.java b/src/main/java/cloud/eppo/ufc/dto/adapters/DateSerializer.java deleted file mode 100644 index 2ea23176..00000000 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/DateSerializer.java +++ /dev/null @@ -1,29 +0,0 @@ -package cloud.eppo.ufc.dto.adapters; - -import static cloud.eppo.Utils.getISODate; - -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.databind.SerializerProvider; -import com.fasterxml.jackson.databind.ser.std.StdSerializer; -import java.io.IOException; -import java.util.Date; - -/** - * This adapter for Date allows gson to serialize to UTC ISO 8601 (vs. its default of local - * timezone) - */ -public class DateSerializer extends StdSerializer { - protected DateSerializer(Class t) { - super(t); - } - - public DateSerializer() { - this(null); - } - - @Override - public void serialize(Date value, JsonGenerator jgen, SerializerProvider provider) - throws IOException { - jgen.writeString(getISODate(value)); - } -} diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoModule.java b/src/main/java/cloud/eppo/ufc/dto/adapters/EppoModule.java deleted file mode 100644 index 8237aaf7..00000000 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoModule.java +++ /dev/null @@ -1,21 +0,0 @@ -package cloud.eppo.ufc.dto.adapters; - -import cloud.eppo.api.EppoValue; -import cloud.eppo.api.dto.BanditParametersResponse; -import cloud.eppo.api.dto.FlagConfigResponse; -import com.fasterxml.jackson.databind.module.SimpleModule; -import java.util.Date; - -public class EppoModule { - public static SimpleModule eppoModule() { - SimpleModule module = new SimpleModule(); - module.addDeserializer(FlagConfigResponse.class, new FlagConfigResponseDeserializer()); - module.addDeserializer( - BanditParametersResponse.class, new BanditParametersResponseDeserializer()); - module.addDeserializer(EppoValue.class, new EppoValueDeserializer()); - module.addSerializer(EppoValue.class, new EppoValueSerializer()); - module.addSerializer(Date.class, new DateSerializer()); - // TODO: add bandit deserializer - return module; - } -} diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueDeserializer.java b/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueDeserializer.java deleted file mode 100644 index 09ec5b36..00000000 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueDeserializer.java +++ /dev/null @@ -1,61 +0,0 @@ -package cloud.eppo.ufc.dto.adapters; - -import cloud.eppo.api.EppoValue; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.deser.std.StdDeserializer; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class EppoValueDeserializer extends StdDeserializer { - private static final Logger log = LoggerFactory.getLogger(EppoValueDeserializer.class); - - protected EppoValueDeserializer(Class vc) { - super(vc); - } - - public EppoValueDeserializer() { - this(null); - } - - @Override - public EppoValue deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException { - return deserializeNode(jp.getCodec().readTree(jp)); - } - - public EppoValue deserializeNode(JsonNode node) { - EppoValue result; - if (node == null || node.isNull()) { - result = EppoValue.nullValue(); - } else if (node.isArray()) { - List stringArray = new ArrayList<>(); - for (JsonNode arrayElement : node) { - if (arrayElement.isValueNode() && arrayElement.isTextual()) { - stringArray.add(arrayElement.asText()); - } else { - log.warn( - "only Strings are supported for array-valued values; received: {}", arrayElement); - } - } - result = EppoValue.valueOf(stringArray); - } else if (node.isValueNode()) { - if (node.isBoolean()) { - result = EppoValue.valueOf(node.asBoolean()); - } else if (node.isNumber()) { - result = EppoValue.valueOf(node.doubleValue()); - } else { - result = EppoValue.valueOf(node.textValue()); - } - } else { - // If here, we don't know what to do; fail to null with a warning - log.warn("Unexpected JSON for parsing a value: {}", node); - result = EppoValue.nullValue(); - } - - return result; - } -} diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueSerializer.java b/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueSerializer.java deleted file mode 100644 index 8bd10bd6..00000000 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueSerializer.java +++ /dev/null @@ -1,37 +0,0 @@ -package cloud.eppo.ufc.dto.adapters; - -import cloud.eppo.api.EppoValue; -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.databind.SerializerProvider; -import com.fasterxml.jackson.databind.ser.std.StdSerializer; -import java.io.IOException; - -public class EppoValueSerializer extends StdSerializer { - protected EppoValueSerializer(Class t) { - super(t); - } - - public EppoValueSerializer() { - this(null); - } - - @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(); - } - } -} diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java b/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java deleted file mode 100644 index 737fcfad..00000000 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java +++ /dev/null @@ -1,237 +0,0 @@ -package cloud.eppo.ufc.dto.adapters; - -import static cloud.eppo.Utils.parseUtcISODateNode; - -import cloud.eppo.api.EppoValue; -import cloud.eppo.api.dto.Allocation; -import cloud.eppo.api.dto.BanditFlagVariation; -import cloud.eppo.api.dto.BanditReference; -import cloud.eppo.api.dto.FlagConfig; -import cloud.eppo.api.dto.FlagConfigResponse; -import cloud.eppo.api.dto.OperatorType; -import cloud.eppo.api.dto.Shard; -import cloud.eppo.api.dto.Split; -import cloud.eppo.api.dto.TargetingCondition; -import cloud.eppo.api.dto.TargetingRule; -import cloud.eppo.api.dto.Variation; -import cloud.eppo.api.dto.VariationType; -import cloud.eppo.model.ShardRange; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.deser.std.StdDeserializer; -import java.io.IOException; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Hand-rolled deserializer 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-deserialization-related classes and annotations. - */ -public class FlagConfigResponseDeserializer extends StdDeserializer { - private static final Logger log = LoggerFactory.getLogger(FlagConfigResponseDeserializer.class); - private final EppoValueDeserializer eppoValueDeserializer = new EppoValueDeserializer(); - - protected FlagConfigResponseDeserializer(Class vc) { - super(vc); - } - - public FlagConfigResponseDeserializer() { - this(null); - } - - @Override - public FlagConfigResponse deserialize(JsonParser jp, DeserializationContext ctxt) - throws IOException { - JsonNode rootNode = jp.getCodec().readTree(jp); - - if (rootNode == null || !rootNode.isObject()) { - log.warn("no top-level JSON object"); - return new FlagConfigResponse.Default(); - } - JsonNode flagsNode = rootNode.get("flags"); - if (flagsNode == null || !flagsNode.isObject()) { - log.warn("no root-level flags object"); - return new FlagConfigResponse.Default(); - } - - // Default is to assume that the config is not obfuscated. - JsonNode formatNode = rootNode.get("format"); - FlagConfigResponse.Format dataFormat = - formatNode == null - ? 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 - .fields() - .forEachRemaining( - field -> { - FlagConfig flagConfig = deserializeFlag(field.getValue()); - flags.put(field.getKey(), flagConfig); - }); - - Map banditReferences = new ConcurrentHashMap<>(); - if (rootNode.has("banditReferences")) { - JsonNode banditReferencesNode = rootNode.get("banditReferences"); - if (!banditReferencesNode.isObject()) { - log.warn("root-level banditReferences property is present but not a JSON object"); - } else { - banditReferencesNode - .fields() - .forEachRemaining( - field -> { - BanditReference banditReference = deserializeBanditReference(field.getValue()); - banditReferences.put(field.getKey(), banditReference); - }); - } - } - - return new FlagConfigResponse.Default( - flags, banditReferences, dataFormat, environmentName, createdAt); - } - - private FlagConfig deserializeFlag(JsonNode jsonNode) { - String key = jsonNode.get("key").asText(); - boolean enabled = jsonNode.get("enabled").asBoolean(); - int totalShards = jsonNode.get("totalShards").asInt(); - VariationType variationType = VariationType.fromString(jsonNode.get("variationType").asText()); - Map variations = deserializeVariations(jsonNode.get("variations")); - List allocations = deserializeAllocations(jsonNode.get("allocations")); - - return new FlagConfig.Default( - key, enabled, totalShards, variationType, variations, allocations); - } - - private Map deserializeVariations(JsonNode jsonNode) { - Map variations = new HashMap<>(); - if (jsonNode == null) { - return variations; - } - for (Iterator> it = jsonNode.fields(); it.hasNext(); ) { - Map.Entry entry = it.next(); - String key = entry.getValue().get("key").asText(); - EppoValue value = eppoValueDeserializer.deserializeNode(entry.getValue().get("value")); - variations.put(entry.getKey(), new Variation.Default(key, value)); - } - return variations; - } - - private List deserializeAllocations(JsonNode jsonNode) { - List allocations = new ArrayList<>(); - if (jsonNode == null) { - return allocations; - } - for (JsonNode allocationNode : jsonNode) { - String key = allocationNode.get("key").asText(); - Set rules = deserializeTargetingRules(allocationNode.get("rules")); - Date startAt = parseUtcISODateNode(allocationNode.get("startAt")); - Date endAt = parseUtcISODateNode(allocationNode.get("endAt")); - List splits = deserializeSplits(allocationNode.get("splits")); - boolean doLog = allocationNode.get("doLog").asBoolean(); - allocations.add(new Allocation.Default(key, rules, startAt, endAt, splits, doLog)); - } - return allocations; - } - - private Set deserializeTargetingRules(JsonNode jsonNode) { - Set targetingRules = new HashSet<>(); - if (jsonNode == null || !jsonNode.isArray()) { - return targetingRules; - } - for (JsonNode ruleNode : jsonNode) { - Set conditions = new HashSet<>(); - for (JsonNode conditionNode : ruleNode.get("conditions")) { - String attribute = conditionNode.get("attribute").asText(); - String operatorKey = conditionNode.get("operator").asText(); - OperatorType operator = OperatorType.fromString(operatorKey); - if (operator == null) { - log.warn("Unknown operator \"{}\"", operatorKey); - continue; - } - EppoValue value = eppoValueDeserializer.deserializeNode(conditionNode.get("value")); - conditions.add(new TargetingCondition.Default(operator, attribute, value)); - } - targetingRules.add(new TargetingRule.Default(conditions)); - } - - return targetingRules; - } - - private List deserializeSplits(JsonNode jsonNode) { - List splits = new ArrayList<>(); - if (jsonNode == null || !jsonNode.isArray()) { - return splits; - } - for (JsonNode splitNode : jsonNode) { - String variationKey = splitNode.get("variationKey").asText(); - Set shards = deserializeShards(splitNode.get("shards")); - Map extraLogging = new HashMap<>(); - JsonNode extraLoggingNode = splitNode.get("extraLogging"); - if (extraLoggingNode != null && extraLoggingNode.isObject()) { - for (Iterator> it = extraLoggingNode.fields(); it.hasNext(); ) { - Map.Entry entry = it.next(); - extraLogging.put(entry.getKey(), entry.getValue().asText()); - } - } - splits.add(new Split.Default(variationKey, shards, extraLogging)); - } - - return splits; - } - - private Set deserializeShards(JsonNode jsonNode) { - Set shards = new HashSet<>(); - if (jsonNode == null || !jsonNode.isArray()) { - return shards; - } - for (JsonNode shardNode : jsonNode) { - String salt = shardNode.get("salt").asText(); - Set ranges = new HashSet<>(); - for (JsonNode rangeNode : shardNode.get("ranges")) { - int start = rangeNode.get("start").asInt(); - int end = rangeNode.get("end").asInt(); - ranges.add(new ShardRange(start, end)); - } - shards.add(new Shard.Default(salt, ranges)); - } - return shards; - } - - private BanditReference deserializeBanditReference(JsonNode jsonNode) { - String modelVersion = jsonNode.get("modelVersion").asText(); - List flagVariations = new ArrayList<>(); - JsonNode flagVariationsNode = jsonNode.get("flagVariations"); - if (flagVariationsNode != null && flagVariationsNode.isArray()) { - for (JsonNode flagVariationNode : flagVariationsNode) { - String banditKey = flagVariationNode.get("key").asText(); - String flagKey = flagVariationNode.get("flagKey").asText(); - String allocationKey = flagVariationNode.get("allocationKey").asText(); - String variationKey = flagVariationNode.get("variationKey").asText(); - String variationValue = flagVariationNode.get("variationValue").asText(); - BanditFlagVariation flagVariation = - new BanditFlagVariation.Default( - banditKey, flagKey, allocationKey, variationKey, variationValue); - flagVariations.add(flagVariation); - } - } - return new BanditReference.Default(modelVersion, flagVariations); - } -} diff --git a/src/test/java/cloud/eppo/UtilsTest.java b/src/test/java/cloud/eppo/UtilsTest.java index 7fb6b58e..3de55e29 100644 --- a/src/test/java/cloud/eppo/UtilsTest.java +++ b/src/test/java/cloud/eppo/UtilsTest.java @@ -3,9 +3,6 @@ import static cloud.eppo.Utils.*; import static org.junit.jupiter.api.Assertions.*; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import java.util.Date; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; @@ -81,33 +78,19 @@ public void testGetShard() { } @Test - public void testParseUtcISODateNode() throws JsonProcessingException { - ObjectMapper mapper = new ObjectMapper(); - JsonNode jsonNode = mapper.readTree("\"2024-05-01T16:13:26.651Z\""); - Date parsedDate = parseUtcISODateNode(jsonNode); - Date expectedDate = new Date(1714580006651L); - assertEquals(expectedDate, parsedDate); - jsonNode = mapper.readTree("null"); - parsedDate = parseUtcISODateNode(jsonNode); - assertNull(parsedDate); - assertNull(parseUtcISODateNode(null)); - } - - @Test - public void testDateParsingThreadSafety() throws InterruptedException { + public void testDateFormattingThreadSafety() throws InterruptedException { final AtomicBoolean collisionDetected = new AtomicBoolean(false); final AtomicInteger unexpectedExceptions = new AtomicInteger(0); - final AtomicInteger incorrectParseResults = new AtomicInteger(0); + final AtomicInteger incorrectFormatResults = new AtomicInteger(0); int numThreads = 20; // Spawn 20 threads - int iterationsPerThread = 100; // Each thread will parse 100 dates + int iterationsPerThread = 100; // Each thread will format 100 dates ExecutorService pool = Executors.newFixedThreadPool(numThreads); CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch finishLatch = new CountDownLatch(numThreads); // Expected date: 2024-05-01T16:13:26.651Z -> 1714580006651L - final String testDateString = "\"2024-05-01T16:13:26.651Z\""; final long expectedTimestamp = 1714580006651L; try { @@ -118,26 +101,14 @@ public void testDateParsingThreadSafety() throws InterruptedException { // Wait for all threads to start simultaneously startLatch.await(); - ObjectMapper mapper = new ObjectMapper(); - for (int j = 0; j < iterationsPerThread; j++) { try { - JsonNode jsonNode = mapper.readTree(testDateString); - Date parsedDate = parseUtcISODateNode(jsonNode); - - if (parsedDate == null || parsedDate.getTime() != expectedTimestamp) { - incorrectParseResults.incrementAndGet(); - collisionDetected.set(true); - } - - // Also test the reverse operation Date originalDate = new Date(expectedTimestamp); String formattedDate = getISODate(originalDate); if (!formattedDate.equals("2024-05-01T16:13:26.651Z")) { - incorrectParseResults.incrementAndGet(); + incorrectFormatResults.incrementAndGet(); collisionDetected.set(true); } - } catch (Exception e) { unexpectedExceptions.incrementAndGet(); } @@ -166,15 +137,15 @@ public void testDateParsingThreadSafety() throws InterruptedException { // Print diagnostic information System.out.println("Unexpected exceptions: " + unexpectedExceptions.get()); - System.out.println("Incorrect parse results: " + incorrectParseResults.get()); - System.out.println("Total operations: " + (numThreads * iterationsPerThread * 2)); + System.out.println("Incorrect format results: " + incorrectFormatResults.get()); + System.out.println("Total operations: " + (numThreads * iterationsPerThread)); String failureMessage = "SimpleDateFormat thread-safety issue detected! " + "Exceptions: " + unexpectedExceptions.get() + ", Incorrect results: " - + incorrectParseResults.get(); + + incorrectFormatResults.get(); assertFalse(collisionDetected.get(), failureMessage); assertEquals(0, unexpectedExceptions.get(), failureMessage); } From 83e6b8199b8389c643bc56f076a5fab16a2cdcda Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 13 Feb 2026 15:48:00 -0700 Subject: [PATCH 36/44] update config docs --- .../java/cloud/eppo/api/Configuration.java | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/src/main/java/cloud/eppo/api/Configuration.java b/src/main/java/cloud/eppo/api/Configuration.java index fc88fcc7..bd9e3e02 100644 --- a/src/main/java/cloud/eppo/api/Configuration.java +++ b/src/main/java/cloud/eppo/api/Configuration.java @@ -9,7 +9,6 @@ import cloud.eppo.api.dto.FlagConfig; import cloud.eppo.api.dto.FlagConfigResponse; import cloud.eppo.api.dto.VariationType; -import java.io.*; import java.util.Collections; import java.util.Date; import java.util.Map; @@ -29,31 +28,37 @@ * accommodate the as-needed loading of bandit parameters as a network call may not be needed if * there are no bandits referenced by the flag configuration. * - *

Usage: Building with just flag configuration (unobfuscated is default) - * FlagConfigResponse flagConfig = parser.parseFlagConfig(flagConfigJsonBytes); - * Configuration config = new Configuration.Builder(flagConfigJsonBytes, flagConfig).build(); - * + *

Usage: Building with just flag configuration (obfuscation auto-detected from format): * - *

Building with bandits (known configuration) - * FlagConfigResponse flagConfig = parser.parseFlagConfig(flagConfigJsonBytes); - * BanditParametersResponse banditParams = parser.parseBanditParams(banditParamsJsonBytes); - * Configuration config = new Configuration.Builder(flagConfigJsonBytes, flagConfig) - * .banditParameters(banditParams) - * .build(); - * + *

{@code
+ * FlagConfigResponse flagConfig = parser.parseFlagConfig(flagConfigJsonBytes);
+ * Configuration config = new Configuration.Builder(flagConfig).build();
+ * }
* - *

Conditionally loading bandit models (with or without an existing bandit configuration). - * FlagConfigResponse flagConfig = parser.parseFlagConfig(flagConfigJsonBytes); - * Configuration.Builder configBuilder = new Configuration.Builder(flagConfigJsonBytes, flagConfig) - * .banditParametersFromConfig(existingConfig); - * if (configBuilder.requiresUpdatedBanditModels()) { - * BanditParametersResponse banditParams = parser.parseBanditParams(banditParamsJsonBytes); - * configBuilder.banditParameters(banditParams); - * } - * Configuration config = configBuilder.build(); - * + *

Building with bandits (known configuration): * - *

Hint: when loading new Flag configuration values, set the current bandit models in the builder + *

{@code
+ * FlagConfigResponse flagConfig = parser.parseFlagConfig(flagConfigJsonBytes);
+ * BanditParametersResponse banditParams = parser.parseBanditParams(banditParamsJsonBytes);
+ * Configuration config = new Configuration.Builder(flagConfig)
+ *     .banditParameters(banditParams)
+ *     .build();
+ * }
+ * + *

Conditionally loading bandit models (with or without an existing bandit configuration): + * + *

{@code
+ * FlagConfigResponse flagConfig = parser.parseFlagConfig(flagConfigJsonBytes);
+ * Configuration.Builder configBuilder = new Configuration.Builder(flagConfig)
+ *     .banditParametersFromConfig(existingConfig);
+ * if (configBuilder.requiresUpdatedBanditModels()) {
+ *   BanditParametersResponse banditParams = parser.parseBanditParams(banditParamsJsonBytes);
+ *   configBuilder.banditParameters(banditParams);
+ * }
+ * Configuration config = configBuilder.build();
+ * }
+ * + *

Hint: when loading new flag configuration values, set the current bandit models in the builder * using {@link Builder#banditParametersFromConfig(Configuration)}, then check {@link * Builder#requiresUpdatedBanditModels()}. */ From 6b0d94dc52646c592b4bcadf310c2230a99491d8 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 13 Feb 2026 15:48:36 -0700 Subject: [PATCH 37/44] remove jackson from bandit action attributes --- src/main/java/cloud/eppo/api/Attributes.java | 42 -------------------- 1 file changed, 42 deletions(-) diff --git a/src/main/java/cloud/eppo/api/Attributes.java b/src/main/java/cloud/eppo/api/Attributes.java index 2406d171..3a146057 100644 --- a/src/main/java/cloud/eppo/api/Attributes.java +++ b/src/main/java/cloud/eppo/api/Attributes.java @@ -1,8 +1,5 @@ package cloud.eppo.api; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.HashMap; import java.util.Map; import java.util.stream.Collectors; @@ -64,43 +61,4 @@ public Attributes getCategoricalAttributes() { public Attributes getAllAttributes() { return this; } - - /** Serializes the attributes to a JSON string, omitting attributes with a null value. */ - public String serializeNonNullAttributesToJSONString() { - return serializeAttributesToJSONString(true); - } - - @SuppressWarnings("SameParameterValue") - private String serializeAttributesToJSONString(boolean omitNulls) { - ObjectMapper mapper = new ObjectMapper(); - ObjectNode result = mapper.createObjectNode(); - - for (Map.Entry entry : entrySet()) { - String attributeName = entry.getKey(); - EppoValue attributeValue = entry.getValue(); - - if (attributeValue == null || attributeValue.isNull()) { - if (!omitNulls) { - result.putNull(attributeName); - } - } else { - if (attributeValue.isNumeric()) { - result.put(attributeName, attributeValue.doubleValue()); - continue; - } - if (attributeValue.isBoolean()) { - result.put(attributeName, attributeValue.booleanValue()); - continue; - } - // fall back put treating any other eppo values as a string - result.put(attributeName, attributeValue.toString()); - } - } - - try { - return mapper.writeValueAsString(result); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - } } From a360488e1aa960a53ddec2f2ba21469fb0f552e9 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 13 Feb 2026 15:57:51 -0700 Subject: [PATCH 38/44] re-delegate json parsing/unwrapping --- src/main/java/cloud/eppo/BaseEppoClient.java | 6 ++- src/main/java/cloud/eppo/api/EppoValue.java | 43 +++++++++++++----- .../java/cloud/eppo/api/EppoValueTest.java | 45 +++++++++++++++---- 3 files changed, 72 insertions(+), 22 deletions(-) diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index 41f96a52..aa4f3a00 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -38,6 +38,7 @@ public class BaseEppoClient { private boolean isGracefulMode; private final IAssignmentCache assignmentCache; private final IAssignmentCache banditAssignmentCache; + private final ConfigurationParser configurationParser; private Timer pollTimer; @Nullable protected CompletableFuture getInitialConfigFuture() { @@ -72,6 +73,7 @@ protected BaseEppoClient( this.assignmentCache = assignmentCache; this.banditAssignmentCache = banditAssignmentCache; + this.configurationParser = configurationParser; SDKKey sdkKey = new SDKKey(apiKey); ApiEndpoints endpointHelper = new ApiEndpoints(sdkKey, apiBaseUrl); @@ -199,7 +201,7 @@ protected AssignmentDetails getTypedAssignmentWithDetails( T resultValue = details.evaluationSuccessful() - ? details.getVariationValue().unwrap(expectedType) + ? details.getVariationValue().unwrap(expectedType, configurationParser::parseJsonValue) : defaultValue; return new AssignmentDetails<>(resultValue, null, details); } @@ -372,7 +374,7 @@ private boolean valueTypeMatchesExpected(VariationType expectedType, EppoValue v value.isString() // Eppo leaves JSON as a JSON string; to verify it's valid we attempt to parse (via // unwrapping) - && value.unwrap(VariationType.JSON) != null; + && value.unwrap(VariationType.JSON, configurationParser::parseJsonValue) != null; break; default: throw new IllegalArgumentException("Unexpected type for type checking: " + expectedType); diff --git a/src/main/java/cloud/eppo/api/EppoValue.java b/src/main/java/cloud/eppo/api/EppoValue.java index e16e8d49..af6b593a 100644 --- a/src/main/java/cloud/eppo/api/EppoValue.java +++ b/src/main/java/cloud/eppo/api/EppoValue.java @@ -2,11 +2,10 @@ import cloud.eppo.api.dto.EppoValueType; import cloud.eppo.api.dto.VariationType; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import java.util.Iterator; import java.util.List; import java.util.Objects; +import java.util.function.Function; public class EppoValue { protected final EppoValueType type; @@ -100,14 +99,39 @@ public EppoValueType getType() { } /** - * Unwraps this EppoValue to the appropriate Java type based on the variation type. + * Unwraps this EppoValue to the appropriate Java type based on the variation type. For JSON + * types, use {@link #unwrap(VariationType, Function)} instead. * - * @param expectedType the expected variation type - * @param the target type (Boolean, Integer, Double, String, or JsonNode) + * @param expectedType the expected variation type (must not be JSON) + * @param the target type (Boolean, Integer, Double, or String) * @return the unwrapped value + * @throws IllegalArgumentException if expectedType is JSON (use the overload with jsonParser) */ @SuppressWarnings("unchecked") public T unwrap(VariationType expectedType) { + if (expectedType == VariationType.JSON) { + throw new IllegalArgumentException( + "JSON unwrap requires a parser function; use unwrap(expectedType, jsonParser)"); + } + return unwrapInternal(expectedType, null); + } + + /** + * Unwraps this EppoValue to the appropriate Java type based on the variation type, using the + * provided parser for JSON values. + * + * @param expectedType the expected variation type + * @param jsonParser function to parse JSON strings (required for JSON type, ignored otherwise) + * @param the target type (Boolean, Integer, Double, String, or the JSON parser's return type) + * @return the unwrapped value + */ + @SuppressWarnings("unchecked") + public T unwrap(VariationType expectedType, Function jsonParser) { + return unwrapInternal(expectedType, jsonParser); + } + + @SuppressWarnings("unchecked") + private T unwrapInternal(VariationType expectedType, Function jsonParser) { switch (expectedType) { case BOOLEAN: return (T) Boolean.valueOf(booleanValue()); @@ -118,13 +142,10 @@ public T unwrap(VariationType expectedType) { case STRING: return (T) stringValue(); case JSON: - String jsonString = stringValue(); - try { - ObjectMapper mapper = new ObjectMapper(); - return (T) mapper.readTree(jsonString); - } catch (JsonProcessingException e) { - return null; + if (jsonParser == null) { + throw new IllegalArgumentException("JSON parser required for JSON type"); } + return (T) jsonParser.apply(stringValue()); } throw new IllegalArgumentException("Unknown variation type: " + expectedType); } diff --git a/src/test/java/cloud/eppo/api/EppoValueTest.java b/src/test/java/cloud/eppo/api/EppoValueTest.java index d05cbcbd..f6a212a6 100644 --- a/src/test/java/cloud/eppo/api/EppoValueTest.java +++ b/src/test/java/cloud/eppo/api/EppoValueTest.java @@ -1,17 +1,32 @@ package cloud.eppo.api; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import cloud.eppo.api.dto.VariationType; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.function.Function; import org.junit.jupiter.api.Test; public class EppoValueTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** JSON parser function for tests - parses JSON strings to JsonNode */ + private static final Function JSON_PARSER = + jsonString -> { + try { + return MAPPER.readTree(jsonString); + } catch (JsonProcessingException e) { + return null; + } + }; + @Test public void testDoubleValue() { EppoValue eppoValue = EppoValue.valueOf(123.4567); @@ -118,7 +133,7 @@ public void testUnwrapString() { public void testUnwrapJsonValid() { String jsonString = "{\"foo\":\"bar\",\"count\":42}"; EppoValue jsonValue = EppoValue.valueOf(jsonString); - JsonNode result = jsonValue.unwrap(VariationType.JSON); + JsonNode result = jsonValue.unwrap(VariationType.JSON, JSON_PARSER); assertTrue(result.isObject()); assertEquals("bar", result.get("foo").asText()); @@ -129,7 +144,7 @@ public void testUnwrapJsonValid() { public void testUnwrapJsonArray() { String jsonArrayString = "[1,2,3,4,5]"; EppoValue jsonValue = EppoValue.valueOf(jsonArrayString); - JsonNode result = jsonValue.unwrap(VariationType.JSON); + JsonNode result = jsonValue.unwrap(VariationType.JSON, JSON_PARSER); assertTrue(result.isArray()); assertEquals(5, result.size()); @@ -141,7 +156,7 @@ public void testUnwrapJsonArray() { public void testUnwrapJsonWithSpecialCharacters() { String jsonString = "{\"a\":\"kümmert\",\"b\":\"schön\"}"; EppoValue jsonValue = EppoValue.valueOf(jsonString); - JsonNode result = jsonValue.unwrap(VariationType.JSON); + JsonNode result = jsonValue.unwrap(VariationType.JSON, JSON_PARSER); assertTrue(result.isObject()); assertEquals("kümmert", result.get("a").asText()); @@ -152,7 +167,7 @@ public void testUnwrapJsonWithSpecialCharacters() { public void testUnwrapJsonWithEmojis() { String jsonString = "{\"a\":\"🤗\",\"b\":\"🌸\"}"; EppoValue jsonValue = EppoValue.valueOf(jsonString); - JsonNode result = jsonValue.unwrap(VariationType.JSON); + JsonNode result = jsonValue.unwrap(VariationType.JSON, JSON_PARSER); assertTrue(result.isObject()); assertEquals("🤗", result.get("a").asText()); @@ -163,7 +178,7 @@ public void testUnwrapJsonWithEmojis() { public void testUnwrapJsonWithWhitespace() { String jsonString = "{ \"key\": \"value\", \"number\": 123 }"; EppoValue jsonValue = EppoValue.valueOf(jsonString); - JsonNode result = jsonValue.unwrap(VariationType.JSON); + JsonNode result = jsonValue.unwrap(VariationType.JSON, JSON_PARSER); assertTrue(result.isObject()); assertEquals("value", result.get("key").asText()); @@ -174,18 +189,30 @@ public void testUnwrapJsonWithWhitespace() { public void testUnwrapJsonInvalid() { String invalidJson = "not valid json {"; EppoValue jsonValue = EppoValue.valueOf(invalidJson); - JsonNode result = jsonValue.unwrap(VariationType.JSON); + // Our test JSON_PARSER returns null for invalid JSON + JsonNode result = jsonValue.unwrap(VariationType.JSON, JSON_PARSER); - assertNull(result, "Invalid JSON should return null"); + assertTrue(result == null, "Invalid JSON should return null from parser"); } @Test public void testUnwrapJsonEmpty() { String emptyJson = "{}"; EppoValue jsonValue = EppoValue.valueOf(emptyJson); - JsonNode result = jsonValue.unwrap(VariationType.JSON); + JsonNode result = jsonValue.unwrap(VariationType.JSON, JSON_PARSER); assertTrue(result.isObject()); assertEquals(0, result.size()); } + + @Test + public void testUnwrapJsonWithoutParserThrows() { + String jsonString = "{\"foo\":\"bar\"}"; + EppoValue jsonValue = EppoValue.valueOf(jsonString); + + assertThrows( + IllegalArgumentException.class, + () -> jsonValue.unwrap(VariationType.JSON), + "Unwrapping JSON without a parser should throw"); + } } From 8c4113132c2a895b8c3b796a6522d1452f5e3c4d Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 13 Feb 2026 15:59:22 -0700 Subject: [PATCH 39/44] move okhttp and jackson to test only --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 28ec2ea3..2ffe8436 100644 --- a/build.gradle +++ b/build.gradle @@ -18,9 +18,7 @@ java { dependencies { api 'org.jetbrains:annotations:26.0.1' - implementation 'com.fasterxml.jackson.core:jackson-databind:2.20.1' implementation 'com.github.zafarkhaja:java-semver:0.10.2' - implementation "com.squareup.okhttp3:okhttp:4.12.0" // For LRU and expiring maps implementation 'org.apache.commons:commons-collections4:4.5.0' implementation 'org.slf4j:slf4j-api:2.0.17' @@ -37,6 +35,8 @@ dependencies { } testImplementation 'net.bytebuddy:byte-buddy:1.18.1' // Use the latest available version testImplementation 'org.mockito:mockito-inline:4.11.0' + testImplementation 'com.fasterxml.jackson.core:jackson-databind:2.20.1' + testImplementation "com.squareup.okhttp3:okhttp:4.12.0" // Use common http/parser implementations in tests. testImplementation project(':eppo-sdk-common') From cd8e79ee77ce5a1bee113433092df247204f7023 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 18 Feb 2026 22:28:52 -0700 Subject: [PATCH 40/44] feat(utils): add Base64Codec interface for pluggable encoding Add pluggable Base64 codec support to allow platform-specific implementations (e.g., Android SDK using android.util.Base64). - Add public Base64Codec interface with encode/decode methods - Add setBase64Codec() method for custom codec injection - Create DefaultBase64Codec inner class using java.util.Base64 - Delegate existing base64Encode/base64Decode methods to codec --- src/main/java/cloud/eppo/Utils.java | 53 +++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/src/main/java/cloud/eppo/Utils.java b/src/main/java/cloud/eppo/Utils.java index 2c8882de..81422190 100644 --- a/src/main/java/cloud/eppo/Utils.java +++ b/src/main/java/cloud/eppo/Utils.java @@ -14,6 +14,24 @@ public final class Utils { private static final ThreadLocal UTC_ISO_DATE_FORMAT = buildUtcIsoDateFormat(); private static final Logger log = LoggerFactory.getLogger(Utils.class); private static final ThreadLocal md = buildMd5MessageDigest(); + private static Base64Codec base64Codec = new DefaultBase64Codec(); + + /** Interface for Base64 encoding/decoding operations. */ + public interface Base64Codec { + String base64Encode(String input); + + String base64Decode(String input); + } + + /** + * Sets the Base64 codec implementation to use for encoding and decoding operations. This allows + * platform-specific implementations (e.g., Android SDK using android.util.Base64). + * + * @param codec the Base64 codec implementation to use + */ + public static void setBase64Codec(Base64Codec codec) { + base64Codec = codec; + } @SuppressWarnings("AnonymousHasLambdaAlternative") private static ThreadLocal buildMd5MessageDigest() { @@ -95,21 +113,34 @@ public static String getISODate(Date date) { } public static String base64Encode(String input) { - if (input == null) { - return null; - } - return new String(Base64.getEncoder().encode(input.getBytes(StandardCharsets.UTF_8))); + return base64Codec.base64Encode(input); } public static String base64Decode(String input) { - if (input == null) { - return null; + return base64Codec.base64Decode(input); + } + + private static class DefaultBase64Codec implements Base64Codec { + @Override + public String base64Encode(String input) { + if (input == null) { + return null; + } + return new String(Base64.getEncoder().encode(input.getBytes(StandardCharsets.UTF_8))); } - byte[] decodedBytes = Base64.getDecoder().decode(input); - if (decodedBytes.length == 0 && !input.isEmpty()) { - throw new RuntimeException( - "zero byte output from Base64; if not running on Android hardware be sure to use RobolectricTestRunner"); + + @Override + public String base64Decode(String input) { + if (input == null) { + return null; + } + byte[] decodedBytes = Base64.getDecoder().decode(input); + if (decodedBytes.length == 0 && !input.isEmpty()) { + throw new RuntimeException( + "zero byte output from Base64; if not running on Android hardware be sure to use" + + " RobolectricTestRunner"); + } + return new String(decodedBytes); } - return new String(decodedBytes); } } From 7825bbfeb09bf7d2ff048a343eb4dff39bcd20dd Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 18 Feb 2026 22:28:57 -0700 Subject: [PATCH 41/44] refactor(deserializer): use Utils.base64Decode instead of duplicate Remove duplicate base64Decode implementation from FlagConfigResponseDeserializer and use the centralized version from Utils via static import. --- .../adapters/FlagConfigResponseDeserializer.java | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java b/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java index b49d3b5d..49046dc3 100644 --- a/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java +++ b/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java @@ -1,5 +1,7 @@ package cloud.eppo.ufc.dto.adapters; +import static cloud.eppo.Utils.base64Decode; + import cloud.eppo.api.EppoValue; import cloud.eppo.api.dto.Allocation; import cloud.eppo.api.dto.BanditFlagVariation; @@ -271,17 +273,4 @@ private static Date parseUtcISODateNode(JsonNode isoDateStringElement) { return result; } - - private static String base64Decode(String input) { - if (input == null) { - return null; - } - byte[] decodedBytes = Base64.getDecoder().decode(input); - if (decodedBytes.length == 0 && !input.isEmpty()) { - throw new RuntimeException( - "zero byte output from Base64; if not running on Android hardware be sure to use" - + " RobolectricTestRunner"); - } - return new String(decodedBytes); - } } From 9c0bc75deaa17187e599ab0608b41b1abbe15b36 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 18 Feb 2026 22:29:02 -0700 Subject: [PATCH 42/44] test(utils): add tests for Base64Codec pluggability Add tests to verify Base64 codec pluggability: - testCustomBase64Codec: verifies custom codec injection works - testBase64EncodeDecodeDefault: verifies default codec behavior - @AfterEach reset method ensures test isolation --- src/test/java/cloud/eppo/UtilsTest.java | 81 +++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/test/java/cloud/eppo/UtilsTest.java b/src/test/java/cloud/eppo/UtilsTest.java index 3de55e29..5e25e329 100644 --- a/src/test/java/cloud/eppo/UtilsTest.java +++ b/src/test/java/cloud/eppo/UtilsTest.java @@ -10,9 +10,42 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; public class UtilsTest { + + @AfterEach + void resetBase64Codec() { + // Reset to default codec to ensure test isolation + Utils.setBase64Codec( + new Utils.Base64Codec() { + @Override + public String base64Encode(String input) { + if (input == null) { + return null; + } + return new String( + java.util.Base64.getEncoder() + .encode(input.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } + + @Override + public String base64Decode(String input) { + if (input == null) { + return null; + } + byte[] decodedBytes = java.util.Base64.getDecoder().decode(input); + if (decodedBytes.length == 0 && !input.isEmpty()) { + throw new RuntimeException( + "zero byte output from Base64; if not running on Android hardware be sure to use" + + " RobolectricTestRunner"); + } + return new String(decodedBytes); + } + }); + } + @Test public void testGetMd5Hash() { // empty string @@ -149,4 +182,52 @@ public void testDateFormattingThreadSafety() throws InterruptedException { assertFalse(collisionDetected.get(), failureMessage); assertEquals(0, unexpectedExceptions.get(), failureMessage); } + + @Test + void testCustomBase64Codec() { + AtomicBoolean encodeCalled = new AtomicBoolean(false); + AtomicBoolean decodeCalled = new AtomicBoolean(false); + + Utils.Base64Codec customCodec = + new Utils.Base64Codec() { + @Override + public String base64Encode(String input) { + encodeCalled.set(true); + return "encoded:" + input; + } + + @Override + public String base64Decode(String input) { + decodeCalled.set(true); + return "decoded:" + input; + } + }; + + Utils.setBase64Codec(customCodec); + + assertEquals("encoded:test", Utils.base64Encode("test")); + assertTrue(encodeCalled.get()); + + assertEquals("decoded:test", Utils.base64Decode("test")); + assertTrue(decodeCalled.get()); + } + + @Test + void testBase64EncodeDecodeDefault() { + // Test null handling + assertNull(Utils.base64Encode(null)); + assertNull(Utils.base64Decode(null)); + + // Test encoding + String original = "Hello, World!"; + String encoded = Utils.base64Encode(original); + assertEquals("SGVsbG8sIFdvcmxkIQ==", encoded); + + // Test decoding + String decoded = Utils.base64Decode(encoded); + assertEquals(original, decoded); + + // Test round-trip + assertEquals(original, Utils.base64Decode(Utils.base64Encode(original))); + } } From 0b8c5fe6e1fc5044ff526db696eb2bb3ff33414b Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 18 Feb 2026 23:04:09 -0700 Subject: [PATCH 43/44] fix(utils): add volatile, null check, reset method, and UTF-8 charset - Add volatile keyword to base64Codec field for thread safety - Add null check in setBase64Codec() throwing IllegalArgumentException - Add resetBase64Codec() method for test cleanup - Use explicit StandardCharsets.UTF_8 in base64Decode - Add test for null codec rejection - Simplify test reset to use resetBase64Codec method --- src/main/java/cloud/eppo/Utils.java | 15 +++++++++-- src/test/java/cloud/eppo/UtilsTest.java | 35 +++++-------------------- 2 files changed, 20 insertions(+), 30 deletions(-) diff --git a/src/main/java/cloud/eppo/Utils.java b/src/main/java/cloud/eppo/Utils.java index 81422190..1e2d81a3 100644 --- a/src/main/java/cloud/eppo/Utils.java +++ b/src/main/java/cloud/eppo/Utils.java @@ -14,7 +14,7 @@ public final class Utils { private static final ThreadLocal UTC_ISO_DATE_FORMAT = buildUtcIsoDateFormat(); private static final Logger log = LoggerFactory.getLogger(Utils.class); private static final ThreadLocal md = buildMd5MessageDigest(); - private static Base64Codec base64Codec = new DefaultBase64Codec(); + private static volatile Base64Codec base64Codec = new DefaultBase64Codec(); /** Interface for Base64 encoding/decoding operations. */ public interface Base64Codec { @@ -28,11 +28,22 @@ public interface Base64Codec { * platform-specific implementations (e.g., Android SDK using android.util.Base64). * * @param codec the Base64 codec implementation to use + * @throws IllegalArgumentException if codec is null */ public static void setBase64Codec(Base64Codec codec) { + if (codec == null) { + throw new IllegalArgumentException("Base64Codec cannot be null"); + } base64Codec = codec; } + /** + * Resets the Base64 codec to the default implementation. Primarily intended for testing purposes. + */ + public static void resetBase64Codec() { + base64Codec = new DefaultBase64Codec(); + } + @SuppressWarnings("AnonymousHasLambdaAlternative") private static ThreadLocal buildMd5MessageDigest() { return new ThreadLocal() { @@ -140,7 +151,7 @@ public String base64Decode(String input) { "zero byte output from Base64; if not running on Android hardware be sure to use" + " RobolectricTestRunner"); } - return new String(decodedBytes); + return new String(decodedBytes, StandardCharsets.UTF_8); } } } diff --git a/src/test/java/cloud/eppo/UtilsTest.java b/src/test/java/cloud/eppo/UtilsTest.java index 5e25e329..82e79f3a 100644 --- a/src/test/java/cloud/eppo/UtilsTest.java +++ b/src/test/java/cloud/eppo/UtilsTest.java @@ -16,34 +16,8 @@ public class UtilsTest { @AfterEach - void resetBase64Codec() { - // Reset to default codec to ensure test isolation - Utils.setBase64Codec( - new Utils.Base64Codec() { - @Override - public String base64Encode(String input) { - if (input == null) { - return null; - } - return new String( - java.util.Base64.getEncoder() - .encode(input.getBytes(java.nio.charset.StandardCharsets.UTF_8))); - } - - @Override - public String base64Decode(String input) { - if (input == null) { - return null; - } - byte[] decodedBytes = java.util.Base64.getDecoder().decode(input); - if (decodedBytes.length == 0 && !input.isEmpty()) { - throw new RuntimeException( - "zero byte output from Base64; if not running on Android hardware be sure to use" - + " RobolectricTestRunner"); - } - return new String(decodedBytes); - } - }); + void resetCodec() { + Utils.resetBase64Codec(); } @Test @@ -230,4 +204,9 @@ void testBase64EncodeDecodeDefault() { // Test round-trip assertEquals(original, Utils.base64Decode(Utils.base64Encode(original))); } + + @Test + void testSetBase64CodecWithNullThrowsException() { + assertThrows(IllegalArgumentException.class, () -> Utils.setBase64Codec(null)); + } } From 9e4d6e06436cd351e931475c9fc0f9d0f7022255 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 18 Feb 2026 23:09:51 -0700 Subject: [PATCH 44/44] make resetCodec package private --- src/main/java/cloud/eppo/Utils.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/cloud/eppo/Utils.java b/src/main/java/cloud/eppo/Utils.java index 1e2d81a3..d3aff926 100644 --- a/src/main/java/cloud/eppo/Utils.java +++ b/src/main/java/cloud/eppo/Utils.java @@ -38,9 +38,10 @@ public static void setBase64Codec(Base64Codec codec) { } /** - * Resets the Base64 codec to the default implementation. Primarily intended for testing purposes. + * Resets the Base64 codec to the default implementation. Package-private: intended for testing + * purposes only. */ - public static void resetBase64Codec() { + static void resetBase64Codec() { base64Codec = new DefaultBase64Codec(); }