diff --git a/README.md b/README.md index cb22c4ce..b2cc5a39 100644 --- a/README.md +++ b/README.md @@ -1626,10 +1626,12 @@ Glean glean = Glean.builder() .build(); ``` -#### Using SDK Constructor Options +#### Using GleanBuilder (regen-safe) ```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") @@ -1637,6 +1639,8 @@ Glean glean = Glean.builder() .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 new file mode 100644 index 00000000..701f259d --- /dev/null +++ b/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanBuilder.java @@ -0,0 +1,247 @@ +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; + +/** + * 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 Optional excludeDeprecatedAfter = Optional.empty(); + private Optional includeExperimental = Optional.empty(); + + 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) { + this.excludeDeprecatedAfter = 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) { + this.includeExperimental = Optional.of(includeExperimental); + return this; + } + + /** + * Builds a new instance of the Glean SDK. + * + * @return The configured Glean instance. + */ + public Glean 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 new file mode 100644 index 00000000..b8e217ec --- /dev/null +++ b/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanCustomConfig.java @@ -0,0 +1,41 @@ +package com.glean.api_client.glean_api_client.hooks; + +import java.util.Optional; + +/** + * 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 are associated with a specific SDK instance via {@link GleanCustomConfigRegistry}. + */ +public final class GleanCustomConfig { + + private final Optional excludeDeprecatedAfter; + private final Optional includeExperimental; + + public GleanCustomConfig(Optional excludeDeprecatedAfter, Optional includeExperimental) { + this.excludeDeprecatedAfter = excludeDeprecatedAfter != null ? excludeDeprecatedAfter : Optional.empty(); + this.includeExperimental = includeExperimental != null ? includeExperimental : Optional.empty(); + } + + /** + * 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; + } + + /** + * Gets whether experimental API features should be enabled. + * + * @return Optional containing the boolean value if set + */ + public Optional includeExperimental() { + return includeExperimental; + } +} 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 5147f1b5..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 @@ -5,6 +5,7 @@ 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; @@ -85,12 +86,16 @@ static AsyncHook.BeforeRequest createAsyncHook(Function envProvi }; } - private static void addHeaders(HttpRequest.Builder builder, SDKConfiguration config, + private static void addHeaders(HttpRequest.Builder builder, + SDKConfiguration sdkConfiguration, Function envProvider) { + Optional customConfig = GleanCustomConfigRegistry.get(sdkConfiguration); + // Get deprecated after value - environment variable takes precedence Optional deprecatedAfterValue = getFirstNonEmpty( getEnv(ENV_EXCLUDE_DEPRECATED_AFTER, envProvider), - config.excludeDeprecatedAfter() + customConfig.flatMap(GleanCustomConfig::excludeDeprecatedAfter), + getSdkConfigurationExcludeDeprecatedAfter(sdkConfiguration) ); deprecatedAfterValue.ifPresent(value -> @@ -100,7 +105,8 @@ 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.flatMap(GleanCustomConfig::includeExperimental).filter(b -> b).map(b -> "true"), + getSdkConfigurationIncludeExperimental(sdkConfiguration).filter(b -> b).map(b -> "true") ); experimentalValue.ifPresent(value -> @@ -123,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 e3182b99..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 @@ -4,14 +4,17 @@ 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; 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.*; @@ -21,6 +24,11 @@ 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() { + GleanCustomConfigRegistry.clearForTests(); + } + private HttpRequest createMockRequest() { return HttpRequest.newBuilder() .uri(URI.create("https://example.com/api/test")) @@ -40,11 +48,11 @@ private Hook.BeforeRequestContext createMockContext(SDKConfiguration config) { private SDKConfiguration createConfig(String excludeDeprecatedAfter, Boolean includeExperimental) { SDKConfiguration config = new SDKConfiguration(); - if (excludeDeprecatedAfter != null) { - config.setExcludeDeprecatedAfter(Optional.of(excludeDeprecatedAfter)); - } - if (includeExperimental != null) { - config.setIncludeExperimental(Optional.of(includeExperimental)); + if (excludeDeprecatedAfter != null || includeExperimental != null) { + GleanCustomConfigRegistry.put( + config, + new GleanCustomConfig(Optional.ofNullable(excludeDeprecatedAfter), Optional.ofNullable(includeExperimental)) + ); } return config; } @@ -249,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; + } + } }