From 39d2e8954fe8a593071d63a06b9c89a1b5e16f90 Mon Sep 17 00:00:00 2001 From: Chris Freeman Date: Tue, 3 Feb 2026 10:33:44 -0700 Subject: [PATCH 1/2] fix: move X-Glean header config to preserved hooks package The custom fields added to SDKConfiguration in #66 are removed when Speakeasy regenerates the SDK, breaking the build. This moves the configuration to a dedicated GleanCustomConfig singleton in the preserved hooks/ package. Changes: - Add GleanCustomConfig singleton for custom configuration storage - Add GleanBuilder wrapper with excludeDeprecatedAfter/includeExperimental methods - Update XGleanHeadersHook to read from GleanCustomConfig instead of SDKConfiguration - Update tests to use GleanCustomConfig - Update README to document GleanBuilder usage --- README.md | 4 +- .../glean_api_client/hooks/GleanBuilder.java | 181 ++++++++++++++++++ .../hooks/GleanCustomConfig.java | 80 ++++++++ .../hooks/XGleanHeadersHook.java | 13 +- .../hooks/XGleanHeadersHookTest.java | 17 +- 5 files changed, 284 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/glean/api_client/glean_api_client/hooks/GleanBuilder.java create mode 100644 src/main/java/com/glean/api_client/glean_api_client/hooks/GleanCustomConfig.java diff --git a/README.md b/README.md index cb22c4ce..abda786e 100644 --- a/README.md +++ b/README.md @@ -1629,7 +1629,9 @@ Glean glean = Glean.builder() #### Using SDK Constructor Options ```java -Glean glean = Glean.builder() +import com.glean.api_client.glean_api_client.hooks.GleanBuilder; + +Glean glean = GleanBuilder.create() .apiToken(System.getenv("GLEAN_API_TOKEN")) .instance("instance-name") .excludeDeprecatedAfter("2026-10-15") diff --git a/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanBuilder.java b/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanBuilder.java new file mode 100644 index 00000000..693df4c5 --- /dev/null +++ b/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanBuilder.java @@ -0,0 +1,181 @@ +package com.glean.api_client.glean_api_client.hooks; + +import com.glean.api_client.glean_api_client.Glean; +import com.glean.api_client.glean_api_client.SecuritySource; +import com.glean.api_client.glean_api_client.utils.HTTPClient; +import com.glean.api_client.glean_api_client.utils.RetryConfig; + +import java.util.Map; +import java.util.Optional; + +/** + * Builder wrapper for creating {@link Glean} instances with custom configuration options. + * + *

This builder extends the standard SDK builder with additional configuration options + * for experimental features and deprecation testing that are preserved across SDK regenerations. + * + *

Example usage: + *

