diff --git a/src/main/java/com/devcycle/sdk/server/cloud/api/DevCycleCloudApiClient.java b/src/main/java/com/devcycle/sdk/server/cloud/api/DevCycleCloudApiClient.java index ef40c75e..d6d055b6 100755 --- a/src/main/java/com/devcycle/sdk/server/cloud/api/DevCycleCloudApiClient.java +++ b/src/main/java/com/devcycle/sdk/server/cloud/api/DevCycleCloudApiClient.java @@ -3,8 +3,8 @@ import com.devcycle.sdk.server.cloud.model.DevCycleCloudOptions; import com.devcycle.sdk.server.common.api.APIUtils; import com.devcycle.sdk.server.common.api.IDevCycleApi; +import com.devcycle.sdk.server.common.api.ObjectMapperUtils; import com.devcycle.sdk.server.common.interceptor.AuthorizationHeaderInterceptor; -import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; import okhttp3.OkHttpClient; import retrofit2.Retrofit; @@ -14,14 +14,13 @@ public final class DevCycleCloudApiClient { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final ObjectMapper OBJECT_MAPPER = ObjectMapperUtils.createDefaultObjectMapper(); private static final String BUCKETING_URL = "https://bucketing-api.devcycle.com/"; private final OkHttpClient.Builder okBuilder; private final Retrofit.Builder adapterBuilder; private String bucketingUrl; public DevCycleCloudApiClient(String apiKey, DevCycleCloudOptions options) { - OBJECT_MAPPER.setSerializationInclusion(JsonInclude.Include.NON_NULL); okBuilder = new OkHttpClient.Builder(); APIUtils.applyRestOptions(options.getRestOptions(), okBuilder); @@ -38,7 +37,7 @@ public DevCycleCloudApiClient(String apiKey, DevCycleCloudOptions options) { adapterBuilder = new Retrofit.Builder() .baseUrl(bucketingUrl) - .addConverterFactory(JacksonConverterFactory.create()); + .addConverterFactory(JacksonConverterFactory.create(OBJECT_MAPPER)); } public IDevCycleApi initialize() { diff --git a/src/main/java/com/devcycle/sdk/server/cloud/api/DevCycleCloudClient.java b/src/main/java/com/devcycle/sdk/server/cloud/api/DevCycleCloudClient.java index 00419714..465803a0 100755 --- a/src/main/java/com/devcycle/sdk/server/cloud/api/DevCycleCloudClient.java +++ b/src/main/java/com/devcycle/sdk/server/cloud/api/DevCycleCloudClient.java @@ -3,6 +3,7 @@ import com.devcycle.sdk.server.cloud.model.DevCycleCloudOptions; import com.devcycle.sdk.server.common.api.IDevCycleApi; import com.devcycle.sdk.server.common.api.IDevCycleClient; +import com.devcycle.sdk.server.common.api.ObjectMapperUtils; import com.devcycle.sdk.server.common.exception.AfterHookError; import com.devcycle.sdk.server.common.exception.BeforeHookError; import com.devcycle.sdk.server.common.exception.DevCycleException; @@ -10,7 +11,6 @@ import com.devcycle.sdk.server.common.model.*; import com.devcycle.sdk.server.common.model.Variable.TypeEnum; import com.devcycle.sdk.server.openfeature.DevCycleProvider; -import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.exc.MismatchedInputException; @@ -23,7 +23,7 @@ public final class DevCycleCloudClient implements IDevCycleClient { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final ObjectMapper OBJECT_MAPPER = ObjectMapperUtils.createDefaultObjectMapper(); private final IDevCycleApi api; private final DevCycleCloudOptions dvcOptions; private final DevCycleProvider openFeatureProvider; @@ -48,7 +48,6 @@ public DevCycleCloudClient(String sdkKey, DevCycleCloudOptions options) { this.dvcOptions = options; api = new DevCycleCloudApiClient(sdkKey, options).initialize(); - OBJECT_MAPPER.setSerializationInclusion(JsonInclude.Include.NON_NULL); this.openFeatureProvider = new DevCycleProvider(this); this.evalHooksRunner = new EvalHooksRunner(dvcOptions.getHooks()); @@ -112,7 +111,7 @@ public Variable variable(DevCycleUser user, String key, T defaultValue) { TypeEnum variableType = TypeEnum.fromClass(defaultValue.getClass()); Variable variable = null; - HookContext context = new HookContext(user, key, defaultValue); + HookContext context = new HookContext(user, key, defaultValue, null); ArrayList> hooks = new ArrayList>(evalHooksRunner.getHooks()); ArrayList> reversedHooks = new ArrayList<>(hooks); Collections.reverse(reversedHooks); diff --git a/src/main/java/com/devcycle/sdk/server/common/api/ObjectMapperUtils.java b/src/main/java/com/devcycle/sdk/server/common/api/ObjectMapperUtils.java new file mode 100644 index 00000000..4bae0497 --- /dev/null +++ b/src/main/java/com/devcycle/sdk/server/common/api/ObjectMapperUtils.java @@ -0,0 +1,48 @@ +package com.devcycle.sdk.server.common.api; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +/** + * Utility class for providing pre-configured ObjectMapper instances + * with consistent settings across the DevCycle SDK. + */ +public class ObjectMapperUtils { + + /** + * Creates a new ObjectMapper with DevCycle SDK default configuration: + * - Ignores unknown properties during deserialization + * - Excludes null values from serialization + * - Uses consistent date/time formatting + * + * @return A pre-configured ObjectMapper instance + */ + public static ObjectMapper createDefaultObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + + // Ignore unknown properties to handle API changes gracefully + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + // Don't include null values in JSON output + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + + return mapper; + } + + /** + * Creates an ObjectMapper specifically configured for event processing + * with additional date formatting settings. + * + * @return A pre-configured ObjectMapper for events + */ + public static ObjectMapper createEventObjectMapper() { + ObjectMapper mapper = createDefaultObjectMapper(); + + // Disable timestamp serialization for events + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + return mapper; + } +} \ No newline at end of file diff --git a/src/main/java/com/devcycle/sdk/server/common/model/HookContext.java b/src/main/java/com/devcycle/sdk/server/common/model/HookContext.java index 320a71f6..4a5a82e1 100644 --- a/src/main/java/com/devcycle/sdk/server/common/model/HookContext.java +++ b/src/main/java/com/devcycle/sdk/server/common/model/HookContext.java @@ -2,6 +2,8 @@ import java.util.Map; +import com.devcycle.sdk.server.local.model.ConfigMetadata; + /** * Context object passed to hooks during variable evaluation. * Contains the user, variable key, default value, and additional context data. @@ -10,19 +12,22 @@ public class HookContext { private DevCycleUser user; private final String key; private final T defaultValue; + private final ConfigMetadata metadata; private Variable variableDetails; - public HookContext(DevCycleUser user, String key, T defaultValue) { + public HookContext(DevCycleUser user, String key, T defaultValue, ConfigMetadata metadata) { this.user = user; this.key = key; this.defaultValue = defaultValue; + this.metadata = metadata; } - public HookContext(DevCycleUser user, String key, T defaultValue, Variable variable) { + public HookContext(DevCycleUser user, String key, T defaultValue, Variable variable, ConfigMetadata metadata) { this.user = user; this.key = key; this.defaultValue = defaultValue; this.variableDetails = variable; + this.metadata = metadata; } public DevCycleUser getUser() { @@ -39,10 +44,14 @@ public T getDefaultValue() { public Variable getVariableDetails() { return variableDetails; } + public ConfigMetadata getMetadata() { + return metadata; + } + public HookContext merge(HookContext other) { if (other == null) { return this; } - return new HookContext<>(other.getUser(), key, defaultValue, variableDetails); + return new HookContext<>(other.getUser(), key, defaultValue, variableDetails, metadata); } } \ No newline at end of file diff --git a/src/main/java/com/devcycle/sdk/server/common/model/ProjectConfig.java b/src/main/java/com/devcycle/sdk/server/common/model/ProjectConfig.java index 9e964587..9de72f50 100644 --- a/src/main/java/com/devcycle/sdk/server/common/model/ProjectConfig.java +++ b/src/main/java/com/devcycle/sdk/server/common/model/ProjectConfig.java @@ -1,6 +1,9 @@ package com.devcycle.sdk.server.common.model; +import com.devcycle.sdk.server.local.model.Environment; +import com.devcycle.sdk.server.local.model.Project; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; @@ -15,10 +18,12 @@ public class ProjectConfig { @Schema(description = "Project Settings") - private Object project; + @JsonProperty("project") + private Project project; @Schema(description = "Environment Key & ID") - private Object environment; + @JsonProperty("environment") + private Environment environment; @Schema(description = "List of Features in this Project") private Object[] features; @@ -34,5 +39,13 @@ public class ProjectConfig { @Schema(description = "SSE Configuration") private SSE sse; + + public Project getProject() { + return project; + } + + public Environment getEnvironment() { + return environment; + } } diff --git a/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalApiClient.java b/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalApiClient.java index 068f6cac..81944824 100755 --- a/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalApiClient.java +++ b/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalApiClient.java @@ -2,8 +2,8 @@ import com.devcycle.sdk.server.common.api.APIUtils; import com.devcycle.sdk.server.common.api.IDevCycleApi; +import com.devcycle.sdk.server.common.api.ObjectMapperUtils; import com.devcycle.sdk.server.local.model.DevCycleLocalOptions; -import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; import okhttp3.OkHttpClient; import retrofit2.Retrofit; @@ -14,7 +14,7 @@ public final class DevCycleLocalApiClient { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final ObjectMapper OBJECT_MAPPER = ObjectMapperUtils.createDefaultObjectMapper(); private static final String CONFIG_URL = "https://config-cdn.devcycle.com/"; private static final int DEFAULT_TIMEOUT_MS = 10000; private static final int MIN_INTERVALS_MS = 1000; @@ -25,7 +25,6 @@ public final class DevCycleLocalApiClient { private DevCycleLocalApiClient(DevCycleLocalOptions options) { - OBJECT_MAPPER.setSerializationInclusion(JsonInclude.Include.NON_NULL); okBuilder = new OkHttpClient.Builder(); APIUtils.applyRestOptions(options.getRestOptions(), okBuilder); @@ -42,7 +41,7 @@ private DevCycleLocalApiClient(DevCycleLocalOptions options) { adapterBuilder = new Retrofit.Builder() .baseUrl(configUrl) - .addConverterFactory(JacksonConverterFactory.create()); + .addConverterFactory(JacksonConverterFactory.create(OBJECT_MAPPER)); } public DevCycleLocalApiClient(String sdkKey, DevCycleLocalOptions options) { diff --git a/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java b/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java index 236967ab..f9c2e05b 100755 --- a/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java +++ b/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java @@ -9,6 +9,7 @@ import com.devcycle.sdk.server.local.managers.EnvironmentConfigManager; import com.devcycle.sdk.server.local.managers.EventQueueManager; import com.devcycle.sdk.server.local.model.BucketedUserConfig; +import com.devcycle.sdk.server.local.model.ConfigMetadata; import com.devcycle.sdk.server.local.model.DevCycleLocalOptions; import com.devcycle.sdk.server.local.protobuf.SDKVariable_PB; import com.devcycle.sdk.server.local.protobuf.VariableForUserParams_PB; @@ -28,6 +29,7 @@ public final class DevCycleLocalClient implements IDevCycleClient { private final EnvironmentConfigManager configManager; private EventQueueManager eventQueueManager; private final String clientUUID; + // raw type here is okay because we're using a generic type for the variable private EvalHooksRunner evalHooksRunner; public DevCycleLocalClient(String sdkKey) { @@ -156,7 +158,7 @@ public Variable variable(DevCycleUser user, String key, T defaultValue) { .setShouldTrackEvent(true) .build(); - HookContext hookContext = new HookContext(user, key, defaultValue); + HookContext hookContext = new HookContext(user, key, defaultValue, getMetadata()); Variable variable = null; ArrayList> hooks = new ArrayList>(evalHooksRunner.getHooks()); ArrayList> reversedHooks = new ArrayList>(evalHooksRunner.getHooks()); @@ -192,14 +194,20 @@ public Variable variable(DevCycleUser user, String key, T defaultValue) { if (!(e instanceof BeforeHookError)) { DevCycleLogger.error("Unable to evaluate Variable " + key + " due to error: " + e, e); } - evalHooksRunner.executeError(reversedHooks, hookContext, e); + // For BeforeHookError, pass the original cause to error hooks, not the wrapper + Throwable errorToPass = (e instanceof BeforeHookError && e.getCause() != null) ? e.getCause() : e; + evalHooksRunner.executeError(reversedHooks, hookContext, errorToPass); } finally { if (variable == null) { variable = defaultVariable; } evalHooksRunner.executeFinally(reversedHooks, hookContext, Optional.of(variable)); - return variable; } + return variable; + } + + public ConfigMetadata getMetadata() { + return configManager.getConfigMetadata(); } diff --git a/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalEventsApiClient.java b/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalEventsApiClient.java index 343aec88..77b183f3 100644 --- a/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalEventsApiClient.java +++ b/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalEventsApiClient.java @@ -2,9 +2,9 @@ import com.devcycle.sdk.server.common.api.APIUtils; import com.devcycle.sdk.server.common.api.IDevCycleApi; +import com.devcycle.sdk.server.common.api.ObjectMapperUtils; import com.devcycle.sdk.server.common.interceptor.AuthorizationHeaderInterceptor; import com.devcycle.sdk.server.local.model.DevCycleLocalOptions; -import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; import okhttp3.OkHttpClient; import retrofit2.Retrofit; @@ -14,14 +14,13 @@ public final class DevCycleLocalEventsApiClient { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final ObjectMapper OBJECT_MAPPER = ObjectMapperUtils.createEventObjectMapper(); private static final String EVENTS_API_URL = "https://events.devcycle.com/"; private final OkHttpClient.Builder okBuilder; private final Retrofit.Builder adapterBuilder; private String eventsApiUrl; public DevCycleLocalEventsApiClient(String sdkKey, DevCycleLocalOptions options) { - OBJECT_MAPPER.setSerializationInclusion(JsonInclude.Include.NON_NULL); okBuilder = new OkHttpClient.Builder(); APIUtils.applyRestOptions(options.getRestOptions(), okBuilder); @@ -35,9 +34,7 @@ public DevCycleLocalEventsApiClient(String sdkKey, DevCycleLocalOptions options) adapterBuilder = new Retrofit.Builder() .baseUrl(eventsApiUrl) - .addConverterFactory(JacksonConverterFactory.create()); - - + .addConverterFactory(JacksonConverterFactory.create(OBJECT_MAPPER)); } public IDevCycleApi initialize() { diff --git a/src/main/java/com/devcycle/sdk/server/local/managers/EnvironmentConfigManager.java b/src/main/java/com/devcycle/sdk/server/local/managers/EnvironmentConfigManager.java index 5460cad1..2dafbc3f 100644 --- a/src/main/java/com/devcycle/sdk/server/local/managers/EnvironmentConfigManager.java +++ b/src/main/java/com/devcycle/sdk/server/local/managers/EnvironmentConfigManager.java @@ -1,11 +1,13 @@ package com.devcycle.sdk.server.local.managers; import com.devcycle.sdk.server.common.api.IDevCycleApi; +import com.devcycle.sdk.server.common.api.ObjectMapperUtils; import com.devcycle.sdk.server.common.exception.DevCycleException; import com.devcycle.sdk.server.common.logging.DevCycleLogger; import com.devcycle.sdk.server.common.model.*; import com.devcycle.sdk.server.local.api.DevCycleLocalApiClient; import com.devcycle.sdk.server.local.bucketing.LocalBucketing; +import com.devcycle.sdk.server.local.model.ConfigMetadata; import com.devcycle.sdk.server.local.model.DevCycleLocalOptions; import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.core.JsonProcessingException; @@ -26,7 +28,7 @@ import java.util.concurrent.TimeUnit; public final class EnvironmentConfigManager { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final ObjectMapper OBJECT_MAPPER = ObjectMapperUtils.createDefaultObjectMapper(); private static final int DEFAULT_POLL_INTERVAL_MS = 30000; private static final int MIN_INTERVALS_MS = 1000; private ScheduledExecutorService scheduler; @@ -37,8 +39,7 @@ public final class EnvironmentConfigManager { private final DevCycleLocalOptions options; private ProjectConfig config; - private String configETag = ""; - private String configLastModified = ""; + private ConfigMetadata configMetadata; private final String sdkKey; private final int pollingIntervalMS; @@ -71,7 +72,9 @@ public void run() { getConfig(); } } catch (DevCycleException e) { - DevCycleLogger.error("Failed to load config: " + e.getMessage()); + DevCycleLogger.error("Failed to load config: " + e.getMessage(), e); + } catch (Exception e) { + DevCycleLogger.error("Unexpected error during config fetch: " + e.getMessage(), e); } } }; @@ -80,10 +83,20 @@ public boolean isConfigInitialized() { return config != null; } - private ProjectConfig getConfig() throws DevCycleException { - Call config = this.configApiClient.getConfig(this.sdkKey, this.configETag, this.configLastModified); - this.config = getResponseWithRetries(config, 1); - if (!this.options.isDisableRealtimeUpdates()) { + private ProjectConfig getConfig() throws DevCycleException { + // Handle initial request where configMetadata might be null + String etag = null; + String lastModified = null; + if (this.configMetadata != null) { + etag = this.configMetadata.configETag; + lastModified = this.configMetadata.configLastModified; + } + + Call config = this.configApiClient.getConfig(this.sdkKey, etag, lastModified); + ProjectConfig fetchedConfig = getResponseWithRetries(config, 1); + this.config = fetchedConfig; + + if (!this.options.isDisableRealtimeUpdates() && this.config != null && this.config.getSse() != null) { try { URI uri = new URI(this.config.getSse().getHostname() + this.config.getSse().getPath()); if (sseManager == null) { @@ -191,13 +204,16 @@ private ProjectConfig getConfigResponse(Call call) throws DevCycl String currentETag = response.headers().get("ETag"); String headerLastModified = response.headers().get("Last-Modified"); - if (!this.configLastModified.isEmpty() && headerLastModified != null && !headerLastModified.isEmpty()) { + // Check if we should skip this config due to older timestamp (only if configMetadata exists) + if (this.configMetadata != null && + !this.configMetadata.configLastModified.isEmpty() && + headerLastModified != null && !headerLastModified.isEmpty()) { ZonedDateTime parsedLastModified = ZonedDateTime.parse( headerLastModified, DateTimeFormatter.RFC_1123_DATE_TIME ); ZonedDateTime configLastModified = ZonedDateTime.parse( - this.configLastModified, + this.configMetadata.configLastModified, DateTimeFormatter.RFC_1123_DATE_TIME ); @@ -209,22 +225,27 @@ private ProjectConfig getConfigResponse(Call call) throws DevCycl ProjectConfig config = response.body(); try { - ObjectMapper mapper = new ObjectMapper(); + ObjectMapper mapper = ObjectMapperUtils.createDefaultObjectMapper(); localBucketing.storeConfig(sdkKey, mapper.writeValueAsString(config)); } catch (JsonProcessingException e) { if (this.config != null) { - DevCycleLogger.error("Unable to parse config with etag: " + currentETag + ". Using cache, etag " + this.configETag + " last-modified: " + this.configLastModified); + String currentConfigInfo = (this.configMetadata != null) ? + " etag " + this.configMetadata.configETag + " last-modified: " + this.configMetadata.configLastModified : + ""; + DevCycleLogger.error("Unable to parse config with etag: " + currentETag + ". Using cache," + currentConfigInfo); return this.config; } else { errorResponse.setMessage(e.getMessage()); throw new DevCycleException(HttpResponseCode.SERVER_ERROR, errorResponse); } } - this.configETag = currentETag; - this.configLastModified = headerLastModified; + this.configMetadata = new ConfigMetadata(currentETag, headerLastModified, config.getProject(), config.getEnvironment()); return response.body(); } else if (httpResponseCode == HttpResponseCode.NOT_MODIFIED) { - DevCycleLogger.debug("Config not modified, using cache, etag: " + this.configETag + " last-modified: " + this.configLastModified); + String cacheInfo = (this.configMetadata != null) ? + " etag: " + this.configMetadata.configETag + " last-modified: " + this.configMetadata.configLastModified : + " (no metadata available)"; + DevCycleLogger.debug("Config not modified, using cache," + cacheInfo); return this.config; } else { if (response.errorBody() != null) { @@ -268,4 +289,8 @@ public void cleanup() { } stopPolling(); } + + public ConfigMetadata getConfigMetadata() { + return configMetadata; + } } \ No newline at end of file diff --git a/src/main/java/com/devcycle/sdk/server/local/model/ConfigMetadata.java b/src/main/java/com/devcycle/sdk/server/local/model/ConfigMetadata.java new file mode 100644 index 00000000..120474b2 --- /dev/null +++ b/src/main/java/com/devcycle/sdk/server/local/model/ConfigMetadata.java @@ -0,0 +1,16 @@ +package com.devcycle.sdk.server.local.model; + +public class ConfigMetadata { + + public final String configETag; + public final String configLastModified; + public final ProjectMetadata project; + public final EnvironmentMetadata environment; + + public ConfigMetadata(String currentETag, String headerLastModified, Project project, Environment environment) { + this.configETag = currentETag; + this.configLastModified = headerLastModified; + this.project = new ProjectMetadata(project._id, project.key); + this.environment = new EnvironmentMetadata(environment._id, environment.key); + } +} diff --git a/src/main/java/com/devcycle/sdk/server/local/model/EnvironmentMetadata.java b/src/main/java/com/devcycle/sdk/server/local/model/EnvironmentMetadata.java new file mode 100644 index 00000000..515907cb --- /dev/null +++ b/src/main/java/com/devcycle/sdk/server/local/model/EnvironmentMetadata.java @@ -0,0 +1,11 @@ +package com.devcycle.sdk.server.local.model; + +public class EnvironmentMetadata { + public final String id; + public final String key; + + public EnvironmentMetadata(String id, String key) { + this.id = id; + this.key = key; + } +} diff --git a/src/main/java/com/devcycle/sdk/server/local/model/ProjectMetadata.java b/src/main/java/com/devcycle/sdk/server/local/model/ProjectMetadata.java new file mode 100644 index 00000000..464dc2a9 --- /dev/null +++ b/src/main/java/com/devcycle/sdk/server/local/model/ProjectMetadata.java @@ -0,0 +1,11 @@ +package com.devcycle.sdk.server.local.model; + +public class ProjectMetadata { + public final String id; + public final String key; + + public ProjectMetadata(String id, String key) { + this.id = id; + this.key = key; + } +} diff --git a/src/test/java/com/devcycle/sdk/server/cloud/DevCycleCloudClientTest.java b/src/test/java/com/devcycle/sdk/server/cloud/DevCycleCloudClientTest.java index 307ac0da..3c28b336 100755 --- a/src/test/java/com/devcycle/sdk/server/cloud/DevCycleCloudClientTest.java +++ b/src/test/java/com/devcycle/sdk/server/cloud/DevCycleCloudClientTest.java @@ -844,4 +844,161 @@ private void assertUserDefaultsCorrect(DevCycleUser user) { Assert.assertEquals(PlatformData.SdkTypeEnum.SERVER, user.getSdkType()); Assert.assertNotNull(user.getPlatformVersion()); } + + @Test + public void variable_withEvalHooks_metadataIsNullInCloudClient() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] metadataChecked = {false}; + + api.addHook(new EvalHook() { + @Override + public void after(HookContext ctx, Variable variable) { + // Cloud client should have null metadata since it doesn't manage local config + Assert.assertNull("Cloud client metadata should be null", ctx.getMetadata()); + metadataChecked[0] = true; + } + }); + + Variable expected = Variable.builder() + .key("test-true") + .value(true) + .type(Variable.TypeEnum.BOOLEAN) + .isDefaulted(false) + .defaultValue(false) + .build(); + + when(apiInterface.getVariableByKey(user, "test-true", dvcOptions.getEnableEdgeDB())).thenReturn(Calls.response(expected)); + + Variable result = api.variable(user, "test-true", false); + + Assert.assertTrue("Metadata check should have been executed", metadataChecked[0]); + Assert.assertEquals(expected, result); + } + + @Test + public void variable_withEvalHooks_metadataConsistentlyNullAcrossHooks() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final Boolean[] metadataWasNull = {null, null, null}; // before, after, finally + + api.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + metadataWasNull[0] = (ctx.getMetadata() == null); + return Optional.empty(); + } + + @Override + public void after(HookContext ctx, Variable variable) { + metadataWasNull[1] = (ctx.getMetadata() == null); + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + metadataWasNull[2] = (ctx.getMetadata() == null); + } + }); + + Variable expected = Variable.builder() + .key("test-true") + .value(true) + .type(Variable.TypeEnum.BOOLEAN) + .isDefaulted(false) + .defaultValue(false) + .build(); + + when(apiInterface.getVariableByKey(user, "test-true", dvcOptions.getEnableEdgeDB())).thenReturn(Calls.response(expected)); + + Variable result = api.variable(user, "test-true", false); + + // Verify all hook stages received null metadata consistently + Assert.assertTrue("Before hook should have null metadata", metadataWasNull[0]); + Assert.assertTrue("After hook should have null metadata", metadataWasNull[1]); + Assert.assertTrue("Finally hook should have null metadata", metadataWasNull[2]); + } + + @Test + public void variable_withEvalHooks_metadataIsNullInErrorHook() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] metadataCheckedInError = {false}; + + api.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + throw new RuntimeException("Test error to trigger error hook"); + } + + @Override + public void error(HookContext ctx, Throwable error) { + // Verify metadata is null even in error hook for cloud client + Assert.assertNull("Cloud client metadata should be null in error hook", ctx.getMetadata()); + metadataCheckedInError[0] = true; + } + }); + + Variable expected = Variable.builder() + .key("test-true") + .value(true) + .type(Variable.TypeEnum.BOOLEAN) + .isDefaulted(false) + .defaultValue(false) + .build(); + + when(apiInterface.getVariableByKey(user, "test-true", dvcOptions.getEnableEdgeDB())).thenReturn(Calls.response(expected)); + + Variable result = api.variable(user, "test-true", false); + + Assert.assertTrue("Metadata should have been checked in error hook", metadataCheckedInError[0]); + Assert.assertNotNull("Variable should not be null even after error", result); + } + + @Test + public void variable_withMultipleHooks_allReceiveNullMetadata() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] metadataChecked = {false, false}; // Two hooks + + // First hook + api.addHook(new EvalHook() { + @Override + public void after(HookContext ctx, Variable variable) { + Assert.assertNull("First hook should receive null metadata in cloud client", ctx.getMetadata()); + metadataChecked[0] = true; + } + }); + + // Second hook + api.addHook(new EvalHook() { + @Override + public void after(HookContext ctx, Variable variable) { + Assert.assertNull("Second hook should receive null metadata in cloud client", ctx.getMetadata()); + metadataChecked[1] = true; + } + }); + + Variable expected = Variable.builder() + .key("test-true") + .value(true) + .type(Variable.TypeEnum.BOOLEAN) + .isDefaulted(false) + .defaultValue(false) + .build(); + + when(apiInterface.getVariableByKey(user, "test-true", dvcOptions.getEnableEdgeDB())).thenReturn(Calls.response(expected)); + + Variable result = api.variable(user, "test-true", false); + + Assert.assertTrue("First hook should have checked metadata", metadataChecked[0]); + Assert.assertTrue("Second hook should have checked metadata", metadataChecked[1]); + } } \ No newline at end of file diff --git a/src/test/java/com/devcycle/sdk/server/cloud/EvalHooksRunnerTest.java b/src/test/java/com/devcycle/sdk/server/cloud/EvalHooksRunnerTest.java index 0917d1f3..503f7f10 100644 --- a/src/test/java/com/devcycle/sdk/server/cloud/EvalHooksRunnerTest.java +++ b/src/test/java/com/devcycle/sdk/server/cloud/EvalHooksRunnerTest.java @@ -29,7 +29,7 @@ public void setup() { @Test public void testBeforeHook() { - HookContext context = new HookContext<>(testUser, "test-key", false); + HookContext context = new HookContext<>(testUser, "test-key", false, null); ArrayList> hooks = new ArrayList<>(); hooks.add(new EvalHook() { @@ -38,7 +38,7 @@ public Optional> before(HookContext ctx) { DevCycleUser modifiedUser = DevCycleUser.builder() .userId("modified-user") .build(); - return Optional.of(new HookContext<>(modifiedUser, ctx.getKey(), ctx.getDefaultValue())); + return Optional.of(new HookContext<>(modifiedUser, ctx.getKey(), ctx.getDefaultValue(), null)); } }); @@ -48,7 +48,7 @@ public Optional> before(HookContext ctx) { @Test(expected = BeforeHookError.class) public void testBeforeHookError() { - HookContext context = new HookContext<>(testUser, "test-key", false); + HookContext context = new HookContext<>(testUser, "test-key", false, null); ArrayList> hooks = new ArrayList<>(); hooks.add(new EvalHook() { @@ -64,7 +64,7 @@ public Optional> before(HookContext ctx) { @Test public void testAfterHook() { final boolean[] hookCalled = {false}; - HookContext context = new HookContext<>(testUser, "test-key", false); + HookContext context = new HookContext<>(testUser, "test-key", false, null); ArrayList> hooks = new ArrayList<>(); hooks.add(new EvalHook() { @@ -81,7 +81,7 @@ public void after(HookContext ctx, Variable variable) { @Test(expected = AfterHookError.class) public void testAfterHookError() { - HookContext context = new HookContext<>(testUser, "test-key", false); + HookContext context = new HookContext<>(testUser, "test-key", false, null); ArrayList> hooks = new ArrayList<>(); hooks.add(new EvalHook() { @@ -97,7 +97,7 @@ public void after(HookContext ctx, Variable variable) { @Test public void testErrorHook() { final boolean[] hookCalled = {false}; - HookContext context = new HookContext<>(testUser, "test-key", false); + HookContext context = new HookContext<>(testUser, "test-key", false, null); Exception testError = new Exception("Test error"); ArrayList> hooks = new ArrayList<>(); @@ -116,7 +116,7 @@ public void error(HookContext ctx, Throwable error) { @Test public void testFinallyHook() { final boolean[] hookCalled = {false}; - HookContext context = new HookContext<>(testUser, "test-key", false); + HookContext context = new HookContext<>(testUser, "test-key", false, null); ArrayList> hooks = new ArrayList<>(); hooks.add(new EvalHook() { @@ -133,7 +133,7 @@ public void onFinally(HookContext ctx, Optional> var @Test public void testClearHooks() { final boolean[] hookCalled = {false}; - HookContext context = new HookContext<>(testUser, "test-key", false); + HookContext context = new HookContext<>(testUser, "test-key", false, null); ArrayList> hooks = new ArrayList<>(); hooks.add(new EvalHook() { @@ -148,4 +148,39 @@ public void after(HookContext ctx, Variable variable) { hookRunner.executeAfter(emptyHooks, context, testVariable); Assert.assertFalse(hookCalled[0]); } + + @Test + public void testMetadataPassedThroughHooks() { + final Object[] capturedMetadata = {null, null, null}; // before, after, finally + HookContext context = new HookContext<>(testUser, "test-key", false, null); + + ArrayList> hooks = new ArrayList<>(); + hooks.add(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + capturedMetadata[0] = ctx.getMetadata(); + return Optional.empty(); + } + + @Override + public void after(HookContext ctx, Variable variable) { + capturedMetadata[1] = ctx.getMetadata(); + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + capturedMetadata[2] = ctx.getMetadata(); + } + }); + + // Execute hooks and verify metadata is consistently passed through + HookContext beforeResult = hookRunner.executeBefore(hooks, context); + hookRunner.executeAfter(hooks, beforeResult, testVariable); + hookRunner.executeFinally(hooks, beforeResult, Optional.of(testVariable)); + + // Verify metadata is consistently null (as passed in the context) + Assert.assertNull("Before hook should receive null metadata", capturedMetadata[0]); + Assert.assertNull("After hook should receive null metadata", capturedMetadata[1]); + Assert.assertNull("Finally hook should receive null metadata", capturedMetadata[2]); + } } diff --git a/src/test/java/com/devcycle/sdk/server/helpers/LocalConfigServer.java b/src/test/java/com/devcycle/sdk/server/helpers/LocalConfigServer.java index 201acb42..382259ed 100644 --- a/src/test/java/com/devcycle/sdk/server/helpers/LocalConfigServer.java +++ b/src/test/java/com/devcycle/sdk/server/helpers/LocalConfigServer.java @@ -7,6 +7,8 @@ import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; public class LocalConfigServer { private final HttpServer server; @@ -26,6 +28,11 @@ public String getHostRootURL() { } public void handleConfigRequest(HttpExchange exchange) throws IOException { + // Add required headers for ConfigMetadata creation + String currentTime = ZonedDateTime.now().format(DateTimeFormatter.RFC_1123_DATE_TIME); + exchange.getResponseHeaders().set("ETag", "\"test-etag-12345\""); + exchange.getResponseHeaders().set("Last-Modified", currentTime); + byte[] responseData = configData.getBytes(StandardCharsets.UTF_8); exchange.sendResponseHeaders(200, responseData.length); OutputStream outputStream = exchange.getResponseBody(); diff --git a/src/test/java/com/devcycle/sdk/server/local/DevCycleLocalClientTest.java b/src/test/java/com/devcycle/sdk/server/local/DevCycleLocalClientTest.java index a098812e..50ae80b6 100644 --- a/src/test/java/com/devcycle/sdk/server/local/DevCycleLocalClientTest.java +++ b/src/test/java/com/devcycle/sdk/server/local/DevCycleLocalClientTest.java @@ -7,9 +7,12 @@ import com.devcycle.sdk.server.common.logging.IDevCycleLogger; import com.devcycle.sdk.server.common.model.*; import com.devcycle.sdk.server.helpers.LocalConfigServer; +import com.devcycle.sdk.server.local.model.Project; +import com.devcycle.sdk.server.local.model.Environment; import com.devcycle.sdk.server.helpers.TestDataFixtures; import com.devcycle.sdk.server.helpers.WhiteBox; import com.devcycle.sdk.server.local.api.DevCycleLocalClient; +import com.devcycle.sdk.server.local.model.ConfigMetadata; import com.devcycle.sdk.server.local.model.DevCycleLocalOptions; import org.junit.AfterClass; import org.junit.Assert; @@ -958,4 +961,219 @@ private DevCycleUser getUser() { .userId("j_test") .build(); } + + @Test + public void variable_withEvalHooks_metadataIsAccessibleInAfterHook() throws DevCycleException { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] metadataChecked = {false}; + + client.addHook(new EvalHook() { + @Override + public void after(HookContext ctx, Variable variable) { + // Verify metadata is accessible and properly populated + Assert.assertNotNull("Metadata should not be null", ctx.getMetadata()); + + // Check that config metadata has the expected structure + ConfigMetadata metadata = ctx.getMetadata(); + Assert.assertNotNull("Config ETag should not be null", metadata.configETag); + Assert.assertNotNull("Config last modified should not be null", metadata.configLastModified); + Assert.assertNotNull("Project metadata should not be null", metadata.project); + Assert.assertNotNull("Environment metadata should not be null", metadata.environment); + + // Verify basic metadata structure is present + Assert.assertFalse("Config ETag should not be empty", metadata.configETag.isEmpty()); + Assert.assertFalse("Config last modified should not be empty", metadata.configLastModified.isEmpty()); + + metadataChecked[0] = true; + } + }); + + Variable result = client.variable(user, "string-var", "default string"); + + Assert.assertTrue("Metadata check should have been executed", metadataChecked[0]); + Assert.assertNotNull("Variable should not be null", result); + } + + @Test + public void variable_withEvalHooks_metadataConsistentAcrossHooks() throws DevCycleException { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final ConfigMetadata[] capturedMetadata = {null, null, null}; // before, after, finally + + client.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + capturedMetadata[0] = ctx.getMetadata(); + return Optional.empty(); + } + + @Override + public void after(HookContext ctx, Variable variable) { + capturedMetadata[1] = ctx.getMetadata(); + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + capturedMetadata[2] = ctx.getMetadata(); + } + }); + + Variable result = client.variable(user, "string-var", "default string"); + + // Verify all hook stages received metadata + Assert.assertNotNull("Before hook should have metadata", capturedMetadata[0]); + Assert.assertNotNull("After hook should have metadata", capturedMetadata[1]); + Assert.assertNotNull("Finally hook should have metadata", capturedMetadata[2]); + + // Verify metadata is consistent across all hook stages + Assert.assertEquals("Before and after metadata should be the same", + capturedMetadata[0].configETag, capturedMetadata[1].configETag); + Assert.assertEquals("Before and finally metadata should be the same", + capturedMetadata[0].configETag, capturedMetadata[2].configETag); + Assert.assertEquals("Metadata timestamps should be consistent", + capturedMetadata[0].configLastModified, capturedMetadata[1].configLastModified); + } + + @Test + public void variable_withEvalHooks_metadataAccessibleInErrorHook() throws DevCycleException { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] metadataCheckedInError = {false}; + + client.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + throw new RuntimeException("Test error to trigger error hook"); + } + + @Override + public void error(HookContext ctx, Throwable error) { + // Verify metadata is accessible even in error hook + Assert.assertNotNull("Metadata should be accessible in error hook", ctx.getMetadata()); + ConfigMetadata metadata = ctx.getMetadata(); + Assert.assertNotNull("Config ETag should not be null in error hook", metadata.configETag); + Assert.assertNotNull("Config last modified should not be null in error hook", metadata.configLastModified); + Assert.assertNotNull("Project metadata should not be null in error hook", metadata.project); + Assert.assertNotNull("Environment metadata should not be null in error hook", metadata.environment); + metadataCheckedInError[0] = true; + } + }); + + Variable result = client.variable(user, "string-var", "default string"); + + Assert.assertTrue("Metadata should have been checked in error hook", metadataCheckedInError[0]); + Assert.assertNotNull("Variable should not be null even after error", result); + } + + @Test + public void variable_withEvalHooks_metadataReflectsCurrentConfig() throws DevCycleException { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final ConfigMetadata[] capturedMetadata = {null}; + + client.addHook(new EvalHook() { + @Override + public void after(HookContext ctx, Variable variable) { + capturedMetadata[0] = ctx.getMetadata(); + } + }); + + Variable result = client.variable(user, "string-var", "default string"); + + Assert.assertNotNull("Metadata should be captured", capturedMetadata[0]); + + // Verify metadata reflects current config state + ConfigMetadata directMetadata = client.getMetadata(); + Assert.assertNotNull("Direct metadata should not be null", directMetadata); + + // The metadata in hooks should match the current client metadata + Assert.assertEquals("Hook metadata ETag should match current metadata", + directMetadata.configETag, capturedMetadata[0].configETag); + Assert.assertEquals("Hook metadata timestamp should match current metadata", + directMetadata.configLastModified, capturedMetadata[0].configLastModified); + Assert.assertEquals("Hook metadata project should match current metadata", + directMetadata.project, capturedMetadata[0].project); + Assert.assertEquals("Hook metadata environment should match current metadata", + directMetadata.environment, capturedMetadata[0].environment); + } + + @Test + public void variable_withMultipleHooks_allReceiveMetadata() throws DevCycleException { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] metadataChecked = {false, false}; // Two hooks + + // First hook + client.addHook(new EvalHook() { + @Override + public void after(HookContext ctx, Variable variable) { + Assert.assertNotNull("First hook should receive metadata", ctx.getMetadata()); + Assert.assertNotNull("First hook metadata should have project", ctx.getMetadata().project); + Assert.assertNotNull("First hook metadata should have config ETag", ctx.getMetadata().configETag); + metadataChecked[0] = true; + } + }); + + // Second hook + client.addHook(new EvalHook() { + @Override + public void after(HookContext ctx, Variable variable) { + Assert.assertNotNull("Second hook should receive metadata", ctx.getMetadata()); + Assert.assertNotNull("Second hook metadata should have environment", ctx.getMetadata().environment); + Assert.assertNotNull("Second hook metadata should have last modified", ctx.getMetadata().configLastModified); + metadataChecked[1] = true; + } + }); + + Variable result = client.variable(user, "string-var", "default string"); + + Assert.assertTrue("First hook should have checked metadata", metadataChecked[0]); + Assert.assertTrue("Second hook should have checked metadata", metadataChecked[1]); + } + + @Test + public void configMetadata_canBeConstructedWithMockData() { + // Create mock project and environment data for testing + Project mockProject = new Project(); + mockProject._id = "mock-project-id"; + mockProject.key = "mock-project-key"; + + Environment mockEnvironment = new Environment(); + mockEnvironment._id = "mock-env-id"; + mockEnvironment.key = "mock-env-key"; + + // Test ConfigMetadata construction + ConfigMetadata metadata = new ConfigMetadata( + "test-etag-12345", + "2023-10-01T12:00:00Z", + mockProject, + mockEnvironment + ); + + // Verify metadata is properly constructed + Assert.assertNotNull("Metadata should not be null", metadata); + Assert.assertEquals("Config ETag should match", "test-etag-12345", metadata.configETag); + Assert.assertEquals("Config last modified should match", "2023-10-01T12:00:00Z", metadata.configLastModified); + Assert.assertNotNull("Project metadata should not be null", metadata.project); + Assert.assertNotNull("Environment metadata should not be null", metadata.environment); + + // Verify that metadata can be used in HookContext + DevCycleUser testUser = DevCycleUser.builder().userId("test-user").build(); + HookContext contextWithMetadata = new HookContext<>(testUser, "test-key", "default", metadata); + + Assert.assertNotNull("HookContext should not be null", contextWithMetadata); + Assert.assertEquals("Metadata should be accessible from context", metadata, contextWithMetadata.getMetadata()); + Assert.assertEquals("Config ETag should be accessible", "test-etag-12345", contextWithMetadata.getMetadata().configETag); + } } \ No newline at end of file