diff --git a/README.md b/README.md index ca098ba9..cb22c4ce 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Remember that each namespace requires its own authentication token type as descr * [Server Selection](#server-selection) * [Custom HTTP Client](#custom-http-client) * [Debugging](#debugging) + * [Experimental Features and Deprecation Testing](#experimental-features-and-deprecation-testing) * [Development](#development) * [Maturity](#maturity) * [Contributions](#contributions) @@ -1598,6 +1599,55 @@ __NOTE__: This is a convenience method that calls `HTTPClient.enableDebugLogging Another option is to set the System property `-Djdk.httpclient.HttpClient.log=all`. However, this second option does not log bodies. +## Experimental Features and Deprecation Testing + +The SDK provides options to test upcoming API changes before they become the default behavior. This is useful for: + +- **Testing experimental features** before they are generally available +- **Preparing for deprecations** by excluding deprecated endpoints ahead of their removal + +### Configuration Options + +You can configure these options either via environment variables or SDK constructor options: + +#### Using Environment Variables + +```bash +# Set environment variables before running your application +export X_GLEAN_EXCLUDE_DEPRECATED_AFTER="2026-10-15" +export X_GLEAN_INCLUDE_EXPERIMENTAL="true" +``` + +```java +// Environment variables are automatically read by the SDK +Glean glean = Glean.builder() + .apiToken(System.getenv("GLEAN_API_TOKEN")) + .instance("instance-name") + .build(); +``` + +#### Using SDK Constructor Options + +```java +Glean glean = Glean.builder() + .apiToken(System.getenv("GLEAN_API_TOKEN")) + .instance("instance-name") + .excludeDeprecatedAfter("2026-10-15") + .includeExperimental(true) + .build(); +``` + +### Option Reference + +| Option | Environment Variable | Type | Description | +| ------ | -------------------- | ---- | ----------- | +| `excludeDeprecatedAfter` | `X_GLEAN_EXCLUDE_DEPRECATED_AFTER` | `String` (date) | Exclude API endpoints that will be deprecated after this date (format: `YYYY-MM-DD`). Use this to test your integration against upcoming deprecations. | +| `includeExperimental` | `X_GLEAN_INCLUDE_EXPERIMENTAL` | `boolean` | When `true`, enables experimental API features that are not yet generally available. Use this to preview and test new functionality. | + +> **Note:** Environment variables take precedence over SDK constructor options when both are set. + +> **Warning:** Experimental features may change or be removed without notice. Do not rely on experimental features in production environments. + # Development diff --git a/build-extras.gradle b/build-extras.gradle index e104d4f6..3a9e7ba5 100644 --- a/build-extras.gradle +++ b/build-extras.gradle @@ -1,4 +1,13 @@ -// This file +// This file // * is referred to in an `apply from` command in `build.gradle` // * can be used to customise `build.gradle` // * is generated once and not overwritten in SDK generation updates + +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +test { + useJUnitPlatform() +} diff --git a/src/main/java/com/glean/api_client/glean_api_client/Glean.java b/src/main/java/com/glean/api_client/glean_api_client/Glean.java index 24f6ef00..19103561 100644 --- a/src/main/java/com/glean/api_client/glean_api_client/Glean.java +++ b/src/main/java/com/glean/api_client/glean_api_client/Glean.java @@ -204,6 +204,35 @@ public Builder instance(String instance) { 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 The builder instance. + */ + public Builder excludeDeprecatedAfter(String excludeDeprecatedAfter) { + this.sdkConfiguration.setExcludeDeprecatedAfter(Optional.of(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 The builder instance. + */ + public Builder includeExperimental(boolean includeExperimental) { + this.sdkConfiguration.setIncludeExperimental(Optional.of(includeExperimental)); + return this; + } // Visible for testing, may be accessed via reflection in tests Builder _hooks(com.glean.api_client.glean_api_client.utils.Hooks hooks) { sdkConfiguration.setHooks(hooks); diff --git a/src/main/java/com/glean/api_client/glean_api_client/SDKConfiguration.java b/src/main/java/com/glean/api_client/glean_api_client/SDKConfiguration.java index ddcd0f25..24dfee1d 100644 --- a/src/main/java/com/glean/api_client/glean_api_client/SDKConfiguration.java +++ b/src/main/java/com/glean/api_client/glean_api_client/SDKConfiguration.java @@ -128,7 +128,7 @@ public Map getServerVariableDefaults() { return serverVariables.get(this.serverIdx); } private Optional retryConfig = Optional.empty(); - + public Optional retryConfig() { return retryConfig; } @@ -137,6 +137,50 @@ public void setRetryConfig(Optional retryConfig) { Utils.checkNotNull(retryConfig, "retryConfig"); this.retryConfig = retryConfig; } + + private Optional excludeDeprecatedAfter = 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; + } + + /** + * 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 + */ + public void setExcludeDeprecatedAfter(Optional excludeDeprecatedAfter) { + Utils.checkNotNull(excludeDeprecatedAfter, "excludeDeprecatedAfter"); + this.excludeDeprecatedAfter = excludeDeprecatedAfter; + } + + private Optional includeExperimental = 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 + */ + public void setIncludeExperimental(Optional includeExperimental) { + Utils.checkNotNull(includeExperimental, "includeExperimental"); + this.includeExperimental = includeExperimental; + } private ScheduledExecutorService retryScheduler = Executors.newSingleThreadScheduledExecutor(); public ScheduledExecutorService retryScheduler() { diff --git a/src/main/java/com/glean/api_client/glean_api_client/hooks/SDKHooks.java b/src/main/java/com/glean/api_client/glean_api_client/hooks/SDKHooks.java index c6e578f1..5c2cb97e 100644 --- a/src/main/java/com/glean/api_client/glean_api_client/hooks/SDKHooks.java +++ b/src/main/java/com/glean/api_client/glean_api_client/hooks/SDKHooks.java @@ -15,6 +15,9 @@ private SDKHooks() { public static void initialize(com.glean.api_client.glean_api_client.utils.Hooks hooks) { hooks.registerAfterError(AgentFileUploadErrorHook.createSyncHook()); + // Register the X-Glean header hook for experimental features and deprecation testing + hooks.registerBeforeRequest(XGleanHeadersHook.createSyncHook()); + // for more information see // https://www.speakeasy.com/docs/additional-features/sdk-hooks } @@ -22,6 +25,9 @@ public static void initialize(com.glean.api_client.glean_api_client.utils.Hooks public static void initialize(com.glean.api_client.glean_api_client.utils.AsyncHooks asyncHooks) { asyncHooks.registerAfterError(AgentFileUploadErrorHook.createAsyncHook()); + // Register the X-Glean header hook for experimental features and deprecation testing + asyncHooks.registerBeforeRequest(XGleanHeadersHook.createAsyncHook()); + // NOTE: If you have existing synchronous hooks, you can adapt them using HookAdapters: // asyncHooks.registerAfterError(com.glean.api_client.glean_api_client.utils.HookAdapters.adapt(mySyncHook)); 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 new file mode 100644 index 00000000..5147f1b5 --- /dev/null +++ b/src/main/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHook.java @@ -0,0 +1,139 @@ +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.net.http.HttpRequest; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +/** + * Hook that adds X-Glean headers for experimental features and deprecation testing. + * + *

This hook sets the following headers based on SDK options or environment variables: + *

+ * + *

Environment variables take precedence over SDK constructor options: + *

+ */ +public final class XGleanHeadersHook { + + static final String ENV_EXCLUDE_DEPRECATED_AFTER = "X_GLEAN_EXCLUDE_DEPRECATED_AFTER"; + static final String ENV_INCLUDE_EXPERIMENTAL = "X_GLEAN_INCLUDE_EXPERIMENTAL"; + + static final String HEADER_EXCLUDE_DEPRECATED_AFTER = "X-Glean-Exclude-Deprecated-After"; + static final String HEADER_EXPERIMENTAL = "X-Glean-Experimental"; + + private XGleanHeadersHook() { + // prevent instantiation + } + + /** + * Creates a synchronous BeforeRequest hook for adding X-Glean headers. + * + * @return the sync hook + */ + public static Hook.BeforeRequest createSyncHook() { + return createSyncHook(System::getenv); + } + + /** + * Creates a synchronous BeforeRequest hook for adding X-Glean headers. + * This variant accepts a custom environment variable provider for testing. + * + * @param envProvider function to get environment variables + * @return the sync hook + */ + static Hook.BeforeRequest createSyncHook(Function envProvider) { + return (context, request) -> { + HttpRequest.Builder builder = Helpers.copy(request); + addHeaders(builder, context.sdkConfiguration(), envProvider); + return builder.build(); + }; + } + + /** + * Creates an asynchronous BeforeRequest hook for adding X-Glean headers. + * + * @return the async hook + */ + public static AsyncHook.BeforeRequest createAsyncHook() { + return createAsyncHook(System::getenv); + } + + /** + * Creates an asynchronous BeforeRequest hook for adding X-Glean headers. + * This variant accepts a custom environment variable provider for testing. + * + * @param envProvider function to get environment variables + * @return the async hook + */ + static AsyncHook.BeforeRequest createAsyncHook(Function envProvider) { + return (context, request) -> { + HttpRequest.Builder builder = Helpers.copy(request); + addHeaders(builder, context.sdkConfiguration(), envProvider); + return CompletableFuture.completedFuture(builder.build()); + }; + } + + private static void addHeaders(HttpRequest.Builder builder, SDKConfiguration config, + Function envProvider) { + // Get deprecated after value - environment variable takes precedence + Optional deprecatedAfterValue = getFirstNonEmpty( + getEnv(ENV_EXCLUDE_DEPRECATED_AFTER, envProvider), + config.excludeDeprecatedAfter() + ); + + deprecatedAfterValue.ifPresent(value -> + builder.header(HEADER_EXCLUDE_DEPRECATED_AFTER, value) + ); + + // Get experimental value - environment variable takes precedence + Optional experimentalValue = getFirstNonEmpty( + getEnvAsBoolean(ENV_INCLUDE_EXPERIMENTAL, envProvider), + config.includeExperimental().filter(b -> b).map(b -> "true") + ); + + experimentalValue.ifPresent(value -> + builder.header(HEADER_EXPERIMENTAL, value) + ); + } + + /** + * Returns the first non-empty Optional from the provided arguments. + */ + @SafeVarargs + private static Optional getFirstNonEmpty(Optional... optionals) { + for (Optional opt : optionals) { + if (opt.isPresent()) { + return opt; + } + } + return Optional.empty(); + } + + private static Optional getEnv(String name, Function envProvider) { + String value = envProvider.apply(name); + if (value != null && !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)) { + return Optional.of("true"); + } + 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 new file mode 100644 index 00000000..e3182b99 --- /dev/null +++ b/src/test/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHookTest.java @@ -0,0 +1,252 @@ +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.SecuritySource; +import com.glean.api_client.glean_api_client.utils.Hook; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.net.http.HttpRequest; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.*; + +class XGleanHeadersHookTest { + + private static final String HEADER_EXCLUDE_DEPRECATED_AFTER = XGleanHeadersHook.HEADER_EXCLUDE_DEPRECATED_AFTER; + private static final String HEADER_EXPERIMENTAL = XGleanHeadersHook.HEADER_EXPERIMENTAL; + + private HttpRequest createMockRequest() { + return HttpRequest.newBuilder() + .uri(URI.create("https://example.com/api/test")) + .GET() + .build(); + } + + private Hook.BeforeRequestContext createMockContext(SDKConfiguration config) { + return new Hook.BeforeRequestContextImpl( + config, + "https://example.com", + "test-operation", + Optional.empty(), + Optional.of(SecuritySource.of(null)) + ); + } + + 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)); + } + return config; + } + + /** + * Creates a mock environment provider with the given key-value pairs. + */ + private Function createEnvProvider(Map env) { + return env::get; + } + + /** + * Creates an empty environment provider (no environment variables set). + */ + private Function emptyEnvProvider() { + return name -> null; + } + + @Nested + class WhenNeitherOptionsNorEnvironmentVariablesAreSet { + + @Test + void shouldNotSetAnyXGleanHeaders() throws Exception { + Hook.BeforeRequest hook = XGleanHeadersHook.createSyncHook(emptyEnvProvider()); + HttpRequest request = createMockRequest(); + SDKConfiguration config = createConfig(null, null); + Hook.BeforeRequestContext context = createMockContext(config); + + HttpRequest result = hook.beforeRequest(context, request); + + assertFalse(result.headers().firstValue(HEADER_EXCLUDE_DEPRECATED_AFTER).isPresent()); + assertFalse(result.headers().firstValue(HEADER_EXPERIMENTAL).isPresent()); + } + } + + @Nested + class WhenUsingSDKConstructorOptions { + + @Test + void shouldSetExcludeDeprecatedAfterHeaderFromOption() throws Exception { + Hook.BeforeRequest hook = XGleanHeadersHook.createSyncHook(emptyEnvProvider()); + HttpRequest request = createMockRequest(); + SDKConfiguration config = createConfig("2026-10-15", null); + Hook.BeforeRequestContext context = createMockContext(config); + + HttpRequest result = hook.beforeRequest(context, request); + + assertEquals("2026-10-15", result.headers().firstValue(HEADER_EXCLUDE_DEPRECATED_AFTER).orElse(null)); + } + + @Test + void shouldSetExperimentalHeaderWhenIncludeExperimentalIsTrue() throws Exception { + Hook.BeforeRequest hook = XGleanHeadersHook.createSyncHook(emptyEnvProvider()); + HttpRequest request = createMockRequest(); + SDKConfiguration config = createConfig(null, true); + Hook.BeforeRequestContext context = createMockContext(config); + + HttpRequest result = hook.beforeRequest(context, request); + + assertEquals("true", result.headers().firstValue(HEADER_EXPERIMENTAL).orElse(null)); + } + + @Test + void shouldNotSetExperimentalHeaderWhenIncludeExperimentalIsFalse() throws Exception { + Hook.BeforeRequest hook = XGleanHeadersHook.createSyncHook(emptyEnvProvider()); + HttpRequest request = createMockRequest(); + SDKConfiguration config = createConfig(null, false); + Hook.BeforeRequestContext context = createMockContext(config); + + HttpRequest result = hook.beforeRequest(context, request); + + assertFalse(result.headers().firstValue(HEADER_EXPERIMENTAL).isPresent()); + } + + @Test + void shouldSetBothHeadersWhenBothOptionsAreProvided() throws Exception { + Hook.BeforeRequest hook = XGleanHeadersHook.createSyncHook(emptyEnvProvider()); + HttpRequest request = createMockRequest(); + SDKConfiguration config = createConfig("2026-10-15", true); + Hook.BeforeRequestContext context = createMockContext(config); + + HttpRequest result = hook.beforeRequest(context, request); + + assertEquals("2026-10-15", result.headers().firstValue(HEADER_EXCLUDE_DEPRECATED_AFTER).orElse(null)); + assertEquals("true", result.headers().firstValue(HEADER_EXPERIMENTAL).orElse(null)); + } + } + + @Nested + class WhenUsingEnvironmentVariables { + + @Test + void shouldSetExcludeDeprecatedAfterHeaderFromEnvironmentVariable() throws Exception { + Map env = new HashMap<>(); + env.put(XGleanHeadersHook.ENV_EXCLUDE_DEPRECATED_AFTER, "2027-01-01"); + + Hook.BeforeRequest hook = XGleanHeadersHook.createSyncHook(createEnvProvider(env)); + HttpRequest request = createMockRequest(); + SDKConfiguration config = createConfig(null, null); + Hook.BeforeRequestContext context = createMockContext(config); + + HttpRequest result = hook.beforeRequest(context, request); + + assertEquals("2027-01-01", result.headers().firstValue(HEADER_EXCLUDE_DEPRECATED_AFTER).orElse(null)); + } + + @Test + void shouldSetExperimentalHeaderFromEnvironmentVariable() throws Exception { + Map env = new HashMap<>(); + env.put(XGleanHeadersHook.ENV_INCLUDE_EXPERIMENTAL, "true"); + + Hook.BeforeRequest hook = XGleanHeadersHook.createSyncHook(createEnvProvider(env)); + HttpRequest request = createMockRequest(); + SDKConfiguration config = createConfig(null, null); + Hook.BeforeRequestContext context = createMockContext(config); + + HttpRequest result = hook.beforeRequest(context, request); + + assertEquals("true", result.headers().firstValue(HEADER_EXPERIMENTAL).orElse(null)); + } + + @Test + void shouldSetBothHeadersFromEnvironmentVariables() throws Exception { + Map env = new HashMap<>(); + env.put(XGleanHeadersHook.ENV_EXCLUDE_DEPRECATED_AFTER, "2027-06-15"); + env.put(XGleanHeadersHook.ENV_INCLUDE_EXPERIMENTAL, "true"); + + Hook.BeforeRequest hook = XGleanHeadersHook.createSyncHook(createEnvProvider(env)); + HttpRequest request = createMockRequest(); + SDKConfiguration config = createConfig(null, null); + Hook.BeforeRequestContext context = createMockContext(config); + + HttpRequest result = hook.beforeRequest(context, request); + + assertEquals("2027-06-15", result.headers().firstValue(HEADER_EXCLUDE_DEPRECATED_AFTER).orElse(null)); + assertEquals("true", result.headers().firstValue(HEADER_EXPERIMENTAL).orElse(null)); + } + + @Test + void shouldIgnoreNonTrueValuesForExperimentalEnvironmentVariable() throws Exception { + Map env = new HashMap<>(); + env.put(XGleanHeadersHook.ENV_INCLUDE_EXPERIMENTAL, "false"); + + Hook.BeforeRequest hook = XGleanHeadersHook.createSyncHook(createEnvProvider(env)); + HttpRequest request = createMockRequest(); + SDKConfiguration config = createConfig(null, null); + Hook.BeforeRequestContext context = createMockContext(config); + + HttpRequest result = hook.beforeRequest(context, request); + + assertFalse(result.headers().firstValue(HEADER_EXPERIMENTAL).isPresent()); + } + } + + @Nested + class EnvironmentVariablesTakePrecedenceOverSDKOptions { + + @Test + void shouldUseEnvironmentVariableForExcludeDeprecatedAfterWhenBothAreSet() throws Exception { + Map env = new HashMap<>(); + env.put(XGleanHeadersHook.ENV_EXCLUDE_DEPRECATED_AFTER, "2027-12-31"); + + Hook.BeforeRequest hook = XGleanHeadersHook.createSyncHook(createEnvProvider(env)); + HttpRequest request = createMockRequest(); + SDKConfiguration config = createConfig("2026-01-01", null); + Hook.BeforeRequestContext context = createMockContext(config); + + HttpRequest result = hook.beforeRequest(context, request); + + assertEquals("2027-12-31", result.headers().firstValue(HEADER_EXCLUDE_DEPRECATED_AFTER).orElse(null)); + } + + @Test + void shouldUseEnvironmentVariableForIncludeExperimentalWhenBothAreSet() throws Exception { + Map env = new HashMap<>(); + env.put(XGleanHeadersHook.ENV_INCLUDE_EXPERIMENTAL, "true"); + + Hook.BeforeRequest hook = XGleanHeadersHook.createSyncHook(createEnvProvider(env)); + HttpRequest request = createMockRequest(); + SDKConfiguration config = createConfig(null, false); + Hook.BeforeRequestContext context = createMockContext(config); + + HttpRequest result = hook.beforeRequest(context, request); + + assertEquals("true", result.headers().firstValue(HEADER_EXPERIMENTAL).orElse(null)); + } + + @Test + void shouldUseEnvironmentVariablesForBothHeadersWhenAllAreSet() throws Exception { + Map env = new HashMap<>(); + env.put(XGleanHeadersHook.ENV_EXCLUDE_DEPRECATED_AFTER, "2028-01-01"); + env.put(XGleanHeadersHook.ENV_INCLUDE_EXPERIMENTAL, "true"); + + Hook.BeforeRequest hook = XGleanHeadersHook.createSyncHook(createEnvProvider(env)); + HttpRequest request = createMockRequest(); + SDKConfiguration config = createConfig("2026-06-01", false); + Hook.BeforeRequestContext context = createMockContext(config); + + HttpRequest result = hook.beforeRequest(context, request); + + assertEquals("2028-01-01", result.headers().firstValue(HEADER_EXCLUDE_DEPRECATED_AFTER).orElse(null)); + assertEquals("true", result.headers().firstValue(HEADER_EXPERIMENTAL).orElse(null)); + } + } +}