{@code
+ * Glean glean = GleanBuilder.create()
+ *         .apiToken("your-api-token")
+ *         .instance("instance-name")
+ *         .excludeDeprecatedAfter("2026-10-15")
+ *         .includeExperimental(true)
+ *         .build();
+ * }
+ */ +public final class GleanBuilder { + + private final Glean.Builder delegate; + + private GleanBuilder() { + this.delegate = Glean.builder(); + } + + /** + * Creates a new builder instance. + * + * @return a new GleanBuilder + */ + public static GleanBuilder create() { + return new GleanBuilder(); + } + + /** + * Configures the SDK security to use the provided API token. + * + * @param apiToken The API token to use for all requests. + * @return This builder instance. + */ + public GleanBuilder apiToken(String apiToken) { + delegate.apiToken(apiToken); + return this; + } + + /** + * Configures the SDK to use a custom security source. + * + * @param securitySource The security source to use for all requests. + * @return This builder instance. + */ + public GleanBuilder securitySource(SecuritySource securitySource) { + delegate.securitySource(securitySource); + return this; + } + + /** + * Allows the default HTTP client to be overridden with a custom implementation. + * + * @param client The HTTP client to use for all requests. + * @return This builder instance. + */ + public GleanBuilder client(HTTPClient client) { + delegate.client(client); + return this; + } + + /** + * Overrides the default server URL. + * + * @param serverUrl The server URL to use for all requests. + * @return This builder instance. + */ + public GleanBuilder serverURL(String serverUrl) { + delegate.serverURL(serverUrl); + return this; + } + + /** + * Overrides the default server URL with a templated URL populated with the provided parameters. + * + * @param serverUrl The server URL to use for all requests. + * @param params The parameters to use when templating the URL. + * @return This builder instance. + */ + public GleanBuilder serverURL(String serverUrl, Map params) { + delegate.serverURL(serverUrl, params); + return this; + } + + /** + * Overrides the default server by index. + * + * @param serverIdx The server to use for all requests. + * @return This builder instance. + */ + public GleanBuilder serverIndex(int serverIdx) { + delegate.serverIndex(serverIdx); + return this; + } + + /** + * Sets the instance variable for URL substitution. + * + * @param instance The instance name to set. + * @return This builder instance. + */ + public GleanBuilder instance(String instance) { + delegate.instance(instance); + return this; + } + + /** + * Overrides the default configuration for retries. + * + * @param retryConfig The retry configuration to use for all requests. + * @return This builder instance. + */ + public GleanBuilder retryConfig(RetryConfig retryConfig) { + delegate.retryConfig(retryConfig); + return this; + } + + /** + * Enables debug logging for HTTP requests and responses, including JSON body content. + * + * @param enabled Whether to enable debug logging. + * @return This builder instance. + */ + public GleanBuilder enableHTTPDebugLogging(boolean enabled) { + delegate.enableHTTPDebugLogging(enabled); + return this; + } + + /** + * Exclude API endpoints that will be deprecated after this date. + * Use this to test your integration against upcoming deprecations. + * + *

More information: Deprecations Overview + * + * @param excludeDeprecatedAfter date string in YYYY-MM-DD format (e.g., '2026-10-15') + * @return This builder instance. + */ + public GleanBuilder excludeDeprecatedAfter(String excludeDeprecatedAfter) { + GleanCustomConfig.getInstance().setExcludeDeprecatedAfter(Optional.ofNullable(excludeDeprecatedAfter)); + return this; + } + + /** + * Enable experimental API features that are not yet generally available. + * Use this to preview and test new functionality. + * + *

Warning: Experimental features may change or be removed without notice. + * Do not rely on experimental features in production environments. + * + * @param includeExperimental whether to include experimental features + * @return This builder instance. + */ + public GleanBuilder includeExperimental(boolean includeExperimental) { + GleanCustomConfig.getInstance().setIncludeExperimental(Optional.of(includeExperimental)); + return this; + } + + /** + * Builds a new instance of the Glean SDK. + * + * @return The configured Glean instance. + */ + public Glean build() { + return delegate.build(); + } +} diff --git a/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanCustomConfig.java b/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanCustomConfig.java new file mode 100644 index 00000000..71cc14b5 --- /dev/null +++ b/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanCustomConfig.java @@ -0,0 +1,80 @@ +package com.glean.api_client.glean_api_client.hooks; + +import java.util.Optional; + +/** + * Thread-safe singleton storing custom Glean configuration values. + * + *

This class holds configuration that is not part of the auto-generated SDK, + * providing a way to configure custom headers and features without modifying + * generated code. + * + *

Values can be set via {@link GleanBuilder} when constructing the SDK instance. + */ +public final class GleanCustomConfig { + + private static final GleanCustomConfig INSTANCE = new GleanCustomConfig(); + + private volatile Optional excludeDeprecatedAfter = Optional.empty(); + private volatile Optional includeExperimental = Optional.empty(); + + private GleanCustomConfig() { + // private constructor for singleton + } + + /** + * Gets the singleton instance. + * + * @return the singleton instance + */ + public static GleanCustomConfig getInstance() { + return INSTANCE; + } + + /** + * Gets the date after which deprecated API endpoints should be excluded. + * + * @return Optional containing the date string (YYYY-MM-DD format) if set + */ + public Optional excludeDeprecatedAfter() { + return excludeDeprecatedAfter; + } + + /** + * Sets the date after which deprecated API endpoints should be excluded. + * Use this to test your integration against upcoming deprecations. + * + * @param excludeDeprecatedAfter date string in YYYY-MM-DD format, or empty to clear + */ + public void setExcludeDeprecatedAfter(Optional excludeDeprecatedAfter) { + this.excludeDeprecatedAfter = excludeDeprecatedAfter != null ? excludeDeprecatedAfter : Optional.empty(); + } + + /** + * Gets whether experimental API features should be enabled. + * + * @return Optional containing the boolean value if set + */ + public Optional includeExperimental() { + return includeExperimental; + } + + /** + * Sets whether experimental API features should be enabled. + * When true, enables experimental API features that are not yet generally available. + * + * @param includeExperimental whether to include experimental features, or empty to clear + */ + public void setIncludeExperimental(Optional includeExperimental) { + this.includeExperimental = includeExperimental != null ? includeExperimental : Optional.empty(); + } + + /** + * Resets all configuration values to their defaults (empty). + * Primarily intended for testing. + */ + public void reset() { + this.excludeDeprecatedAfter = Optional.empty(); + this.includeExperimental = Optional.empty(); + } +} diff --git a/src/main/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHook.java b/src/main/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHook.java index 5147f1b5..e5fce285 100644 --- a/src/main/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHook.java +++ b/src/main/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHook.java @@ -1,6 +1,5 @@ package com.glean.api_client.glean_api_client.hooks; -import com.glean.api_client.glean_api_client.SDKConfiguration; import com.glean.api_client.glean_api_client.utils.AsyncHook; import com.glean.api_client.glean_api_client.utils.Helpers; import com.glean.api_client.glean_api_client.utils.Hook; @@ -56,7 +55,7 @@ public static Hook.BeforeRequest createSyncHook() { static Hook.BeforeRequest createSyncHook(Function envProvider) { return (context, request) -> { HttpRequest.Builder builder = Helpers.copy(request); - addHeaders(builder, context.sdkConfiguration(), envProvider); + addHeaders(builder, envProvider); return builder.build(); }; } @@ -80,17 +79,19 @@ public static AsyncHook.BeforeRequest createAsyncHook() { static AsyncHook.BeforeRequest createAsyncHook(Function envProvider) { return (context, request) -> { HttpRequest.Builder builder = Helpers.copy(request); - addHeaders(builder, context.sdkConfiguration(), envProvider); + addHeaders(builder, envProvider); return CompletableFuture.completedFuture(builder.build()); }; } - private static void addHeaders(HttpRequest.Builder builder, SDKConfiguration config, + private static void addHeaders(HttpRequest.Builder builder, Function envProvider) { + GleanCustomConfig customConfig = GleanCustomConfig.getInstance(); + // Get deprecated after value - environment variable takes precedence Optional deprecatedAfterValue = getFirstNonEmpty( getEnv(ENV_EXCLUDE_DEPRECATED_AFTER, envProvider), - config.excludeDeprecatedAfter() + customConfig.excludeDeprecatedAfter() ); deprecatedAfterValue.ifPresent(value -> @@ -100,7 +101,7 @@ private static void addHeaders(HttpRequest.Builder builder, SDKConfiguration con // Get experimental value - environment variable takes precedence Optional experimentalValue = getFirstNonEmpty( getEnvAsBoolean(ENV_INCLUDE_EXPERIMENTAL, envProvider), - config.includeExperimental().filter(b -> b).map(b -> "true") + customConfig.includeExperimental().filter(b -> b).map(b -> "true") ); experimentalValue.ifPresent(value -> diff --git a/src/test/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHookTest.java b/src/test/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHookTest.java index e3182b99..27e4db2c 100644 --- a/src/test/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHookTest.java +++ b/src/test/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHookTest.java @@ -4,6 +4,7 @@ import com.glean.api_client.glean_api_client.SecuritySource; import com.glean.api_client.glean_api_client.utils.Hook; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -21,6 +22,12 @@ class XGleanHeadersHookTest { private static final String HEADER_EXCLUDE_DEPRECATED_AFTER = XGleanHeadersHook.HEADER_EXCLUDE_DEPRECATED_AFTER; private static final String HEADER_EXPERIMENTAL = XGleanHeadersHook.HEADER_EXPERIMENTAL; + @BeforeEach + void setUp() { + // Reset the singleton config before each test + GleanCustomConfig.getInstance().reset(); + } + private HttpRequest createMockRequest() { return HttpRequest.newBuilder() .uri(URI.create("https://example.com/api/test")) @@ -39,14 +46,16 @@ private Hook.BeforeRequestContext createMockContext(SDKConfiguration config) { } private SDKConfiguration createConfig(String excludeDeprecatedAfter, Boolean includeExperimental) { - SDKConfiguration config = new SDKConfiguration(); + // Configure the GleanCustomConfig singleton (used by the hook) + GleanCustomConfig customConfig = GleanCustomConfig.getInstance(); if (excludeDeprecatedAfter != null) { - config.setExcludeDeprecatedAfter(Optional.of(excludeDeprecatedAfter)); + customConfig.setExcludeDeprecatedAfter(Optional.of(excludeDeprecatedAfter)); } if (includeExperimental != null) { - config.setIncludeExperimental(Optional.of(includeExperimental)); + customConfig.setIncludeExperimental(Optional.of(includeExperimental)); } - return config; + // Return a plain SDKConfiguration (not used for the custom fields anymore) + return new SDKConfiguration(); } /** From a49b965ec826f9033161cf992b22f4db6e58c594 Mon Sep 17 00:00:00 2001 From: Chris Freeman Date: Tue, 3 Feb 2026 10:54:20 -0700 Subject: [PATCH 2/2] fix: scope X-Glean header config per SDK instance Avoid global singleton state by mapping custom config to SDKConfiguration via a WeakHashMap registry, and restore compatibility by reading SDKConfiguration values via reflection when present. --- README.md | 4 +- .../glean_api_client/hooks/GleanBuilder.java | 72 ++++++++++++- .../hooks/GleanCustomConfig.java | 53 ++-------- .../hooks/GleanCustomConfigRegistry.java | 47 ++++++++ .../hooks/XGleanHeadersHook.java | 75 +++++++++++-- .../hooks/XGleanHeadersHookTest.java | 100 ++++++++++++++++-- 6 files changed, 282 insertions(+), 69 deletions(-) create mode 100644 src/main/java/com/glean/api_client/glean_api_client/hooks/GleanCustomConfigRegistry.java diff --git a/README.md b/README.md index abda786e..b2cc5a39 100644 --- a/README.md +++ b/README.md @@ -1626,7 +1626,7 @@ Glean glean = Glean.builder() .build(); ``` -#### Using SDK Constructor Options +#### Using GleanBuilder (regen-safe) ```java import com.glean.api_client.glean_api_client.hooks.GleanBuilder; @@ -1639,6 +1639,8 @@ Glean glean = GleanBuilder.create() .build(); ``` +> **Note:** `GleanBuilder` is preserved across SDK regenerations. Generated builder options may change or be removed by regeneration. + ### Option Reference | Option | Environment Variable | Type | Description | diff --git a/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanBuilder.java b/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanBuilder.java index 693df4c5..701f259d 100644 --- a/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanBuilder.java +++ b/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanBuilder.java @@ -1,10 +1,12 @@ package com.glean.api_client.glean_api_client.hooks; import com.glean.api_client.glean_api_client.Glean; +import com.glean.api_client.glean_api_client.SDKConfiguration; import com.glean.api_client.glean_api_client.SecuritySource; import com.glean.api_client.glean_api_client.utils.HTTPClient; import com.glean.api_client.glean_api_client.utils.RetryConfig; +import java.lang.reflect.Field; import java.util.Map; import java.util.Optional; @@ -28,6 +30,9 @@ public final class GleanBuilder { private final Glean.Builder delegate; + private Optional excludeDeprecatedAfter = Optional.empty(); + private Optional includeExperimental = Optional.empty(); + private GleanBuilder() { this.delegate = Glean.builder(); } @@ -151,7 +156,7 @@ public GleanBuilder enableHTTPDebugLogging(boolean enabled) { * @return This builder instance. */ public GleanBuilder excludeDeprecatedAfter(String excludeDeprecatedAfter) { - GleanCustomConfig.getInstance().setExcludeDeprecatedAfter(Optional.ofNullable(excludeDeprecatedAfter)); + this.excludeDeprecatedAfter = Optional.ofNullable(excludeDeprecatedAfter); return this; } @@ -166,7 +171,7 @@ public GleanBuilder excludeDeprecatedAfter(String excludeDeprecatedAfter) { * @return This builder instance. */ public GleanBuilder includeExperimental(boolean includeExperimental) { - GleanCustomConfig.getInstance().setIncludeExperimental(Optional.of(includeExperimental)); + this.includeExperimental = Optional.of(includeExperimental); return this; } @@ -176,6 +181,67 @@ public GleanBuilder includeExperimental(boolean includeExperimental) { * @return The configured Glean instance. */ public Glean build() { - return delegate.build(); + Glean sdk = delegate.build(); + SDKConfiguration sdkConfiguration = extractSdkConfiguration(sdk); + if (sdkConfiguration != null) { + GleanCustomConfigRegistry.put( + sdkConfiguration, + new GleanCustomConfig(excludeDeprecatedAfter, includeExperimental) + ); + } + return sdk; + } + + private static SDKConfiguration extractSdkConfiguration(Glean sdk) { + if (sdk == null) { + return null; + } + + try { + // Preferred: reflectively access Glean.client -> Client.sdkConfiguration + Object client = readFieldValue(sdk, "client"); + if (client != null) { + Object cfg = readFieldValue(client, "sdkConfiguration"); + if (cfg instanceof SDKConfiguration) { + return (SDKConfiguration) cfg; + } + } + } catch (RuntimeException e) { + // Best-effort: fall through to generic field scan + } + + // Fallback: scan first-level fields for an SDKConfiguration + for (Field f : sdk.getClass().getDeclaredFields()) { + if (!SDKConfiguration.class.isAssignableFrom(f.getType())) { + continue; + } + try { + f.setAccessible(true); + Object cfg = f.get(sdk); + if (cfg instanceof SDKConfiguration) { + return (SDKConfiguration) cfg; + } + } catch (IllegalAccessException e) { + // ignore + } + } + + return null; + } + + private static Object readFieldValue(Object target, String fieldName) { + Class c = target.getClass(); + while (c != null) { + try { + Field f = c.getDeclaredField(fieldName); + f.setAccessible(true); + return f.get(target); + } catch (NoSuchFieldException e) { + c = c.getSuperclass(); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + return null; } } diff --git a/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanCustomConfig.java b/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanCustomConfig.java index 71cc14b5..b8e217ec 100644 --- a/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanCustomConfig.java +++ b/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanCustomConfig.java @@ -3,32 +3,22 @@ import java.util.Optional; /** - * Thread-safe singleton storing custom Glean configuration values. + * Immutable custom Glean configuration values. * *

This class holds configuration that is not part of the auto-generated SDK, * providing a way to configure custom headers and features without modifying * generated code. * - *

Values can be set via {@link GleanBuilder} when constructing the SDK instance. + *

Values are associated with a specific SDK instance via {@link GleanCustomConfigRegistry}. */ public final class GleanCustomConfig { - private static final GleanCustomConfig INSTANCE = new GleanCustomConfig(); + private final Optional excludeDeprecatedAfter; + private final Optional includeExperimental; - private volatile Optional excludeDeprecatedAfter = Optional.empty(); - private volatile Optional includeExperimental = Optional.empty(); - - private GleanCustomConfig() { - // private constructor for singleton - } - - /** - * Gets the singleton instance. - * - * @return the singleton instance - */ - public static GleanCustomConfig getInstance() { - return INSTANCE; + public GleanCustomConfig(Optional excludeDeprecatedAfter, Optional includeExperimental) { + this.excludeDeprecatedAfter = excludeDeprecatedAfter != null ? excludeDeprecatedAfter : Optional.empty(); + this.includeExperimental = includeExperimental != null ? includeExperimental : Optional.empty(); } /** @@ -40,16 +30,6 @@ public Optional excludeDeprecatedAfter() { return excludeDeprecatedAfter; } - /** - * Sets the date after which deprecated API endpoints should be excluded. - * Use this to test your integration against upcoming deprecations. - * - * @param excludeDeprecatedAfter date string in YYYY-MM-DD format, or empty to clear - */ - public void setExcludeDeprecatedAfter(Optional excludeDeprecatedAfter) { - this.excludeDeprecatedAfter = excludeDeprecatedAfter != null ? excludeDeprecatedAfter : Optional.empty(); - } - /** * Gets whether experimental API features should be enabled. * @@ -58,23 +38,4 @@ public void setExcludeDeprecatedAfter(Optional excludeDeprecatedAfter) { public Optional includeExperimental() { return includeExperimental; } - - /** - * Sets whether experimental API features should be enabled. - * When true, enables experimental API features that are not yet generally available. - * - * @param includeExperimental whether to include experimental features, or empty to clear - */ - public void setIncludeExperimental(Optional includeExperimental) { - this.includeExperimental = includeExperimental != null ? includeExperimental : Optional.empty(); - } - - /** - * Resets all configuration values to their defaults (empty). - * Primarily intended for testing. - */ - public void reset() { - this.excludeDeprecatedAfter = Optional.empty(); - this.includeExperimental = Optional.empty(); - } } diff --git a/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanCustomConfigRegistry.java b/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanCustomConfigRegistry.java new file mode 100644 index 00000000..afa0fb28 --- /dev/null +++ b/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanCustomConfigRegistry.java @@ -0,0 +1,47 @@ +package com.glean.api_client.glean_api_client.hooks; + +import com.glean.api_client.glean_api_client.SDKConfiguration; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.WeakHashMap; + +/** + * Registry mapping a generated {@link SDKConfiguration} instance to preserved custom configuration. + * + *

Speakeasy regenerations overwrite generated classes, so custom configuration is stored outside + * generated code and associated to a specific SDK instance at runtime. + */ +final class GleanCustomConfigRegistry { + + private static final Map REGISTRY = + Collections.synchronizedMap(new WeakHashMap<>()); + + private GleanCustomConfigRegistry() { + // prevent instantiation + } + + static void put(SDKConfiguration sdkConfiguration, GleanCustomConfig customConfig) { + if (sdkConfiguration == null) { + return; + } + + if (customConfig == null) { + return; + } + + REGISTRY.put(sdkConfiguration, customConfig); + } + + static Optional get(SDKConfiguration sdkConfiguration) { + if (sdkConfiguration == null) { + return Optional.empty(); + } + return Optional.ofNullable(REGISTRY.get(sdkConfiguration)); + } + + static void clearForTests() { + REGISTRY.clear(); + } +} diff --git a/src/main/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHook.java b/src/main/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHook.java index e5fce285..255ac37d 100644 --- a/src/main/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHook.java +++ b/src/main/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHook.java @@ -1,9 +1,11 @@ package com.glean.api_client.glean_api_client.hooks; +import com.glean.api_client.glean_api_client.SDKConfiguration; import com.glean.api_client.glean_api_client.utils.AsyncHook; import com.glean.api_client.glean_api_client.utils.Helpers; import com.glean.api_client.glean_api_client.utils.Hook; +import java.lang.reflect.Method; import java.net.http.HttpRequest; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -55,7 +57,7 @@ public static Hook.BeforeRequest createSyncHook() { static Hook.BeforeRequest createSyncHook(Function envProvider) { return (context, request) -> { HttpRequest.Builder builder = Helpers.copy(request); - addHeaders(builder, envProvider); + addHeaders(builder, context.sdkConfiguration(), envProvider); return builder.build(); }; } @@ -79,19 +81,21 @@ public static AsyncHook.BeforeRequest createAsyncHook() { static AsyncHook.BeforeRequest createAsyncHook(Function envProvider) { return (context, request) -> { HttpRequest.Builder builder = Helpers.copy(request); - addHeaders(builder, envProvider); + addHeaders(builder, context.sdkConfiguration(), envProvider); return CompletableFuture.completedFuture(builder.build()); }; } private static void addHeaders(HttpRequest.Builder builder, + SDKConfiguration sdkConfiguration, Function envProvider) { - GleanCustomConfig customConfig = GleanCustomConfig.getInstance(); + Optional customConfig = GleanCustomConfigRegistry.get(sdkConfiguration); // Get deprecated after value - environment variable takes precedence Optional deprecatedAfterValue = getFirstNonEmpty( getEnv(ENV_EXCLUDE_DEPRECATED_AFTER, envProvider), - customConfig.excludeDeprecatedAfter() + customConfig.flatMap(GleanCustomConfig::excludeDeprecatedAfter), + getSdkConfigurationExcludeDeprecatedAfter(sdkConfiguration) ); deprecatedAfterValue.ifPresent(value -> @@ -101,7 +105,8 @@ private static void addHeaders(HttpRequest.Builder builder, // Get experimental value - environment variable takes precedence Optional experimentalValue = getFirstNonEmpty( getEnvAsBoolean(ENV_INCLUDE_EXPERIMENTAL, envProvider), - customConfig.includeExperimental().filter(b -> b).map(b -> "true") + customConfig.flatMap(GleanCustomConfig::includeExperimental).filter(b -> b).map(b -> "true"), + getSdkConfigurationIncludeExperimental(sdkConfiguration).filter(b -> b).map(b -> "true") ); experimentalValue.ifPresent(value -> @@ -124,17 +129,71 @@ private static Optional getFirstNonEmpty(Optional... optionals) private static Optional getEnv(String name, Function envProvider) { String value = envProvider.apply(name); - if (value != null && !value.isEmpty()) { - return Optional.of(value); + if (value != null) { + value = value.trim(); + if (!value.isEmpty()) { + return Optional.of(value); + } } return Optional.empty(); } private static Optional getEnvAsBoolean(String name, Function envProvider) { String value = envProvider.apply(name); - if ("true".equalsIgnoreCase(value)) { + if (value != null && "true".equalsIgnoreCase(value.trim())) { return Optional.of("true"); } return Optional.empty(); } + + private static Optional getSdkConfigurationExcludeDeprecatedAfter(SDKConfiguration sdkConfiguration) { + if (sdkConfiguration == null) { + return Optional.empty(); + } + + // Use reflection to avoid a compile-time dependency on generated methods. + try { + Method m = sdkConfiguration.getClass().getMethod("excludeDeprecatedAfter"); + Object result = m.invoke(sdkConfiguration); + if (result instanceof Optional) { + Optional opt = (Optional) result; + Object v = opt.orElse(null); + if (v instanceof String) { + String s = ((String) v).trim(); + return s.isEmpty() ? Optional.empty() : Optional.of(s); + } + } + } catch (NoSuchMethodException e) { + return Optional.empty(); + } catch (Exception e) { + return Optional.empty(); + } + + return Optional.empty(); + } + + private static Optional getSdkConfigurationIncludeExperimental(SDKConfiguration sdkConfiguration) { + if (sdkConfiguration == null) { + return Optional.empty(); + } + + // Use reflection to avoid a compile-time dependency on generated methods. + try { + Method m = sdkConfiguration.getClass().getMethod("includeExperimental"); + Object result = m.invoke(sdkConfiguration); + if (result instanceof Optional) { + Optional opt = (Optional) result; + Object v = opt.orElse(null); + if (v instanceof Boolean) { + return Optional.of((Boolean) v); + } + } + } catch (NoSuchMethodException e) { + return Optional.empty(); + } catch (Exception e) { + return Optional.empty(); + } + + return Optional.empty(); + } } diff --git a/src/test/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHookTest.java b/src/test/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHookTest.java index 27e4db2c..c03303bf 100644 --- a/src/test/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHookTest.java +++ b/src/test/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHookTest.java @@ -10,9 +10,11 @@ import java.net.URI; import java.net.http.HttpRequest; +import java.lang.reflect.Method; import java.util.HashMap; import java.util.Map; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.function.Function; import static org.junit.jupiter.api.Assertions.*; @@ -24,8 +26,7 @@ class XGleanHeadersHookTest { @BeforeEach void setUp() { - // Reset the singleton config before each test - GleanCustomConfig.getInstance().reset(); + GleanCustomConfigRegistry.clearForTests(); } private HttpRequest createMockRequest() { @@ -46,16 +47,14 @@ private Hook.BeforeRequestContext createMockContext(SDKConfiguration config) { } private SDKConfiguration createConfig(String excludeDeprecatedAfter, Boolean includeExperimental) { - // Configure the GleanCustomConfig singleton (used by the hook) - GleanCustomConfig customConfig = GleanCustomConfig.getInstance(); - if (excludeDeprecatedAfter != null) { - customConfig.setExcludeDeprecatedAfter(Optional.of(excludeDeprecatedAfter)); + SDKConfiguration config = new SDKConfiguration(); + if (excludeDeprecatedAfter != null || includeExperimental != null) { + GleanCustomConfigRegistry.put( + config, + new GleanCustomConfig(Optional.ofNullable(excludeDeprecatedAfter), Optional.ofNullable(includeExperimental)) + ); } - if (includeExperimental != null) { - customConfig.setIncludeExperimental(Optional.of(includeExperimental)); - } - // Return a plain SDKConfiguration (not used for the custom fields anymore) - return new SDKConfiguration(); + return config; } /** @@ -258,4 +257,83 @@ void shouldUseEnvironmentVariablesForBothHeadersWhenAllAreSet() throws Exception assertEquals("true", result.headers().firstValue(HEADER_EXPERIMENTAL).orElse(null)); } } + + @Nested + class WhenMultipleSDKInstancesExist { + + @Test + void shouldNotLeakConfigBetweenSDKConfigurations() throws Exception { + Hook.BeforeRequest hook = XGleanHeadersHook.createSyncHook(emptyEnvProvider()); + HttpRequest request = createMockRequest(); + + SDKConfiguration configA = createConfig("2026-10-15", true); + SDKConfiguration configB = createConfig("2027-01-01", false); + + HttpRequest resA = hook.beforeRequest(createMockContext(configA), request); + HttpRequest resB = hook.beforeRequest(createMockContext(configB), request); + + assertEquals("2026-10-15", resA.headers().firstValue(HEADER_EXCLUDE_DEPRECATED_AFTER).orElse(null)); + assertEquals("true", resA.headers().firstValue(HEADER_EXPERIMENTAL).orElse(null)); + + assertEquals("2027-01-01", resB.headers().firstValue(HEADER_EXCLUDE_DEPRECATED_AFTER).orElse(null)); + assertFalse(resB.headers().firstValue(HEADER_EXPERIMENTAL).isPresent()); + } + } + + @Nested + class AsyncHookParity { + + @Test + void shouldApplySameHeadersInAsyncHook() { + var hook = XGleanHeadersHook.createAsyncHook(emptyEnvProvider()); + HttpRequest request = createMockRequest(); + SDKConfiguration config = createConfig("2026-10-15", true); + + CompletableFuture fut = hook.beforeRequest(createMockContext(config), request); + HttpRequest result = fut.join(); + + assertEquals("2026-10-15", result.headers().firstValue(HEADER_EXCLUDE_DEPRECATED_AFTER).orElse(null)); + assertEquals("true", result.headers().firstValue(HEADER_EXPERIMENTAL).orElse(null)); + } + } + + @Nested + class BackwardCompatibilityWithGeneratedSDKConfiguration { + + @Test + void shouldFallBackToSDKConfigurationWhenRegistryIsEmpty() throws Exception { + Hook.BeforeRequest hook = XGleanHeadersHook.createSyncHook(emptyEnvProvider()); + HttpRequest request = createMockRequest(); + SDKConfiguration config = new SDKConfiguration(); + + boolean setDeprecatedAfter = tryInvokeOptionalSetter(config, "setExcludeDeprecatedAfter", Optional.of("2029-12-31")); + boolean setExperimental = tryInvokeOptionalSetter(config, "setIncludeExperimental", Optional.of(true)); + + HttpRequest result = hook.beforeRequest(createMockContext(config), request); + + if (setDeprecatedAfter) { + assertEquals("2029-12-31", result.headers().firstValue(HEADER_EXCLUDE_DEPRECATED_AFTER).orElse(null)); + } else { + assertFalse(result.headers().firstValue(HEADER_EXCLUDE_DEPRECATED_AFTER).isPresent()); + } + + if (setExperimental) { + assertEquals("true", result.headers().firstValue(HEADER_EXPERIMENTAL).orElse(null)); + } else { + assertFalse(result.headers().firstValue(HEADER_EXPERIMENTAL).isPresent()); + } + } + } + + private static boolean tryInvokeOptionalSetter(Object target, String methodName, Optional value) { + try { + Method m = target.getClass().getMethod(methodName, Optional.class); + m.invoke(target, value); + return true; + } catch (NoSuchMethodException e) { + return false; + } catch (Exception e) { + return false; + } + } }