From d1189c55590cb109e1c351cc93840b3afb860ba3 Mon Sep 17 00:00:00 2001 From: Chris Freeman Date: Fri, 27 Feb 2026 14:37:54 -0700 Subject: [PATCH 1/4] feat: add server URL normalization hook Add SDKInit hook that normalizes server URLs: strips trailing slashes and preserves http/https schemes. Consistent with Go/Python/Java SDKs. --- src/hooks/registration.ts | 2 ++ src/hooks/server-url-normalizer.ts | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 src/hooks/server-url-normalizer.ts diff --git a/src/hooks/registration.ts b/src/hooks/registration.ts index 2b00fb65..287c71c4 100644 --- a/src/hooks/registration.ts +++ b/src/hooks/registration.ts @@ -1,5 +1,6 @@ import { Hooks, AfterErrorHook } from "./types.js"; import { XGlean } from "./x-glean.js"; +import { serverURLNormalizerHook } from "./server-url-normalizer.js"; /* * This file is only ever generated once on the first generation and then is free to be modified. @@ -47,6 +48,7 @@ export function initHooks(hooks: Hooks) { // Add hooks by calling hooks.register{ClientInit/BeforeCreateRequest/BeforeRequest/AfterSuccess/AfterError}Hook // with an instance of a hook that implements that specific Hook interface // Hooks are registered per SDK instance, and are valid for the lifetime of the SDK instance + hooks.registerSDKInitHook(serverURLNormalizerHook); hooks.registerAfterErrorHook(agentFileUploadErrorHook); // Register the X-Glean header hook for experimental features and deprecation testing diff --git a/src/hooks/server-url-normalizer.ts b/src/hooks/server-url-normalizer.ts new file mode 100644 index 00000000..b376dc7e --- /dev/null +++ b/src/hooks/server-url-normalizer.ts @@ -0,0 +1,20 @@ +import { SDKInitHook, SDKInitOptions } from "./types.js"; + +export function normalizeServerURL(url: string): string { + let normalized = url; + if (!/^https?:\/\//i.test(normalized)) { + normalized = `https://${normalized}`; + } + normalized = normalized.replace(/\/+$/, ""); + return normalized; +} + +export const serverURLNormalizerHook: SDKInitHook = { + sdkInit(opts: SDKInitOptions): SDKInitOptions { + if (opts.baseURL) { + const normalized = normalizeServerURL(opts.baseURL.toString()); + opts.baseURL = new URL(normalized); + } + return opts; + }, +}; From bccc97f937e7a1acec7d1e047883bcad0639ef50 Mon Sep 17 00:00:00 2001 From: Chris Freeman Date: Fri, 27 Feb 2026 14:38:02 -0700 Subject: [PATCH 2/4] feat: normalize schemeless serverURL in config.ts before URL parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Speakeasy-generated serverURLFromOptions() calls new URL() which throws on schemeless input like "mycompany-be.glean.com". This edit adds normalization (prepend https://, strip trailing slashes) before the URL constructor, matching the behavior of Go/Python/Java SDKs. This is a Custom Code edit to a generated file — Speakeasy's 3-way merge will preserve it across regenerations. --- src/__tests__/server-url-normalizer.test.ts | 113 ++++++++++++++++++++ src/lib/config.ts | 6 +- 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/server-url-normalizer.test.ts diff --git a/src/__tests__/server-url-normalizer.test.ts b/src/__tests__/server-url-normalizer.test.ts new file mode 100644 index 00000000..4a086564 --- /dev/null +++ b/src/__tests__/server-url-normalizer.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeServerURL, + serverURLNormalizerHook, +} from "../hooks/server-url-normalizer.js"; +import { HTTPClient } from "../lib/http.js"; +import { SDKInitOptions } from "../hooks/types.js"; +import { serverURLFromOptions } from "../lib/config.js"; + +describe("normalizeServerURL", () => { + it("should prepend https:// when no scheme is provided", () => { + expect(normalizeServerURL("example.glean.com")).toBe( + "https://example.glean.com", + ); + }); + + it("should preserve https:// scheme", () => { + expect(normalizeServerURL("https://example.glean.com")).toBe( + "https://example.glean.com", + ); + }); + + it("should preserve http:// for localhost", () => { + expect(normalizeServerURL("http://localhost:8080")).toBe( + "http://localhost:8080", + ); + }); + + it("should preserve http:// for non-localhost URLs", () => { + expect(normalizeServerURL("http://example.glean.com")).toBe( + "http://example.glean.com", + ); + }); + + it("should strip trailing slashes", () => { + expect(normalizeServerURL("https://example.glean.com/")).toBe( + "https://example.glean.com", + ); + }); + + it("should strip multiple trailing slashes", () => { + expect(normalizeServerURL("https://example.glean.com///")).toBe( + "https://example.glean.com", + ); + }); + + it("should preserve URLs with paths", () => { + expect(normalizeServerURL("https://example.glean.com/api/v1")).toBe( + "https://example.glean.com/api/v1", + ); + }); + + it("should handle no scheme with trailing slash", () => { + expect(normalizeServerURL("example.glean.com/")).toBe( + "https://example.glean.com", + ); + }); +}); + +describe("serverURLNormalizerHook", () => { + function createOpts(baseURL: string | null): SDKInitOptions { + return { + baseURL: baseURL ? new URL(baseURL) : null, + client: new HTTPClient(), + }; + } + + it("should normalize the baseURL when present", () => { + const opts = { + baseURL: new URL("https://example.glean.com/"), + client: new HTTPClient(), + }; + + const result = serverURLNormalizerHook.sdkInit(opts); + + expect(result.baseURL?.toString()).toBe("https://example.glean.com/"); + }); + + it("should return opts unchanged when baseURL is null", () => { + const opts = createOpts(null); + + const result = serverURLNormalizerHook.sdkInit(opts); + + expect(result.baseURL).toBeNull(); + }); +}); + +describe("serverURLFromOptions (config.ts normalization)", () => { + it("should handle schemeless serverURL", () => { + const url = serverURLFromOptions({ serverURL: "mycompany-be.glean.com" }); + expect(url?.origin).toBe("https://mycompany-be.glean.com"); + }); + + it("should handle serverURL with https scheme", () => { + const url = serverURLFromOptions({ serverURL: "https://mycompany-be.glean.com" }); + expect(url?.origin).toBe("https://mycompany-be.glean.com"); + }); + + it("should handle serverURL with http scheme", () => { + const url = serverURLFromOptions({ serverURL: "http://localhost:8080" }); + expect(url?.origin).toBe("http://localhost:8080"); + }); + + it("should handle serverURL with trailing slashes", () => { + const url = serverURLFromOptions({ serverURL: "https://mycompany-be.glean.com///" }); + expect(url?.origin).toBe("https://mycompany-be.glean.com"); + }); + + it("should handle schemeless serverURL with trailing slash", () => { + const url = serverURLFromOptions({ serverURL: "mycompany-be.glean.com/" }); + expect(url?.origin).toBe("https://mycompany-be.glean.com"); + }); +}); diff --git a/src/lib/config.ts b/src/lib/config.ts index 715aecd5..9399728d 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -61,7 +61,11 @@ export function serverURLFromOptions(options: SDKOptions): URL | null { params = serverParams[serverIdx] || {}; } - const u = pathToFunc(serverURL)(params); + let u = pathToFunc(serverURL)(params); + if (u && !/^https?:\/\//i.test(u)) { + u = `https://${u}`; + } + u = u.replace(/\/+$/, ""); return new URL(u); } From b8386871c6df8df321b5fe19cce5ce7d09f7f3db Mon Sep 17 00:00:00 2001 From: Chris Freeman Date: Wed, 4 Mar 2026 09:48:56 -0700 Subject: [PATCH 3/4] docs: update hand-authored content to use serverURL, enable persistent edits - Update README preamble and Experimental Features examples to use serverURL instead of instance - Update indexing.test-d.ts to use serverURL/GLEAN_SERVER_URL - Enable persistentEdits in gen.yaml to preserve the config.ts schemeless URL normalization across Speakeasy regenerations --- .speakeasy/gen.yaml | 3 ++- README.md | 8 ++++---- src/__tests__/indexing.test-d.ts | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.speakeasy/gen.yaml b/.speakeasy/gen.yaml index 6152b05f..07628c77 100644 --- a/.speakeasy/gen.yaml +++ b/.speakeasy/gen.yaml @@ -27,7 +27,8 @@ generation: versioningStrategy: automatic mockServer: disabled: false - persistentEdits: {} + persistentEdits: + enabled: true tests: generateTests: true generateNewTests: true diff --git a/README.md b/README.md index 603b3ad8..fbadc6b9 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Each namespace has its own authentication requirements and access patterns. Whil ```typescript // Example of accessing Client namespace const glean = new Glean({ - instance: 'instance-name', + serverURL: "mycompany-be.glean.com", apiToken: 'client-token' }); @@ -24,7 +24,7 @@ await glean.client.search.query({ // Example of accessing Indexing namespace const glean = new Glean({ - instance: 'instance-name', + serverURL: "mycompany-be.glean.com", apiToken: 'indexing-token' }); @@ -1169,7 +1169,7 @@ import { Glean } from "@gleanwork/api-client"; const glean = new Glean({ apiToken: process.env["GLEAN_API_TOKEN"] ?? "", - instance: process.env["GLEAN_INSTANCE"] ?? "", + serverURL: "mycompany-be.glean.com", }); ``` @@ -1182,7 +1182,7 @@ import type { XGleanOptions } from "@gleanwork/api-client/hooks/x-glean-options. const opts = { apiToken: process.env["GLEAN_API_TOKEN"] ?? "", - instance: process.env["GLEAN_INSTANCE"] ?? "", + serverURL: "mycompany-be.glean.com", excludeDeprecatedAfter: "2026-10-15", includeExperimental: true, } satisfies SDKOptions & XGleanOptions; diff --git a/src/__tests__/indexing.test-d.ts b/src/__tests__/indexing.test-d.ts index eec60088..a269a80b 100644 --- a/src/__tests__/indexing.test-d.ts +++ b/src/__tests__/indexing.test-d.ts @@ -6,7 +6,7 @@ test("type test for `Glean` constructor", () => { async function run() { const glean = new Glean({ apiToken: process.env["GLEAN_API_TOKEN"], - instance: process.env["GLEAN_INSTANCE"], + serverURL: process.env["GLEAN_SERVER_URL"], }); const response = await glean.indexing.documents.index({ @@ -34,7 +34,7 @@ test("type test for `Glean` constructor", () => { const correctClient = new Glean({ apiToken: "token", - instance: "example-instance", + serverURL: "mycompany-be.glean.com", }); expectTypeOf(correctClient).toEqualTypeOf(); }); From b3a27e52514fbd34890898da168c980d9d249678 Mon Sep 17 00:00:00 2001 From: Chris Freeman Date: Wed, 4 Mar 2026 10:01:21 -0700 Subject: [PATCH 4/4] fix: use fully qualified https:// URLs in documentation and tests --- README.md | 8 ++++---- src/__tests__/indexing.test-d.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fbadc6b9..97a3818b 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Each namespace has its own authentication requirements and access patterns. Whil ```typescript // Example of accessing Client namespace const glean = new Glean({ - serverURL: "mycompany-be.glean.com", + serverURL: "https://mycompany-be.glean.com", apiToken: 'client-token' }); @@ -24,7 +24,7 @@ await glean.client.search.query({ // Example of accessing Indexing namespace const glean = new Glean({ - serverURL: "mycompany-be.glean.com", + serverURL: "https://mycompany-be.glean.com", apiToken: 'indexing-token' }); @@ -1169,7 +1169,7 @@ import { Glean } from "@gleanwork/api-client"; const glean = new Glean({ apiToken: process.env["GLEAN_API_TOKEN"] ?? "", - serverURL: "mycompany-be.glean.com", + serverURL: "https://mycompany-be.glean.com", }); ``` @@ -1182,7 +1182,7 @@ import type { XGleanOptions } from "@gleanwork/api-client/hooks/x-glean-options. const opts = { apiToken: process.env["GLEAN_API_TOKEN"] ?? "", - serverURL: "mycompany-be.glean.com", + serverURL: "https://mycompany-be.glean.com", excludeDeprecatedAfter: "2026-10-15", includeExperimental: true, } satisfies SDKOptions & XGleanOptions; diff --git a/src/__tests__/indexing.test-d.ts b/src/__tests__/indexing.test-d.ts index a269a80b..2d3f1bb4 100644 --- a/src/__tests__/indexing.test-d.ts +++ b/src/__tests__/indexing.test-d.ts @@ -34,7 +34,7 @@ test("type test for `Glean` constructor", () => { const correctClient = new Glean({ apiToken: "token", - serverURL: "mycompany-be.glean.com", + serverURL: "https://mycompany-be.glean.com", }); expectTypeOf(correctClient).toEqualTypeOf(); });