From d7072208a5119838facb657fb5709cd9beba0f5b Mon Sep 17 00:00:00 2001 From: Parth Suthar Date: Wed, 23 Jul 2025 14:09:06 -0400 Subject: [PATCH 1/5] feat: add config metadata --- .../com/devcycle/examples/LocalExample.java | 42 +++- .../cloud/api/DevCycleCloudApiClient.java | 7 +- .../server/cloud/api/DevCycleCloudClient.java | 7 +- .../server/common/api/ObjectMapperUtils.java | 48 ++++ .../sdk/server/common/model/HookContext.java | 15 +- .../server/common/model/ProjectConfig.java | 17 +- .../local/api/DevCycleLocalApiClient.java | 7 +- .../server/local/api/DevCycleLocalClient.java | 10 +- .../api/DevCycleLocalEventsApiClient.java | 9 +- .../managers/EnvironmentConfigManager.java | 51 ++-- .../server/local/model/ConfigMetadata.java | 17 ++ .../local/model/EnvironmentMetadata.java | 11 + .../server/local/model/ProjectMetadata.java | 11 + .../server/cloud/DevCycleCloudClientTest.java | 157 +++++++++++++ .../sdk/server/cloud/EvalHooksRunnerTest.java | 51 +++- .../server/local/DevCycleLocalClientTest.java | 218 ++++++++++++++++++ 16 files changed, 624 insertions(+), 54 deletions(-) create mode 100644 src/main/java/com/devcycle/sdk/server/common/api/ObjectMapperUtils.java create mode 100644 src/main/java/com/devcycle/sdk/server/local/model/ConfigMetadata.java create mode 100644 src/main/java/com/devcycle/sdk/server/local/model/EnvironmentMetadata.java create mode 100644 src/main/java/com/devcycle/sdk/server/local/model/ProjectMetadata.java diff --git a/src/examples/java/com/devcycle/examples/LocalExample.java b/src/examples/java/com/devcycle/examples/LocalExample.java index 2df9724c..26e4d657 100644 --- a/src/examples/java/com/devcycle/examples/LocalExample.java +++ b/src/examples/java/com/devcycle/examples/LocalExample.java @@ -1,13 +1,18 @@ package com.devcycle.examples; +import java.util.Optional; + import com.devcycle.sdk.server.common.logging.SimpleDevCycleLogger; import com.devcycle.sdk.server.common.model.DevCycleEvent; import com.devcycle.sdk.server.common.model.DevCycleUser; +import com.devcycle.sdk.server.common.model.EvalHook; +import com.devcycle.sdk.server.common.model.HookContext; +import com.devcycle.sdk.server.common.model.Variable; import com.devcycle.sdk.server.local.api.DevCycleLocalClient; import com.devcycle.sdk.server.local.model.DevCycleLocalOptions; public class LocalExample { - public static String VARIABLE_KEY = "test-boolean-variable"; + public static String VARIABLE_KEY = "example-text"; public static void main(String[] args) throws InterruptedException { String server_sdk_key = System.getenv("DEVCYCLE_SERVER_SDK_KEY"); @@ -18,11 +23,11 @@ public static void main(String[] args) throws InterruptedException { // Create user object DevCycleUser user = DevCycleUser.builder() - .userId("SOME_USER_ID") + .userId("j_test") .build(); // The default value can be of type string, boolean, number, or JSON - Boolean defaultValue = false; + String defaultValue = "false"; DevCycleLocalOptions options = DevCycleLocalOptions.builder() .customLogger(new SimpleDevCycleLogger(SimpleDevCycleLogger.Level.DEBUG)) @@ -31,6 +36,31 @@ public static void main(String[] args) throws InterruptedException { // Initialize DevCycle Client DevCycleLocalClient client = new DevCycleLocalClient(server_sdk_key, options); + client.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + System.out.println("before"); + System.out.println(ctx.getMetadata().project.key); + System.out.println(ctx.getMetadata().environment.key); + return Optional.of(ctx); + } + + @Override + public void after(HookContext ctx, Variable variable) { + System.out.println("after"); + System.out.println(variable.getValue()); + System.out.println(ctx.getMetadata().project.key); + System.out.println(ctx.getMetadata().environment.key); + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + System.out.println("finally"); + System.out.println(ctx.getMetadata().project.key); + System.out.println(ctx.getMetadata().environment.key); + } + }); + for (int i = 0; i < 10; i++) { if (client.isInitialized()) { break; @@ -41,13 +71,13 @@ public static void main(String[] args) throws InterruptedException { // Fetch variable values using the identifier key, with a default value and user // object // The default value can be of type string, boolean, number, or JSON - Boolean variableValue = client.variableValue(user, VARIABLE_KEY, defaultValue); + String variableValue = client.variableValue(user, VARIABLE_KEY, defaultValue); // Use variable value - if (variableValue) { + if (variableValue.equals("true")) { System.out.println("feature is enabled"); } else { - System.out.println("feature is NOT enabled"); + System.out.println("feature is NOT enabled: " + variableValue); } DevCycleEvent event = DevCycleEvent.builder().type("local-test").build(); 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..8e16e29d 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()); @@ -198,8 +200,12 @@ public Variable variable(DevCycleUser user, String key, T defaultValue) { 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..8c105e8c 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,16 @@ 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 = (this.configMetadata != null) ? this.configMetadata.configETag : null; + String lastModified = (this.configMetadata != null) ? this.configMetadata.configLastModified : null; + + 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 +200,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 +221,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 : + " (no metadata available)"; + 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 +285,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..70d20561 --- /dev/null +++ b/src/main/java/com/devcycle/sdk/server/local/model/ConfigMetadata.java @@ -0,0 +1,17 @@ +package com.devcycle.sdk.server.local.model; + +public class ConfigMetadata { + + public String configETag; + public String configLastModified; + public ProjectMetadata project; + public 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..e3bffbde --- /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 String id; + public 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..6995ec6e --- /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 String id; + public 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/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 From 05adb0c73e30abe4d6f2c106cd590b1bac4d5891 Mon Sep 17 00:00:00 2001 From: Parth Suthar Date: Wed, 23 Jul 2025 14:53:47 -0400 Subject: [PATCH 2/5] fix: local config server missing the headers for the metadata --- .../devcycle/sdk/server/local/api/DevCycleLocalClient.java | 4 +++- .../com/devcycle/sdk/server/helpers/LocalConfigServer.java | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) 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 8e16e29d..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 @@ -194,7 +194,9 @@ 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; 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(); From ec3b905488efa4729c5c4df237ce149f02029ff3 Mon Sep 17 00:00:00 2001 From: Parth Suthar Date: Wed, 23 Jul 2025 16:26:14 -0400 Subject: [PATCH 3/5] add getters for config metadata variables and cleanup code --- .../com/devcycle/examples/LocalExample.java | 12 +++++----- .../managers/EnvironmentConfigManager.java | 12 ++++++---- .../server/local/model/ConfigMetadata.java | 23 +++++++++++++++---- .../local/model/EnvironmentMetadata.java | 12 ++++++++-- .../server/local/model/ProjectMetadata.java | 12 ++++++++-- 5 files changed, 53 insertions(+), 18 deletions(-) diff --git a/src/examples/java/com/devcycle/examples/LocalExample.java b/src/examples/java/com/devcycle/examples/LocalExample.java index 26e4d657..6162793a 100644 --- a/src/examples/java/com/devcycle/examples/LocalExample.java +++ b/src/examples/java/com/devcycle/examples/LocalExample.java @@ -40,8 +40,8 @@ public static void main(String[] args) throws InterruptedException { @Override public Optional> before(HookContext ctx) { System.out.println("before"); - System.out.println(ctx.getMetadata().project.key); - System.out.println(ctx.getMetadata().environment.key); + System.out.println(ctx.getMetadata().getProject().getKey()); + System.out.println(ctx.getMetadata().getEnvironment().getKey()); return Optional.of(ctx); } @@ -49,15 +49,15 @@ public Optional> before(HookContext ctx) { public void after(HookContext ctx, Variable variable) { System.out.println("after"); System.out.println(variable.getValue()); - System.out.println(ctx.getMetadata().project.key); - System.out.println(ctx.getMetadata().environment.key); + System.out.println(ctx.getMetadata().getProject().getKey()); + System.out.println(ctx.getMetadata().getEnvironment().getKey()); } @Override public void onFinally(HookContext ctx, Optional> variable) { System.out.println("finally"); - System.out.println(ctx.getMetadata().project.key); - System.out.println(ctx.getMetadata().environment.key); + System.out.println(ctx.getMetadata().getProject().getKey()); + System.out.println(ctx.getMetadata().getEnvironment().getKey()); } }); 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 8c105e8c..e23c15d6 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 @@ -85,9 +85,13 @@ public boolean isConfigInitialized() { private ProjectConfig getConfig() throws DevCycleException { // Handle initial request where configMetadata might be null - String etag = (this.configMetadata != null) ? this.configMetadata.configETag : null; - String lastModified = (this.configMetadata != null) ? this.configMetadata.configLastModified : null; - + String etag = null; + String lastModified = null; + if (this.configMetadata != null) { + etag = this.configMetadata.getConfigETag(); + lastModified = this.configMetadata.getConfigLastModified(); + } + Call config = this.configApiClient.getConfig(this.sdkKey, etag, lastModified); ProjectConfig fetchedConfig = getResponseWithRetries(config, 1); this.config = fetchedConfig; @@ -227,7 +231,7 @@ private ProjectConfig getConfigResponse(Call call) throws DevCycl if (this.config != null) { String currentConfigInfo = (this.configMetadata != null) ? " etag " + this.configMetadata.configETag + " last-modified: " + this.configMetadata.configLastModified : - " (no metadata available)"; + ""; DevCycleLogger.error("Unable to parse config with etag: " + currentETag + ". Using cache," + currentConfigInfo); return this.config; } else { 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 index 70d20561..a415da4a 100644 --- a/src/main/java/com/devcycle/sdk/server/local/model/ConfigMetadata.java +++ b/src/main/java/com/devcycle/sdk/server/local/model/ConfigMetadata.java @@ -2,10 +2,10 @@ public class ConfigMetadata { - public String configETag; - public String configLastModified; - public ProjectMetadata project; - public EnvironmentMetadata environment; + 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; @@ -14,4 +14,19 @@ public ConfigMetadata(String currentETag, String headerLastModified, Project pro this.environment = new EnvironmentMetadata(environment._id, environment.key); } + public String getConfigETag() { + return configETag; + } + + public String getConfigLastModified() { + return configLastModified; + } + + public ProjectMetadata getProject() { + return project; + } + + public EnvironmentMetadata getEnvironment() { + return environment; + } } 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 index e3bffbde..6725acd2 100644 --- a/src/main/java/com/devcycle/sdk/server/local/model/EnvironmentMetadata.java +++ b/src/main/java/com/devcycle/sdk/server/local/model/EnvironmentMetadata.java @@ -1,11 +1,19 @@ package com.devcycle.sdk.server.local.model; public class EnvironmentMetadata { - public String id; - public String key; + public final String id; + public final String key; public EnvironmentMetadata(String id, String key) { this.id = id; this.key = key; } + + public String getId() { + return id; + } + + public String getKey() { + return 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 index 6995ec6e..847de0ab 100644 --- a/src/main/java/com/devcycle/sdk/server/local/model/ProjectMetadata.java +++ b/src/main/java/com/devcycle/sdk/server/local/model/ProjectMetadata.java @@ -1,11 +1,19 @@ package com.devcycle.sdk.server.local.model; public class ProjectMetadata { - public String id; - public String key; + public final String id; + public final String key; public ProjectMetadata(String id, String key) { this.id = id; this.key = key; } + + public String getId() { + return id; + } + + public String getKey() { + return key; + } } From c3c6a810025712593987ff582f26bfd250250d7f Mon Sep 17 00:00:00 2001 From: Parth Suthar Date: Wed, 23 Jul 2025 16:29:21 -0400 Subject: [PATCH 4/5] revert local example testing file --- .../com/devcycle/examples/LocalExample.java | 42 +++---------------- 1 file changed, 6 insertions(+), 36 deletions(-) diff --git a/src/examples/java/com/devcycle/examples/LocalExample.java b/src/examples/java/com/devcycle/examples/LocalExample.java index 6162793a..2df9724c 100644 --- a/src/examples/java/com/devcycle/examples/LocalExample.java +++ b/src/examples/java/com/devcycle/examples/LocalExample.java @@ -1,18 +1,13 @@ package com.devcycle.examples; -import java.util.Optional; - import com.devcycle.sdk.server.common.logging.SimpleDevCycleLogger; import com.devcycle.sdk.server.common.model.DevCycleEvent; import com.devcycle.sdk.server.common.model.DevCycleUser; -import com.devcycle.sdk.server.common.model.EvalHook; -import com.devcycle.sdk.server.common.model.HookContext; -import com.devcycle.sdk.server.common.model.Variable; import com.devcycle.sdk.server.local.api.DevCycleLocalClient; import com.devcycle.sdk.server.local.model.DevCycleLocalOptions; public class LocalExample { - public static String VARIABLE_KEY = "example-text"; + public static String VARIABLE_KEY = "test-boolean-variable"; public static void main(String[] args) throws InterruptedException { String server_sdk_key = System.getenv("DEVCYCLE_SERVER_SDK_KEY"); @@ -23,11 +18,11 @@ public static void main(String[] args) throws InterruptedException { // Create user object DevCycleUser user = DevCycleUser.builder() - .userId("j_test") + .userId("SOME_USER_ID") .build(); // The default value can be of type string, boolean, number, or JSON - String defaultValue = "false"; + Boolean defaultValue = false; DevCycleLocalOptions options = DevCycleLocalOptions.builder() .customLogger(new SimpleDevCycleLogger(SimpleDevCycleLogger.Level.DEBUG)) @@ -36,31 +31,6 @@ public static void main(String[] args) throws InterruptedException { // Initialize DevCycle Client DevCycleLocalClient client = new DevCycleLocalClient(server_sdk_key, options); - client.addHook(new EvalHook() { - @Override - public Optional> before(HookContext ctx) { - System.out.println("before"); - System.out.println(ctx.getMetadata().getProject().getKey()); - System.out.println(ctx.getMetadata().getEnvironment().getKey()); - return Optional.of(ctx); - } - - @Override - public void after(HookContext ctx, Variable variable) { - System.out.println("after"); - System.out.println(variable.getValue()); - System.out.println(ctx.getMetadata().getProject().getKey()); - System.out.println(ctx.getMetadata().getEnvironment().getKey()); - } - - @Override - public void onFinally(HookContext ctx, Optional> variable) { - System.out.println("finally"); - System.out.println(ctx.getMetadata().getProject().getKey()); - System.out.println(ctx.getMetadata().getEnvironment().getKey()); - } - }); - for (int i = 0; i < 10; i++) { if (client.isInitialized()) { break; @@ -71,13 +41,13 @@ public void onFinally(HookContext ctx, Optional> variab // Fetch variable values using the identifier key, with a default value and user // object // The default value can be of type string, boolean, number, or JSON - String variableValue = client.variableValue(user, VARIABLE_KEY, defaultValue); + Boolean variableValue = client.variableValue(user, VARIABLE_KEY, defaultValue); // Use variable value - if (variableValue.equals("true")) { + if (variableValue) { System.out.println("feature is enabled"); } else { - System.out.println("feature is NOT enabled: " + variableValue); + System.out.println("feature is NOT enabled"); } DevCycleEvent event = DevCycleEvent.builder().type("local-test").build(); From 770715d3659d69ecd7247a99a0aa0538faf9d7e2 Mon Sep 17 00:00:00 2001 From: Parth Suthar Date: Wed, 23 Jul 2025 16:41:19 -0400 Subject: [PATCH 5/5] remove getters for final variables as its not needed --- .../local/managers/EnvironmentConfigManager.java | 4 ++-- .../sdk/server/local/model/ConfigMetadata.java | 16 ---------------- .../server/local/model/EnvironmentMetadata.java | 8 -------- .../sdk/server/local/model/ProjectMetadata.java | 8 -------- 4 files changed, 2 insertions(+), 34 deletions(-) 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 e23c15d6..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 @@ -88,8 +88,8 @@ private ProjectConfig getConfig() throws DevCycleException { String etag = null; String lastModified = null; if (this.configMetadata != null) { - etag = this.configMetadata.getConfigETag(); - lastModified = this.configMetadata.getConfigLastModified(); + etag = this.configMetadata.configETag; + lastModified = this.configMetadata.configLastModified; } Call config = this.configApiClient.getConfig(this.sdkKey, etag, lastModified); 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 index a415da4a..120474b2 100644 --- a/src/main/java/com/devcycle/sdk/server/local/model/ConfigMetadata.java +++ b/src/main/java/com/devcycle/sdk/server/local/model/ConfigMetadata.java @@ -13,20 +13,4 @@ public ConfigMetadata(String currentETag, String headerLastModified, Project pro this.project = new ProjectMetadata(project._id, project.key); this.environment = new EnvironmentMetadata(environment._id, environment.key); } - - public String getConfigETag() { - return configETag; - } - - public String getConfigLastModified() { - return configLastModified; - } - - public ProjectMetadata getProject() { - return project; - } - - public EnvironmentMetadata getEnvironment() { - return environment; - } } 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 index 6725acd2..515907cb 100644 --- a/src/main/java/com/devcycle/sdk/server/local/model/EnvironmentMetadata.java +++ b/src/main/java/com/devcycle/sdk/server/local/model/EnvironmentMetadata.java @@ -8,12 +8,4 @@ public EnvironmentMetadata(String id, String key) { this.id = id; this.key = key; } - - public String getId() { - return id; - } - - public String getKey() { - return 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 index 847de0ab..464dc2a9 100644 --- a/src/main/java/com/devcycle/sdk/server/local/model/ProjectMetadata.java +++ b/src/main/java/com/devcycle/sdk/server/local/model/ProjectMetadata.java @@ -8,12 +8,4 @@ public ProjectMetadata(String id, String key) { this.id = id; this.key = key; } - - public String getId() { - return id; - } - - public String getKey() { - return key; - } }