diff --git a/.speakeasy/gen.yaml b/.speakeasy/gen.yaml index 5beda5e0..90540f02 100644 --- a/.speakeasy/gen.yaml +++ b/.speakeasy/gen.yaml @@ -38,7 +38,37 @@ typescript: dependencies: {} devDependencies: {} peerDependencies: {} - additionalPackageJSON: {} + additionalPackageJSON: + exports: + ./hooks: + import: + "@gleanwork/api-client/source": ./src/hooks/index.ts + types: ./dist/esm/hooks/index.d.ts + default: ./dist/esm/hooks/index.js + require: + types: ./dist/commonjs/hooks/index.d.ts + default: ./dist/commonjs/hooks/index.js + ./hooks/*.js: + import: + "@gleanwork/api-client/source": ./src/hooks/*.ts + types: ./dist/esm/hooks/*.d.ts + default: ./dist/esm/hooks/*.js + require: + types: ./dist/commonjs/hooks/*.d.ts + default: ./dist/commonjs/hooks/*.js + ./hooks/*: + import: + "@gleanwork/api-client/source": ./src/hooks/*.ts + types: ./dist/esm/hooks/*.d.ts + default: ./dist/esm/hooks/*.js + require: + types: ./dist/commonjs/hooks/*.d.ts + default: ./dist/commonjs/hooks/*.js + tshy: + exports: + ./hooks: ./src/hooks/index.ts + ./hooks/*.js: ./src/hooks/*.ts + ./hooks/*: ./src/hooks/*.ts additionalScripts: {} alwaysIncludeInboundAndOutbound: false author: Speakeasy diff --git a/README.md b/README.md index 0fc35f4e..a32a23cd 100644 --- a/README.md +++ b/README.md @@ -1167,13 +1167,17 @@ const glean = new Glean({ ```typescript import { Glean } from "@gleanwork/api-client"; +import type { SDKOptions } from "@gleanwork/api-client"; +import type { XGleanOptions } from "@gleanwork/api-client/hooks/x-glean-options.js"; -const glean = new Glean({ +const opts = { apiToken: process.env["GLEAN_API_TOKEN"] ?? "", instance: process.env["GLEAN_INSTANCE"] ?? "", - excludeDeprecatedAfter: '2026-10-15', + excludeDeprecatedAfter: "2026-10-15", includeExperimental: true, -}); +} satisfies SDKOptions & XGleanOptions; + +const glean = new Glean(opts); ``` ### Option Reference diff --git a/package.json b/package.json index 14e089d9..a4e66b61 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "./models/components": "./src/models/components/index.ts", "./models/operations": "./src/models/operations/index.ts", "./react-query": "./src/react-query/index.ts", + "./hooks": "./src/hooks/index.ts", + "./hooks/*.js": "./src/hooks/*.ts", + "./hooks/*": "./src/hooks/*.ts", "./*.js": "./src/*.ts", "./*": "./src/*.ts" } @@ -130,6 +133,39 @@ "default": "./dist/commonjs/react-query/index.js" } }, + "./hooks": { + "import": { + "@gleanwork/api-client/source": "./src/hooks/index.ts", + "types": "./dist/esm/hooks/index.d.ts", + "default": "./dist/esm/hooks/index.js" + }, + "require": { + "types": "./dist/commonjs/hooks/index.d.ts", + "default": "./dist/commonjs/hooks/index.js" + } + }, + "./hooks/*.js": { + "import": { + "@gleanwork/api-client/source": "./src/hooks/*.ts", + "types": "./dist/esm/hooks/*.d.ts", + "default": "./dist/esm/hooks/*.js" + }, + "require": { + "types": "./dist/commonjs/hooks/*.d.ts", + "default": "./dist/commonjs/hooks/*.js" + } + }, + "./hooks/*": { + "import": { + "@gleanwork/api-client/source": "./src/hooks/*.ts", + "types": "./dist/esm/hooks/*.d.ts", + "default": "./dist/esm/hooks/*.js" + }, + "require": { + "types": "./dist/commonjs/hooks/*.d.ts", + "default": "./dist/commonjs/hooks/*.js" + } + }, "./*.js": { "import": { "@gleanwork/api-client/source": "./src/*.ts", diff --git a/src/__tests__/x-glean.test.ts b/src/__tests__/x-glean.test.ts index ad9927ca..178cd1bc 100644 --- a/src/__tests__/x-glean.test.ts +++ b/src/__tests__/x-glean.test.ts @@ -151,6 +151,34 @@ describe("XGlean hook", () => { ); expect(result.headers.get("X-Glean-Experimental")).toBe("true"); }); + + it("should not set X-Glean-Experimental header when environment variable is false", () => { + process.env["X_GLEAN_INCLUDE_EXPERIMENTAL"] = "false"; + + const hook = new XGlean(); + const request = createMockRequest(); + const context = createMockContext({ + includeExperimental: true, + }); + + const result = hook.beforeRequest(context, request); + + expect(result.headers.has("X-Glean-Experimental")).toBe(false); + }); + + it("should fall back to SDK options when experimental env var is empty", () => { + process.env["X_GLEAN_INCLUDE_EXPERIMENTAL"] = " "; + + const hook = new XGlean(); + const request = createMockRequest(); + const context = createMockContext({ + includeExperimental: true, + }); + + const result = hook.beforeRequest(context, request); + + expect(result.headers.get("X-Glean-Experimental")).toBe("true"); + }); }); describe("environment variables take precedence over SDK options", () => { @@ -202,5 +230,33 @@ describe("XGlean hook", () => { ); expect(result.headers.get("X-Glean-Experimental")).toBe("true"); }); + + it("should omit X-Glean-Experimental header when env var is false, even if option is true", () => { + process.env["X_GLEAN_INCLUDE_EXPERIMENTAL"] = "false"; + + const hook = new XGlean(); + const request = createMockRequest(); + const context = createMockContext({ + includeExperimental: true, + }); + + const result = hook.beforeRequest(context, request); + + expect(result.headers.has("X-Glean-Experimental")).toBe(false); + }); + }); + + describe("when options are malformed at runtime", () => { + it("should not set X-Glean-Exclude-Deprecated-After when excludeDeprecatedAfter is not a string", () => { + const hook = new XGlean(); + const request = createMockRequest(); + const context = createMockContext({ + excludeDeprecatedAfter: 123 as any, + }); + + const result = hook.beforeRequest(context, request); + + expect(result.headers.has("X-Glean-Exclude-Deprecated-After")).toBe(false); + }); }); }); diff --git a/src/hooks/x-glean-options.ts b/src/hooks/x-glean-options.ts index f4a20d39..04625fc8 100644 --- a/src/hooks/x-glean-options.ts +++ b/src/hooks/x-glean-options.ts @@ -20,10 +20,10 @@ export interface XGleanOptions { * * More information: https://developers.glean.com/deprecations/overview */ - excludeDeprecatedAfter?: string | undefined; + excludeDeprecatedAfter?: string; /** * When true, enables experimental API features that are not yet generally available. * Use this to preview and test new functionality. */ - includeExperimental?: boolean | undefined; + includeExperimental?: boolean; } diff --git a/src/hooks/x-glean.ts b/src/hooks/x-glean.ts index 01a0fc5f..ddcccd29 100644 --- a/src/hooks/x-glean.ts +++ b/src/hooks/x-glean.ts @@ -1,5 +1,4 @@ import { BeforeRequestContext, BeforeRequestHook } from "./types.js"; -import { XGleanOptions } from "./x-glean-options.js"; /** * Get the first non-empty value from the provided arguments. @@ -8,26 +7,57 @@ function getFirstValue( aValue: string | undefined, bValue: string | undefined, ): string | false { - if (aValue) return aValue; - if (bValue) return bValue; + if (aValue && aValue.trim()) return aValue.trim(); + if (bValue && bValue.trim()) return bValue.trim(); return false; } +function getStringOption( + options: Record, + key: string, +): string | undefined { + const v = options[key]; + return typeof v === "string" && v.trim() ? v.trim() : undefined; +} + +function getBooleanOption(options: Record, key: string): boolean { + return options[key] === true; +} + export class XGlean implements BeforeRequestHook { beforeRequest(hookCtx: BeforeRequestContext, request: Request): Request { - // Cast options to include X-Glean custom properties - // These properties may be passed by users but aren't in the generated SDKOptions type - const options = hookCtx.options as XGleanOptions; + const opt = hookCtx.options as unknown; + const options = typeof opt === "object" && opt != null + ? (opt as Record) + : {}; const deprecatedValue = getFirstValue( process.env["X_GLEAN_EXCLUDE_DEPRECATED_AFTER"], - options.excludeDeprecatedAfter, + getStringOption(options, "excludeDeprecatedAfter"), ); - const experimentalValue = getFirstValue( - process.env["X_GLEAN_INCLUDE_EXPERIMENTAL"], - options.includeExperimental === true ? "true" : undefined, - ); + const experimentalEnv = process.env["X_GLEAN_INCLUDE_EXPERIMENTAL"]; + let experimentalValue: string | false = false; + if (typeof experimentalEnv !== "undefined") { + const t = experimentalEnv.trim(); + // Env var is treated as a boolean toggle. + // - "true" (case-insensitive) => set header + // - anything else (including "false") => omit header + // - empty/whitespace => treat as unset and fall back to options + if (!t) { + experimentalValue = getBooleanOption(options, "includeExperimental") + ? "true" + : false; + } else if (t.toLowerCase() === "true") { + experimentalValue = "true"; + } else { + experimentalValue = false; + } + } else { + experimentalValue = getBooleanOption(options, "includeExperimental") + ? "true" + : false; + } if (deprecatedValue) { request.headers.set("X-Glean-Exclude-Deprecated-After", deprecatedValue);