diff --git a/.gitignore b/.gitignore index 83583d1..889ff0c 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ bin/ ### Mac OS ### .DS_Store .idea/ +.planning/ diff --git a/README.md b/README.md index 81da6b8..9fd32ef 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ```groovy dependencies { - implementation 'cloud.eppo:eppo-server-sdk:5.3.3' + implementation 'cloud.eppo:eppo-server-sdk:6.0.0' } ``` @@ -54,10 +54,10 @@ Snapshots are published automatically after each push to `main` branch. ```groovy repositories { - maven { url "https://central.sonatype.com/repository/maven-snapshot" } + maven { url "https://central.sonatype.com/repository/maven-snapshots/" } } dependencies { - implementation 'cloud.eppo:eppo-server-sdk:4.0.1-SNAPSHOT' + implementation 'cloud.eppo:eppo-server-sdk:6.0.0-SNAPSHOT' } ``` diff --git a/build.gradle b/build.gradle index ca327e9..9400737 100644 --- a/build.gradle +++ b/build.gradle @@ -11,7 +11,7 @@ java { } group = 'cloud.eppo' -version = '5.3.4' +version = '6.0.0-SNAPSHOT' ext.isReleaseVersion = !version.endsWith("SNAPSHOT") import org.apache.tools.ant.filters.ReplaceTokens @@ -26,11 +26,11 @@ processResources { repositories { mavenCentral() mavenLocal() - maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots/' } + maven { url 'https://central.sonatype.com/repository/maven-snapshots/' } } dependencies { - api 'cloud.eppo:sdk-common-jvm:3.13.2' + api 'cloud.eppo:sdk-common-jvm:4.0.0-SNAPSHOT' implementation 'com.github.zafarkhaja:java-semver:0.10.2' implementation 'com.fasterxml.jackson.core:jackson-databind:2.20.1' @@ -39,8 +39,8 @@ dependencies { implementation 'org.jetbrains:annotations:26.0.2' // Logback classic 1.3.x is compatible with java 8 - only needed for tests + testImplementation 'cloud.eppo:eppo-sdk-framework:0.1.0-SNAPSHOT:tests' testImplementation 'ch.qos.logback:logback-classic:1.3.16' - testImplementation 'cloud.eppo:sdk-common-jvm:3.5.4:tests' testImplementation platform('org.junit:junit-bom:5.11.4') testImplementation 'org.junit.jupiter:junit-jupiter' testImplementation 'com.github.tomakehurst:wiremock-jre8:2.35.2' @@ -63,6 +63,7 @@ test { spotless { ratchetFrom 'origin/main' + format 'misc', { // define the files to apply `misc` to target '*.gradle', '.gitattributes', '.gitignore' @@ -129,8 +130,6 @@ publishing { } } } - - jreleaser { signing { active = 'ALWAYS' @@ -161,8 +160,6 @@ jreleaser { } } } - - // Custom task to ensure we can conditionally publish either a release or snapshot artifact // based on a command line switch. See github workflow files for more details on usage. tasks.register('checkVersion') { diff --git a/src/main/java/cloud/eppo/EppoClient.java b/src/main/java/cloud/eppo/EppoClient.java index 8e351f4..b99c473 100644 --- a/src/main/java/cloud/eppo/EppoClient.java +++ b/src/main/java/cloud/eppo/EppoClient.java @@ -6,6 +6,7 @@ import cloud.eppo.cache.LRUInMemoryAssignmentCache; import cloud.eppo.logging.AssignmentLogger; import cloud.eppo.logging.BanditLogger; +import com.fasterxml.jackson.databind.JsonNode; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import org.jetbrains.annotations.NotNull; @@ -19,7 +20,7 @@ * buildAndInit() method. Then call getInstance() to access the singleton and call methods to get * assignments and bandit actions. */ -public class EppoClient extends BaseEppoClient { +public class EppoClient extends BaseEppoClient { private static final Logger log = LoggerFactory.getLogger(EppoClient.class); private static final boolean DEFAULT_IS_GRACEFUL_MODE = true; @@ -50,17 +51,18 @@ private EppoClient( sdkKey, sdkName, sdkVersion, - null, baseUrl, assignmentLogger, banditLogger, - null, + new MemoryOnlyConfigurationStore(), isGracefulMode, false, true, null, assignmentCache, - banditAssignmentCache); + banditAssignmentCache, + new JacksonConfigurationParser(), + new OkHttpEppoClient()); } /** diff --git a/src/test/java/cloud/eppo/EppoClientTest.java b/src/test/java/cloud/eppo/EppoClientTest.java index 2e0ab67..85e0d55 100644 --- a/src/test/java/cloud/eppo/EppoClientTest.java +++ b/src/test/java/cloud/eppo/EppoClientTest.java @@ -12,23 +12,21 @@ import cloud.eppo.api.BanditActions; import cloud.eppo.api.BanditResult; import cloud.eppo.api.Configuration; +import cloud.eppo.api.dto.VariationType; import cloud.eppo.helpers.AssignmentTestCase; import cloud.eppo.helpers.BanditTestCase; -import cloud.eppo.helpers.TestUtils; import cloud.eppo.logging.Assignment; import cloud.eppo.logging.AssignmentLogger; import cloud.eppo.logging.BanditAssignment; import cloud.eppo.logging.BanditLogger; -import cloud.eppo.ufc.dto.VariationType; import com.github.tomakehurst.wiremock.WireMockServer; import com.github.tomakehurst.wiremock.client.WireMock; import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import com.github.tomakehurst.wiremock.stubbing.Scenario; import java.io.File; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; import java.util.stream.Stream; import org.apache.commons.io.FileUtils; import org.junit.jupiter.api.AfterAll; @@ -60,7 +58,10 @@ public class EppoClientTest { public static void initMockServer() { mockServer = new WireMockServer(TEST_PORT); mockServer.start(); + registerDefaultStubs(); + } + private static void registerDefaultStubs() { // If we get the dummy flag API key, return flags-v1.json String ufcFlagsResponseJson = readConfig("src/test/resources/shared/ufc/flags-v1.json"); mockServer.stubFor( @@ -97,12 +98,13 @@ private static String readConfig(String jsonToReturnFilePath) { @AfterEach public void cleanUp() { - TestUtils.setBaseClientHttpClientOverrideField(null); try { EppoClient.getInstance().stopPolling(); } catch (IllegalStateException ex) { // pass: Indicates that the singleton Eppo Client has not yet been initialized. } + mockServer.resetAll(); + registerDefaultStubs(); } @AfterAll @@ -224,29 +226,24 @@ public void testReinitializeWitForcing() { @Test public void testPolling() { - EppoHttpClient httpClient = new EppoHttpClient(TEST_HOST, DUMMY_FLAG_API_KEY, "java", "3.0.0"); - EppoHttpClient httpClientSpy = spy(httpClient); - TestUtils.setBaseClientHttpClientOverrideField(httpClientSpy); + // Reset request journal so we can count from zero + mockServer.resetRequests(); EppoClient.builder(DUMMY_FLAG_API_KEY) + .apiBaseUrl(TEST_HOST) .pollingIntervalMs(20) .forceReinitialize(true) .buildAndInit(); - // Method will be called immediately on init - verify(httpClientSpy, times(1)).get(anyString()); - - // Sleep for 25 ms to allow another polling cycle to complete - sleepUninterruptedly(25); + // Wait to allow polling cycles + sleepUninterruptedly(50); - // Now, the method should have been called twice - verify(httpClientSpy, times(2)).get(anyString()); + // Verify multiple requests were made (init + at least one poll) + mockServer.verify( + com.github.tomakehurst.wiremock.client.WireMock.moreThanOrExactly(2), + WireMock.getRequestedFor(WireMock.urlMatching(".*flag-config/v1/config.*"))); EppoClient.getInstance().stopPolling(); - sleepUninterruptedly(25); - - // No more calls since stopped - verify(httpClientSpy, times(2)).get(anyString()); } // NOTE: Graceful mode during init is intrinsically true since the call is non-blocking and @@ -254,7 +251,7 @@ public void testPolling() { @Test public void testClientMakesDefaultAssignmentsAfterFailingToInitialize() { - // Set up bad HTTP response + // Set up bad HTTP response via WireMock mockHttpError(); // Initialize and no exception should be thrown. @@ -277,59 +274,45 @@ public void testGetConfiguration() { } @Test - public void testConfigurationChangeListener() throws ExecutionException, InterruptedException { + public void testConfigurationChangeListener() { List received = new ArrayList<>(); - // Set up a changing response from the "server" - EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); - - // Mock sync get to return empty - when(mockHttpClient.get(anyString())).thenReturn(EMPTY_CONFIG); - - // Mock async get to return empty - when(mockHttpClient.get(anyString())).thenReturn(EMPTY_CONFIG); + // Stub first response: empty config + mockServer.stubFor( + WireMock.get(WireMock.urlMatching(".*flag-config/v1/config.*")) + .inScenario("config-change") + .whenScenarioStateIs(Scenario.STARTED) + .willReturn(WireMock.okJson(new String(EMPTY_CONFIG))) + .willSetStateTo("has-config")); - setBaseClientHttpClientOverrideField(mockHttpClient); + // Stub second response: real config + mockServer.stubFor( + WireMock.get(WireMock.urlMatching(".*flag-config/v1/config.*")) + .inScenario("config-change") + .whenScenarioStateIs("has-config") + .willReturn(WireMock.okJson(new String(BOOL_FLAG_CONFIG)))); - EppoClient.Builder clientBuilder = + EppoClient eppoClient = EppoClient.builder(DUMMY_FLAG_API_KEY) + .apiBaseUrl(TEST_HOST) .forceReinitialize(true) .onConfigurationChange(received::add) - .isGracefulMode(false); + .isGracefulMode(false) + .buildAndInit(); - // Initialize and no exception should be thrown. - EppoClient eppoClient = clientBuilder.buildAndInit(); - - verify(mockHttpClient, times(1)).get(anyString()); assertEquals(1, received.size()); - // Now, return the boolean flag config so that the config has changed. - when(mockHttpClient.get(anyString())).thenReturn(BOOL_FLAG_CONFIG); - - // Trigger a reload of the client eppoClient.loadConfiguration(); - assertEquals(2, received.size()); - - // Reload the client again; the config hasn't changed, but Java doesn't check eTag (yet) - eppoClient.loadConfiguration(); - - assertEquals(3, received.size()); } 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); + mockServer.stubFor( + WireMock.get(WireMock.urlMatching(".*flag-config/v1/config.*")) + .willReturn(WireMock.serverError())); + mockServer.stubFor( + WireMock.get(WireMock.urlMatching(".*flag-config/v1/bandits.*")) + .willReturn(WireMock.serverError())); } @SuppressWarnings("SameParameterValue") @@ -346,7 +329,7 @@ private EppoClient initClient(String apiKey) { mockBanditLogger = mock(BanditLogger.class); return EppoClient.builder(apiKey) - .apiBaseUrl(Constants.appendApiPathToHost(TEST_HOST)) + .apiBaseUrl(TEST_HOST) .assignmentLogger(mockAssignmentLogger) .banditLogger(mockBanditLogger) .isGracefulMode(false) @@ -359,7 +342,7 @@ private EppoClient initFailingGracefulClient(boolean isGracefulMode) { mockBanditLogger = mock(BanditLogger.class); return EppoClient.builder(DUMMY_FLAG_API_KEY) - .apiBaseUrl("blag") + .apiBaseUrl(TEST_HOST) .assignmentLogger(mockAssignmentLogger) .banditLogger(mockBanditLogger) .isGracefulMode(isGracefulMode) @@ -369,9 +352,9 @@ private EppoClient initFailingGracefulClient(boolean isGracefulMode) { private void uninitClient() { try { - Field httpClientOverrideField = EppoClient.class.getDeclaredField("instance"); - httpClientOverrideField.setAccessible(true); - httpClientOverrideField.set(null, null); + Field instanceField = EppoClient.class.getDeclaredField("instance"); + instanceField.setAccessible(true); + instanceField.set(null, null); } catch (NoSuchFieldException | IllegalAccessException e) { throw new RuntimeException(e); } @@ -380,21 +363,21 @@ private void uninitClient() { private void initBuggyClient() { try { EppoClient eppoClient = initClient(DUMMY_FLAG_API_KEY); + + // Create a mock IConfigurationStore that returns a mock Configuration. + // The mock Configuration throws on getFlag() to simulate evaluation errors, + // but returns null for getEnvironmentName()/getConfigFetchedAt()/getConfigPublishedAt() + // so the catch block in BaseEppoClient can build error details. + Configuration mockConfig = mock(Configuration.class); + when(mockConfig.getFlag(anyString())) + .thenThrow(new RuntimeException("Intentional test error")); + @SuppressWarnings("unchecked") + IConfigurationStore mockStore = mock(IConfigurationStore.class); + when(mockStore.getConfiguration()).thenReturn(mockConfig); + Field configurationStoreField = BaseEppoClient.class.getDeclaredField("configurationStore"); configurationStoreField.setAccessible(true); - configurationStoreField.set(eppoClient, null); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new RuntimeException(e); - } - } - - public static void setBaseClientHttpClientOverrideField(EppoHttpClient httpClient) { - // Uses reflection to set a static override field used for tests (e.g., httpClientOverride) - try { - Field httpClientOverrideField = BaseEppoClient.class.getDeclaredField("httpClientOverride"); - httpClientOverrideField.setAccessible(true); - httpClientOverrideField.set(null, httpClient); - httpClientOverrideField.setAccessible(false); + configurationStoreField.set(eppoClient, mockStore); } catch (NoSuchFieldException | IllegalAccessException e) { throw new RuntimeException(e); }